Files
brandly/src/utils/expressCompiler.ts
T

400 lines
16 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 or generic brand asset)
if (field.brandAssetId) {
if (designMD.brandAssets) {
const asset = designMD.brandAssets.find(a => a.id === field.brandAssetId);
if (asset) return asset.url;
}
if (brandContent) {
const asset = brandContent.find(a => a.id === field.brandAssetId);
if (asset) {
if (asset.content.imageUrl) return asset.content.imageUrl;
if (asset.thumbnail) return asset.thumbnail;
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 '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 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>,
variationId?: string,
): { 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 user uploaded video
if (videoDurations && videoDurations[scene.id]) {
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 === '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,
});
}
const fieldsToProcess = (scene.fields && scene.fields.length > 0)
? scene.fields
: null;
const activeVariation = variationId && scene.variations ? scene.variations.find(v => v.id === variationId) : null;
if (fieldsToProcess) {
// New TemplateField[] format: process ALL natures
for (const field of fieldsToProcess) {
const position = activeVariation && activeVariation.positions[field.id]
? { ...field.position, ...activeVariation.positions[field.id] }
: field.position;
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: position.x,
y: position.y,
startFrame: sceneStart,
endFrame: sceneEnd,
scale: 1,
rotation: position.rotation || 0,
opacity: field.style.opacity ?? 100,
blendMode: field.style.blendMode,
layerId,
isBrandElement: field.nature === 'brand-variable',
brandDisplayMode: 'overlay',
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: position.w,
height: position.h,
objectFit: (field.style.mediaFit || 'cover') as 'cover' | 'contain' | 'fill',
} : {}),
...(field.type === 'shape' ? {
width: position.w,
height: 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) {
const position = activeVariation && activeVariation.positions[field.id]
? { ...field.position, ...activeVariation.positions[field.id] }
: field.position;
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: position.x,
y: position.y,
startFrame: sceneStart,
endFrame: sceneEnd,
scale: 1,
rotation: 0,
opacity: field.style.opacity ?? 100,
layerId,
isBrandElement: field.type === 'logo',
brandDisplayMode: 'overlay',
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: position.w,
} : {}),
...(field.type === 'media' || field.type === 'logo' ? {
width: position.w,
height: 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 };
}