461 lines
19 KiB
TypeScript
461 lines
19 KiB
TypeScript
import { ExpressTemplate, ExpressScene, ExpressField, DesignMD, TimelineElement, TimelineLayer, TransitionType, CompanyProfile, BrandContentPiece } from '../types';
|
|
|
|
/**
|
|
* Resolves brand variable placeholders in field values.
|
|
* Uses CompanyProfile for social handles, tagline, etc.
|
|
* Uses DesignMD for visual brand properties.
|
|
*/
|
|
function resolveBrandValue(
|
|
field: ExpressField,
|
|
userValue: string,
|
|
designMD: DesignMD,
|
|
company?: CompanyProfile,
|
|
brandContent?: BrandContentPiece[],
|
|
): string {
|
|
if (userValue && userValue.trim()) return userValue;
|
|
|
|
// Resolve from brand asset ID (e.g. a logo badge piece)
|
|
if (field.brandAssetId && brandContent) {
|
|
const asset = brandContent.find(a => a.id === field.brandAssetId);
|
|
if (asset) {
|
|
// For images, return the thumbnail/image URL
|
|
if (asset.content.imageUrl) return asset.content.imageUrl;
|
|
if (asset.thumbnail) return asset.thumbnail;
|
|
// For text cards, return the text
|
|
if (asset.content.text) return asset.content.text;
|
|
}
|
|
}
|
|
|
|
// Auto-resolve from brand source
|
|
if (field.brandSource) {
|
|
switch (field.brandSource) {
|
|
case 'brand-name': return company?.name || designMD.brandName || 'Tu Marca';
|
|
case 'tagline': return company?.tagline || '';
|
|
case 'logo': return designMD.logoUrl || '';
|
|
case 'intro-video': return designMD.introVideoUrl || '';
|
|
case 'outro-video': return designMD.outroVideoUrl || '';
|
|
case 'primary-color': return designMD.primaryColor;
|
|
case 'secondary-color': return designMD.secondaryColor;
|
|
// Social handles
|
|
case 'instagram': return company?.socialLinks?.instagram || '';
|
|
case 'tiktok': return company?.socialLinks?.tiktok || '';
|
|
case 'twitter': return company?.socialLinks?.x || '';
|
|
case 'youtube': return company?.socialLinks?.youtube || '';
|
|
case 'website': return company?.socialLinks?.website || '';
|
|
}
|
|
}
|
|
|
|
// Use placeholder (removing brand variable markers)
|
|
return field.placeholder.replace(/\{[^}]+\}/g, company?.name || designMD.brandName || '');
|
|
}
|
|
|
|
/** Resolve a field's color from brand palette */
|
|
function resolveColor(field: ExpressField, designMD: DesignMD): string {
|
|
if (field.style.color) return field.style.color;
|
|
if (field.brandSource === 'primary-color') return designMD.primaryColor;
|
|
return designMD.textColor || '#FFFFFF';
|
|
}
|
|
|
|
/** Resolve a field's font from brand config */
|
|
function resolveFont(field: ExpressField, designMD: DesignMD): string {
|
|
if (field.style.fontSize && field.style.fontSize >= 28) {
|
|
return designMD.titleFont || designMD.baseFont;
|
|
}
|
|
return designMD.paragraphFont || designMD.baseFont;
|
|
}
|
|
|
|
/**
|
|
* Gets canvas dimensions for an aspect ratio.
|
|
*/
|
|
export function getAspectDimensions(aspect: string): { w: number; h: number } {
|
|
switch (aspect) {
|
|
case '9:16': return { w: 1080, h: 1920 };
|
|
case '16:9': return { w: 1920, h: 1080 };
|
|
case '1:1': return { w: 1080, h: 1080 };
|
|
case '4:5': return { w: 1080, h: 1350 };
|
|
case '4:3': return { w: 1440, h: 1080 };
|
|
default: return { w: 1080, h: 1920 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Compute total duration of template in seconds.
|
|
* @param videoDurations Optional map of scene.id → actual video duration (seconds).
|
|
* When provided, overrides the static durationSeconds for scenes
|
|
* that have user-uploaded video content.
|
|
*/
|
|
export function getTemplateDuration(
|
|
template: ExpressTemplate,
|
|
videoDurations?: Record<string, number>,
|
|
designMD?: DesignMD,
|
|
): number {
|
|
return template.scenes.reduce((sum, scene) => {
|
|
// If it's a brand segment, read duration from designMD instead of template
|
|
if (scene.segmentSource === 'brand' && designMD) {
|
|
if (scene.type === 'intro') {
|
|
if (!designMD.introVideoUrl) return sum; // Skipped
|
|
const frames = designMD.introDurationFrames || (scene.durationSeconds * 30);
|
|
return sum + (frames / 30);
|
|
}
|
|
if (scene.type === 'outro') {
|
|
if (!designMD.outroVideoUrl) return sum; // Skipped
|
|
const frames = designMD.outroDurationFrames || (scene.durationSeconds * 30);
|
|
return sum + (frames / 30);
|
|
}
|
|
}
|
|
|
|
// If we know the actual video duration for this scene, use it
|
|
if (videoDurations && videoDurations[scene.id]) {
|
|
return sum + videoDurations[scene.id];
|
|
}
|
|
return sum + scene.durationSeconds;
|
|
}, 0);
|
|
}
|
|
|
|
/**
|
|
* Compiles a scene-based Express template into TimelineElement[].
|
|
* Concatenates scenes sequentially, each scene contributing its fields as elements.
|
|
*/
|
|
export function compileExpressToTimeline(
|
|
template: ExpressTemplate,
|
|
fieldData: Record<string, string>,
|
|
designMD: DesignMD,
|
|
company?: CompanyProfile,
|
|
videoDurations?: Record<string, number>,
|
|
): { elements: TimelineElement[]; layers: TimelineLayer[] } {
|
|
const fps = 30;
|
|
const elements: TimelineElement[] = [];
|
|
const layers: TimelineLayer[] = [
|
|
{ id: 'layer-express-bg', name: 'Fondos', type: 'visual', colorLabel: 'purple' },
|
|
{ id: 'layer-express-media', name: 'Media', type: 'visual', colorLabel: 'blue' },
|
|
{ id: 'layer-express-text', name: 'Textos', type: 'visual', colorLabel: 'orange' },
|
|
{ id: 'layer-express-brand', name: 'Marca', type: 'brand', colorLabel: 'yellow' },
|
|
];
|
|
|
|
let frameOffset = 0;
|
|
|
|
// Process each scene sequentially — the template's scenes are the sole source of truth
|
|
for (const scene of template.scenes) {
|
|
// Default to template's duration
|
|
let sceneDuration = scene.durationSeconds;
|
|
|
|
// Override if brand segment
|
|
if (scene.segmentSource === 'brand' && designMD) {
|
|
if (scene.type === 'intro') {
|
|
if (!designMD.introVideoUrl) { sceneDuration = 0; }
|
|
else { sceneDuration = (designMD.introDurationFrames || (scene.durationSeconds * 30)) / 30; }
|
|
}
|
|
if (scene.type === 'outro') {
|
|
if (!designMD.outroVideoUrl) { sceneDuration = 0; }
|
|
else { sceneDuration = (designMD.outroDurationFrames || (scene.durationSeconds * 30)) / 30; }
|
|
}
|
|
} else if (videoDurations && videoDurations[scene.id]) {
|
|
// Override if user uploaded video
|
|
sceneDuration = videoDurations[scene.id];
|
|
}
|
|
|
|
const sceneDurFrames = sceneDuration * fps;
|
|
const sceneStart = frameOffset;
|
|
const sceneEnd = frameOffset + sceneDurFrames;
|
|
|
|
// ── Handle intro/outro segments ──
|
|
if ((scene.type === 'intro' || scene.type === 'outro') && scene.segmentSource) {
|
|
const isIntro = scene.type === 'intro';
|
|
|
|
if (scene.segmentSource === 'brand') {
|
|
// Resolve video from DesignMD
|
|
const videoUrl = isIntro ? designMD.introVideoUrl : designMD.outroVideoUrl;
|
|
if (!videoUrl) {
|
|
// Brand has no video for this segment — skip entirely
|
|
// Don't advance frameOffset so it doesn't create a gap
|
|
continue;
|
|
}
|
|
|
|
// Convert from center-based coords (SegmentVideoFrame) to top-left coords (CompositionElement)
|
|
const segW = scene.segmentVideoW ?? 100;
|
|
const segH = scene.segmentVideoH ?? 100;
|
|
const segX = (scene.segmentVideoX ?? 50) - segW / 2;
|
|
const segY = (scene.segmentVideoY ?? 50) - segH / 2;
|
|
|
|
elements.push({
|
|
id: `express-segment-${scene.id}`,
|
|
type: 'video',
|
|
content: videoUrl,
|
|
x: segX,
|
|
y: segY,
|
|
startFrame: sceneStart,
|
|
endFrame: sceneEnd,
|
|
width: segW,
|
|
height: segH,
|
|
// CompositionElement's isFullscreenBrand path reads w/h (not width/height)
|
|
w: segW,
|
|
h: segH,
|
|
objectFit: (isIntro
|
|
? (designMD.introVideoFit || 'cover')
|
|
: (designMD.outroVideoFit || 'cover')) as 'cover' | 'contain' | 'fill',
|
|
containBgColor: isIntro ? designMD.introVideoBgColor : designMD.outroVideoBgColor,
|
|
layerId: 'layer-express-bg',
|
|
isBrandElement: true,
|
|
isLocked: true,
|
|
elementName: isIntro ? 'Intro de marca' : 'Outro de marca',
|
|
scale: 1,
|
|
rotation: 0,
|
|
opacity: 100,
|
|
transitionIn: scene.segmentTransition
|
|
? { type: scene.segmentTransition.type as TransitionType, duration: scene.segmentTransition.duration }
|
|
: undefined,
|
|
});
|
|
} else if (scene.segmentSource === 'form') {
|
|
// Form-sourced: look up the uploaded video from fieldData
|
|
const segmentFieldId = `segment-${scene.id}`;
|
|
const videoUrl = fieldData[segmentFieldId] || '';
|
|
|
|
// Convert from center-based coords (SegmentVideoFrame) to top-left coords
|
|
const fSegW = scene.segmentVideoW ?? 100;
|
|
const fSegH = scene.segmentVideoH ?? 100;
|
|
const fSegX = (scene.segmentVideoX ?? 50) - fSegW / 2;
|
|
const fSegY = (scene.segmentVideoY ?? 50) - fSegH / 2;
|
|
|
|
elements.push({
|
|
id: `express-segment-${scene.id}`,
|
|
type: 'video',
|
|
content: videoUrl,
|
|
x: fSegX,
|
|
y: fSegY,
|
|
startFrame: sceneStart,
|
|
endFrame: sceneEnd,
|
|
width: fSegW,
|
|
height: fSegH,
|
|
objectFit: (scene.segmentVideoFit || 'cover') as 'cover' | 'contain' | 'fill',
|
|
layerId: 'layer-express-media',
|
|
isBrandElement: false,
|
|
isLocked: false,
|
|
elementName: scene.segmentFieldLabel || (isIntro ? 'Video de intro' : 'Video de cierre'),
|
|
sourceFieldId: segmentFieldId,
|
|
scale: 1,
|
|
rotation: 0,
|
|
opacity: 100,
|
|
// Show placeholder when no video uploaded
|
|
...(videoUrl ? {} : {
|
|
isPlaceholder: true,
|
|
placeholderLabel: scene.segmentFieldLabel || (isIntro ? 'Video de intro' : 'Video de cierre'),
|
|
}),
|
|
transitionIn: scene.segmentTransition
|
|
? { type: scene.segmentTransition.type as TransitionType, duration: scene.segmentTransition.duration }
|
|
: undefined,
|
|
});
|
|
}
|
|
|
|
frameOffset = sceneEnd;
|
|
continue;
|
|
}
|
|
|
|
// Scene background (if brand type, use secondary color)
|
|
if (scene.background) {
|
|
const bgColor = scene.background.type === 'brand'
|
|
? designMD.secondaryColor
|
|
: scene.background.type === 'gradient'
|
|
? designMD.primaryColor
|
|
: (scene.background.value || designMD.secondaryColor);
|
|
|
|
elements.push({
|
|
id: `express-bg-${scene.id}`,
|
|
type: 'shape',
|
|
content: '',
|
|
x: 50, y: 50,
|
|
startFrame: sceneStart,
|
|
endFrame: sceneEnd,
|
|
scale: 1, rotation: 0, opacity: 100,
|
|
layerId: 'layer-express-bg',
|
|
isBrandElement: false,
|
|
isLocked: false,
|
|
elementName: `Fondo: ${scene.name}`,
|
|
color: bgColor,
|
|
width: 100,
|
|
height: 100,
|
|
shapeType: 'rectangle',
|
|
transitionIn: scene.transition ? { type: scene.transition.type as TransitionType, duration: scene.transition.duration } : undefined,
|
|
});
|
|
}
|
|
|
|
// Process fields — prefer new TemplateField[] format over legacy editableFields
|
|
const fieldsToProcess = (scene.fields && scene.fields.length > 0)
|
|
? scene.fields
|
|
: null;
|
|
|
|
if (fieldsToProcess) {
|
|
// New TemplateField[] format: process ALL natures
|
|
for (const field of fieldsToProcess) {
|
|
let value: string;
|
|
|
|
if (field.nature === 'static') {
|
|
// Static: always use the fixed content
|
|
value = field.content || '';
|
|
} else if (field.nature === 'brand-variable') {
|
|
// Brand variable: auto-resolve from DesignMD/CompanyProfile
|
|
const legacyField: ExpressField = {
|
|
id: field.id, type: field.type === 'video' ? 'media' : field.type === 'image' ? (field.brandSource === 'logo' ? 'logo' : 'media') : field.type as ExpressField['type'],
|
|
label: field.label, placeholder: field.content, required: field.required,
|
|
brandSource: field.brandSource, brandAssetId: field.brandAssetId,
|
|
position: field.position, style: field.style as ExpressField['style'],
|
|
};
|
|
value = resolveBrandValue(legacyField, '', designMD, company, company?.brandContent);
|
|
} else {
|
|
// Editable slot: use user-provided data, or fallback to content
|
|
const legacyField: ExpressField = {
|
|
id: field.id, type: field.type === 'video' ? 'media' : field.type === 'image' ? 'media' : field.type as ExpressField['type'],
|
|
label: field.label, placeholder: field.content, required: field.required,
|
|
brandSource: field.brandSource, brandAssetId: field.brandAssetId,
|
|
position: field.position, style: field.style as ExpressField['style'],
|
|
};
|
|
value = resolveBrandValue(legacyField, fieldData[field.id] || '', designMD, company, company?.brandContent);
|
|
}
|
|
|
|
// For media fields, resolveBrandValue may return placeholder TEXT (e.g. "Foto o gráfico")
|
|
// which is NOT a valid URL. Detect and treat as empty to avoid crashing Remotion's <Img>.
|
|
const isMediaField = field.type === 'image' || field.type === 'video';
|
|
if (isMediaField && value && !/^(https?:|blob:|data:|\/)/i.test(value)) {
|
|
value = '';
|
|
}
|
|
if (!value && !isMediaField) continue;
|
|
|
|
const elType = field.type === 'text' || field.type === 'sticker' ? 'text'
|
|
: field.type === 'shape' ? 'shape'
|
|
: field.type === 'video' ? 'video'
|
|
: 'image';
|
|
|
|
// For stickers, format the text with @ prefix and strip URLs
|
|
let compiledContent = value;
|
|
if (field.type === 'sticker' && field.style.sticker) {
|
|
const st = field.style.sticker;
|
|
if (st.showAtPrefix && field.brandSource !== 'website') {
|
|
compiledContent = `@${value.replace(/^@/, '')}`;
|
|
} else if (field.brandSource === 'website') {
|
|
compiledContent = value.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
}
|
|
}
|
|
|
|
const layerId = field.type === 'text' ? 'layer-express-text'
|
|
: field.nature === 'brand-variable' ? 'layer-express-brand'
|
|
: 'layer-express-media';
|
|
|
|
const isEmptyMedia = isMediaField && !value;
|
|
|
|
elements.push({
|
|
id: `express-${scene.id}-${field.id}`,
|
|
sourceFieldId: field.id,
|
|
type: elType,
|
|
content: field.type === 'sticker' ? compiledContent : (value || ''),
|
|
x: field.position.x,
|
|
y: field.position.y,
|
|
startFrame: sceneStart,
|
|
endFrame: sceneEnd,
|
|
scale: 1,
|
|
rotation: field.position.rotation || 0,
|
|
opacity: field.style.opacity ?? 100,
|
|
layerId,
|
|
isBrandElement: field.nature === 'brand-variable',
|
|
isLocked: field.nature === 'static',
|
|
elementName: field.label,
|
|
// Placeholder mode for empty media fields
|
|
...(isEmptyMedia ? {
|
|
isPlaceholder: true,
|
|
placeholderLabel: field.label,
|
|
} : {}),
|
|
...(field.type === 'text' || field.type === 'sticker' ? {
|
|
fontSize: field.style.fontSize || (field.type === 'sticker' ? 14 : 24),
|
|
fontWeight: field.style.fontWeight || (field.type === 'sticker' ? 500 : 400),
|
|
fontFamily: field.style.fontFamily || designMD.baseFont,
|
|
color: field.style.color || designMD.textColor || '#FFFFFF',
|
|
textAlign: (field.style.textAlign as 'left' | 'center' | 'right') || (field.type === 'sticker' ? 'left' : 'center'),
|
|
} : {}),
|
|
...(field.type === 'image' || field.type === 'video' ? {
|
|
width: field.position.w,
|
|
height: field.position.h,
|
|
objectFit: ((field.nature === 'brand-variable' && field.brandSource === 'intro-video')
|
|
? (designMD.introVideoFit || field.style.mediaFit || 'cover')
|
|
: (field.nature === 'brand-variable' && field.brandSource === 'outro-video')
|
|
? (designMD.outroVideoFit || field.style.mediaFit || 'cover')
|
|
: (field.style.mediaFit || 'cover')) as 'cover' | 'contain' | 'fill',
|
|
containBgColor: (field.nature === 'brand-variable' && field.brandSource === 'intro-video')
|
|
? designMD.introVideoBgColor
|
|
: (field.nature === 'brand-variable' && field.brandSource === 'outro-video')
|
|
? designMD.outroVideoBgColor
|
|
: undefined,
|
|
} : {}),
|
|
...(field.type === 'shape' ? {
|
|
width: field.position.w,
|
|
height: field.position.h,
|
|
shapeType: field.style.shapeType || 'rectangle',
|
|
color: field.style.shapeFill || designMD.primaryColor,
|
|
} : {}),
|
|
transitionIn: scene.transition ? { type: scene.transition.type as TransitionType, duration: scene.transition.duration } : undefined,
|
|
});
|
|
}
|
|
} else {
|
|
// Legacy ExpressField[] format
|
|
for (const field of scene.editableFields) {
|
|
let value = resolveBrandValue(field, fieldData[field.id] || '', designMD, company, company?.brandContent);
|
|
// For media fields, placeholder text is not a valid URL — clear it to avoid crashing Remotion
|
|
const isLegacyMedia = field.type === 'media' || field.type === 'logo';
|
|
if (isLegacyMedia && value && !/^(https?:|blob:|data:|\/)/i.test(value)) {
|
|
value = '';
|
|
}
|
|
// Skip non-media fields with no resolved value; media fields get a placeholder
|
|
if (!value && !isLegacyMedia) continue;
|
|
|
|
const elType = field.type === 'text' ? 'text'
|
|
: field.type === 'logo' ? 'image'
|
|
: 'image';
|
|
|
|
const layerId = field.type === 'text' ? 'layer-express-text'
|
|
: field.type === 'logo' ? 'layer-express-brand'
|
|
: 'layer-express-media';
|
|
|
|
const isEmptyMedia = isLegacyMedia && !value;
|
|
|
|
elements.push({
|
|
id: `express-${scene.id}-${field.id}`,
|
|
sourceFieldId: field.id,
|
|
type: elType,
|
|
content: value || '',
|
|
x: field.position.x,
|
|
y: field.position.y,
|
|
startFrame: sceneStart,
|
|
endFrame: sceneEnd,
|
|
scale: 1,
|
|
rotation: 0,
|
|
opacity: field.style.opacity ?? 100,
|
|
layerId,
|
|
isBrandElement: field.type === 'logo',
|
|
isLocked: false,
|
|
elementName: field.label,
|
|
// Placeholder mode for empty media fields
|
|
...(isEmptyMedia ? {
|
|
isPlaceholder: true,
|
|
placeholderLabel: field.label,
|
|
} : {}),
|
|
...(field.type === 'text' ? {
|
|
fontSize: field.style.fontSize || 24,
|
|
fontWeight: field.style.fontWeight || 400,
|
|
fontFamily: resolveFont(field, designMD),
|
|
color: resolveColor(field, designMD),
|
|
textAlign: (field.style.textAlign as 'left' | 'center' | 'right') || 'center',
|
|
width: field.position.w,
|
|
} : {}),
|
|
...(field.type === 'media' || field.type === 'logo' ? {
|
|
width: field.position.w,
|
|
height: field.position.h,
|
|
objectFit: 'cover' as const,
|
|
} : {}),
|
|
transitionIn: scene.transition ? { type: scene.transition.type as TransitionType, duration: scene.transition.duration } : undefined,
|
|
});
|
|
}
|
|
}
|
|
|
|
frameOffset = sceneEnd;
|
|
}
|
|
|
|
return { elements, layers };
|
|
}
|