Initial commit — Bradly branding editor platform

This commit is contained in:
2026-06-02 03:27:03 -05:00
commit b135a70cc7
180 changed files with 43160 additions and 0 deletions
+249
View File
@@ -0,0 +1,249 @@
/**
* ChromaKeyShader — WebGL2-based chroma key processor
*
* Uses a GPU fragment shader for real-time background removal.
* Processes in YCbCr color space for more perceptually accurate
* color matching, and includes spill suppression + edge refinement.
*
* Falls back gracefully: caller should check `isSupported()` before use.
*/
const VERTEX_SHADER = `#version 300 es
in vec2 a_position;
in vec2 a_texCoord;
out vec2 v_texCoord;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
v_texCoord = a_texCoord;
}
`;
const FRAGMENT_SHADER = `#version 300 es
precision mediump float;
in vec2 v_texCoord;
out vec4 outColor;
uniform sampler2D u_texture;
uniform vec3 u_keyColor; // Key color in RGB (0-1 range)
uniform float u_tolerance; // Inner tolerance radius (color distance)
uniform float u_softness; // Softness zone width
uniform float u_spillSuppress; // Spill suppression strength (0-1)
// Convert RGB to YCbCr for more perceptual color matching
vec3 rgbToYCbCr(vec3 rgb) {
float y = 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b;
float cb = -0.169 * rgb.r - 0.331 * rgb.g + 0.500 * rgb.b + 0.5;
float cr = 0.500 * rgb.r - 0.419 * rgb.g - 0.081 * rgb.b + 0.5;
return vec3(y, cb, cr);
}
void main() {
vec4 texColor = texture(u_texture, v_texCoord);
// Convert to YCbCr space — compare only chroma (Cb, Cr), ignore luminance (Y)
vec3 texYCbCr = rgbToYCbCr(texColor.rgb);
vec3 keyYCbCr = rgbToYCbCr(u_keyColor);
// Distance in chroma space only (ignore luminance for better results)
float chromaDist = distance(texYCbCr.yz, keyYCbCr.yz);
// Also factor in RGB distance for edge cases (pure white/black)
float rgbDist = distance(texColor.rgb, u_keyColor);
// Use the minimum of both distances for better detection of white/black
float dist = min(chromaDist * 2.0, rgbDist);
// Smoothstep for clean alpha transition
float alpha = smoothstep(u_tolerance, u_tolerance + u_softness, dist);
// Spill suppression — reduce key color influence on semi-transparent pixels
vec3 finalColor = texColor.rgb;
if (alpha > 0.0 && alpha < 1.0 && u_spillSuppress > 0.0) {
float spillMask = 1.0 - alpha;
finalColor = mix(texColor.rgb,
texColor.rgb + (texColor.rgb - u_keyColor) * spillMask * 0.5,
u_spillSuppress);
finalColor = clamp(finalColor, 0.0, 1.0);
}
outColor = vec4(finalColor, texColor.a * alpha);
}
`;
export interface ChromaKeyShaderParams {
keyColor: [number, number, number]; // RGB 0-255
tolerance: number; // 0-1 range (normalized distance)
softness: number; // 0-1 range
spillSuppress?: number; // 0-1 range, default 0.5
}
export class ChromaKeyShader {
private gl: WebGL2RenderingContext;
private program: WebGLProgram;
private texture: WebGLTexture;
private vao: WebGLVertexArrayObject;
// Uniform locations
private uTexture: WebGLUniformLocation;
private uKeyColor: WebGLUniformLocation;
private uTolerance: WebGLUniformLocation;
private uSoftness: WebGLUniformLocation;
private uSpillSuppress: WebGLUniformLocation;
constructor(canvas: HTMLCanvasElement) {
const gl = canvas.getContext('webgl2', {
alpha: true,
premultipliedAlpha: false,
preserveDrawingBuffer: true,
});
if (!gl) throw new Error('WebGL2 not supported');
this.gl = gl;
// Compile shaders
const vs = this.compileShader(gl.VERTEX_SHADER, VERTEX_SHADER);
const fs = this.compileShader(gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
// Link program
this.program = gl.createProgram()!;
gl.attachShader(this.program, vs);
gl.attachShader(this.program, fs);
gl.linkProgram(this.program);
if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) {
throw new Error('Shader link failed: ' + gl.getProgramInfoLog(this.program));
}
// Get uniform locations
this.uTexture = gl.getUniformLocation(this.program, 'u_texture')!;
this.uKeyColor = gl.getUniformLocation(this.program, 'u_keyColor')!;
this.uTolerance = gl.getUniformLocation(this.program, 'u_tolerance')!;
this.uSoftness = gl.getUniformLocation(this.program, 'u_softness')!;
this.uSpillSuppress = gl.getUniformLocation(this.program, 'u_spillSuppress')!;
// Create texture
this.texture = gl.createTexture()!;
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
// Create fullscreen quad VAO
this.vao = gl.createVertexArray()!;
gl.bindVertexArray(this.vao);
// Position buffer (clip space quad)
const posBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
-1, -1, 1, -1, -1, 1,
1, -1, 1, 1, -1, 1,
]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(this.program, 'a_position');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
// Texcoord buffer (flip Y for video frames)
const tcBuf = gl.createBuffer()!;
gl.bindBuffer(gl.ARRAY_BUFFER, tcBuf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
0, 1, 1, 1, 0, 0,
1, 1, 1, 0, 0, 0,
]), gl.STATIC_DRAW);
const aTex = gl.getAttribLocation(this.program, 'a_texCoord');
gl.enableVertexAttribArray(aTex);
gl.vertexAttribPointer(aTex, 2, gl.FLOAT, false, 0, 0);
gl.bindVertexArray(null);
// Enable alpha blending
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
}
/**
* Render a source (video or image) with chroma key applied.
* Updates the canvas size to match the source dimensions.
*/
render(
source: HTMLVideoElement | HTMLImageElement | HTMLCanvasElement,
params: ChromaKeyShaderParams
): void {
const gl = this.gl;
const canvas = gl.canvas as HTMLCanvasElement;
// Get source dimensions
const srcW = source instanceof HTMLVideoElement ? source.videoWidth : source.width;
const srcH = source instanceof HTMLVideoElement ? source.videoHeight : source.height;
if (srcW === 0 || srcH === 0) return;
// Resize canvas if needed
if (canvas.width !== srcW || canvas.height !== srcH) {
canvas.width = srcW;
canvas.height = srcH;
gl.viewport(0, 0, srcW, srcH);
}
// Upload source to texture
gl.bindTexture(gl.TEXTURE_2D, this.texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);
// Set uniforms
gl.useProgram(this.program);
gl.uniform1i(this.uTexture, 0);
gl.uniform3f(
this.uKeyColor,
params.keyColor[0] / 255,
params.keyColor[1] / 255,
params.keyColor[2] / 255
);
gl.uniform1f(this.uTolerance, params.tolerance);
gl.uniform1f(this.uSoftness, params.softness);
gl.uniform1f(this.uSpillSuppress, params.spillSuppress ?? 0.5);
// Clear and draw
gl.clearColor(0, 0, 0, 0);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.bindVertexArray(this.vao);
gl.drawArrays(gl.TRIANGLES, 0, 6);
gl.bindVertexArray(null);
}
/**
* Check if WebGL2 is available in the current browser.
*/
static isSupported(): boolean {
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl2');
canvas.remove();
return !!gl;
} catch {
return false;
}
}
/**
* Cleanup all WebGL resources.
*/
dispose(): void {
const gl = this.gl;
gl.deleteTexture(this.texture);
gl.deleteProgram(this.program);
gl.deleteVertexArray(this.vao);
}
private compileShader(type: number, source: string): WebGLShader {
const gl = this.gl;
const shader = gl.createShader(type)!;
gl.shaderSource(shader, source);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
const info = gl.getShaderInfoLog(shader);
gl.deleteShader(shader);
throw new Error('Shader compile failed: ' + info);
}
return shader;
}
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Audio metadata utility.
* Reads real duration from audio files using Web Audio API.
*/
const durationCache = new Map<string, number>();
/**
* Get the real duration of an audio file in seconds.
* Results are cached by URL.
*/
export async function getAudioDuration(url: string): Promise<number> {
if (durationCache.has(url)) {
return durationCache.get(url)!;
}
try {
const audioCtx = new AudioContext();
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
const duration = audioBuffer.duration;
durationCache.set(url, duration);
await audioCtx.close();
return duration;
} catch (err) {
console.warn('[audioMetadata] Failed to get duration:', err);
return 5; // fallback: 5 seconds
}
}
/**
* Convert duration in seconds to frames at a given FPS.
*/
export function durationToFrames(durationSec: number, fps: number = 30): number {
return Math.round(durationSec * fps);
}
/**
* Format seconds as mm:ss string.
*/
export function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
+91
View File
@@ -0,0 +1,91 @@
/**
* Audio waveform extraction utility.
* Decodes audio files and extracts peak data for visualization.
*/
const peakCache = new Map<string, Float32Array>();
let audioCtx: AudioContext | null = null;
function getAudioContext(): AudioContext {
if (!audioCtx) {
audioCtx = new AudioContext();
}
return audioCtx;
}
/**
* Decode an audio URL into an AudioBuffer.
*/
export async function decodeAudioUrl(url: string): Promise<AudioBuffer> {
const ctx = getAudioContext();
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
return ctx.decodeAudioData(arrayBuffer);
}
/**
* Extract peak amplitude data from an AudioBuffer.
* Returns a Float32Array of normalized peaks (0-1) with `numBuckets` entries.
*/
export function extractPeaks(buffer: AudioBuffer, numBuckets: number): Float32Array {
const channelData = buffer.getChannelData(0); // Use first channel
const samplesPerBucket = Math.floor(channelData.length / numBuckets);
const peaks = new Float32Array(numBuckets);
for (let i = 0; i < numBuckets; i++) {
let max = 0;
const start = i * samplesPerBucket;
const end = Math.min(start + samplesPerBucket, channelData.length);
for (let j = start; j < end; j++) {
const abs = Math.abs(channelData[j]);
if (abs > max) max = abs;
}
peaks[i] = max;
}
return peaks;
}
/**
* Get peak data for an audio URL, with caching.
* @param url - The audio file URL (blob: or http:)
* @param numBuckets - Number of peak buckets to generate
*/
export async function getPeaks(url: string, numBuckets: number = 200): Promise<Float32Array> {
const cacheKey = `${url}:${numBuckets}`;
if (peakCache.has(cacheKey)) {
return peakCache.get(cacheKey)!;
}
try {
const buffer = await decodeAudioUrl(url);
const peaks = extractPeaks(buffer, numBuckets);
peakCache.set(cacheKey, peaks);
return peaks;
} catch (err) {
console.warn('[audioWaveform] Failed to decode audio:', err);
// Return fake peaks as fallback
const fallback = new Float32Array(numBuckets);
for (let i = 0; i < numBuckets; i++) {
fallback[i] = 0.1 + Math.abs(Math.sin(i * 0.3) * Math.cos(i * 0.8)) * 0.9;
}
return fallback;
}
}
/**
* Clear cached peaks for a specific URL or all URLs.
*/
export function clearPeakCache(url?: string): void {
if (url) {
// Clear all bucket variants for this URL
for (const key of peakCache.keys()) {
if (key.startsWith(url + ':')) {
peakCache.delete(key);
}
}
} else {
peakCache.clear();
}
}
+204
View File
@@ -0,0 +1,204 @@
/**
* batchExporter — Utility to render N pieces and package them as a ZIP.
*
* Strategy:
* - For images: uses offscreen canvas capture from Remotion Player screenshots
* - For video: delegates to the server-side render pipeline (/api/render/start)
*
* Uses JSZip for packaging and file-saver for download.
*/
import JSZip from 'jszip';
import { saveAs } from 'file-saver';
import { compileExpressToTimeline, getAspectDimensions } from './expressCompiler';
import { resolveBlobFieldData } from './uploadBlobContent';
import type {
BatchPieceData, ExpressTemplate, CompanyProfile, DesignMD,
} from '../types';
export interface BatchExportOptions {
format: 'png' | 'jpeg';
/** Quality for JPEG (0-1). Default 0.92 */
quality?: number;
}
export interface BatchExportProgress {
current: number;
total: number;
status: 'rendering' | 'packaging' | 'done' | 'error';
error?: string;
}
/**
* Find the background field ID to inject per-piece backgrounds.
*/
function findBackgroundFieldId(template: ExpressTemplate): string | null {
for (const scene of template.scenes) {
const fields = scene.fields ?? [];
const bgField = fields.find(f =>
f.nature === 'editable-slot' && (f.type === 'image' || f.type === 'video') && f.isBackground
);
if (bgField) return bgField.id;
const mediaField = fields.find(f =>
f.nature === 'editable-slot' && (f.type === 'image' || f.type === 'video')
);
if (mediaField) return mediaField.id;
}
return null;
}
/**
* Render a single piece to an image blob using an offscreen canvas.
* Creates a temporary iframe with a Remotion-like render, captures it.
*/
async function renderPieceToImage(
piece: BatchPieceData,
template: ExpressTemplate,
designMD: DesignMD,
brand: CompanyProfile,
backgroundFieldId: string | null,
dimensions: { w: number; h: number },
options: BatchExportOptions,
): Promise<Blob> {
// Build fieldData with background injected
const rawFieldData: Record<string, string> = { ...piece.fieldData };
if (backgroundFieldId && piece.backgroundUrl) {
rawFieldData[backgroundFieldId] = piece.backgroundUrl;
}
// Resolve blob: URLs to persistent server URLs
const fieldData = await resolveBlobFieldData(rawFieldData);
const compiled = compileExpressToTimeline(template, fieldData, designMD, brand);
// Strip transitions
compiled.elements = compiled.elements.map(el => ({
...el,
transitionIn: undefined,
transitionOut: undefined,
}));
// Use the server-side render endpoint for high-quality output
const isStill = true;
const inputProps = {
designMD,
timelineElements: compiled.elements,
layers: compiled.layers,
selectedElementId: null,
textOverlay: '',
brandVisibility: { logo: false, frame: false, background: true },
outputFormat: template.format,
};
const body = {
format: options.format,
width: dimensions.w,
height: dimensions.h,
fps: 30,
durationInFrames: 1,
compositionId: 'BrandStill',
inputProps,
};
const res = await fetch('/api/render/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
throw new Error(`Render failed for piece ${piece.index + 1}`);
}
const job = await res.json();
// Poll for completion
const maxWait = 60_000; // 60s timeout
const pollInterval = 1_000;
const startTime = Date.now();
while (Date.now() - startTime < maxWait) {
await new Promise(r => setTimeout(r, pollInterval));
const statusRes = await fetch(`/api/render/jobs/${job.id}`);
if (!statusRes.ok) continue;
const statusData = await statusRes.json();
if (statusData.status === 'done' && statusData.downloadUrl) {
const fileRes = await fetch(statusData.downloadUrl);
if (!fileRes.ok) throw new Error(`Download failed for piece ${piece.index + 1}`);
return await fileRes.blob();
}
if (statusData.status === 'error') {
throw new Error(statusData.error || `Render error for piece ${piece.index + 1}`);
}
}
throw new Error(`Render timeout for piece ${piece.index + 1}`);
}
/**
* Export all batch pieces as a ZIP file.
*
* @param pieces - Array of batch pieces to render
* @param template - The Express template
* @param brand - Brand profile (for DesignMD + brand variables)
* @param options - Export format options
* @param onProgress - Progress callback
* @returns Promise that resolves when download starts
*/
export async function exportBatchAsZip(
pieces: BatchPieceData[],
template: ExpressTemplate,
brand: CompanyProfile,
options: BatchExportOptions,
onProgress?: (progress: BatchExportProgress) => void,
): Promise<void> {
const designMD = brand.design;
const dimensions = getAspectDimensions(template.aspectRatio);
const backgroundFieldId = findBackgroundFieldId(template);
const zip = new JSZip();
const validPieces = pieces.filter(p => p.isValid);
const total = validPieces.length;
onProgress?.({ current: 0, total, status: 'rendering' });
for (let i = 0; i < validPieces.length; i++) {
const piece = validPieces[i];
try {
const blob = await renderPieceToImage(
piece, template, designMD, brand, backgroundFieldId, dimensions, options,
);
// Name file: use background filename (without ext) or fallback to index
const ext = options.format === 'jpeg' ? 'jpg' : 'png';
const baseName = piece.backgroundFilename
? piece.backgroundFilename.replace(/\.[^.]+$/, '')
: `pieza-${piece.index + 1}`;
const fileName = `${baseName}.${ext}`;
zip.file(fileName, blob);
} catch (err) {
console.error(`Failed to render piece ${piece.index + 1}:`, err);
// Add an error placeholder
zip.file(`ERROR_pieza-${piece.index + 1}.txt`, `Error rendering piece: ${err}`);
}
onProgress?.({ current: i + 1, total, status: 'rendering' });
}
onProgress?.({ current: total, total, status: 'packaging' });
// Generate ZIP
const zipBlob = await zip.generateAsync({ type: 'blob' });
// Trigger download
const zipName = `${template.name}_${brand.name}_lote-${total}.zip`
.replace(/\s+/g, '_')
.replace(/[^a-zA-Z0-9._-]/g, '');
saveAs(zipBlob, zipName);
onProgress?.({ current: total, total, status: 'done' });
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Track and clean up blob URLs to prevent memory leaks.
*
* Usage:
* const url = createTrackedBlobURL(file);
* // ... use the url ...
* revokeTrackedBlobURL(url); // when done
*/
const trackedURLs = new Set<string>();
/**
* Create a blob URL and track it for later cleanup.
*/
export function createTrackedBlobURL(source: File | Blob): string {
const url = URL.createObjectURL(source);
trackedURLs.add(url);
return url;
}
/**
* Revoke a previously tracked blob URL to free memory.
* Safe to call with non-blob URLs (no-op).
*/
export function revokeTrackedBlobURL(url: string): void {
if (trackedURLs.has(url)) {
URL.revokeObjectURL(url);
trackedURLs.delete(url);
}
}
/**
* Revoke all tracked blob URLs. Useful for full cleanup.
*/
export function revokeAllTrackedBlobURLs(): void {
trackedURLs.forEach(url => URL.revokeObjectURL(url));
trackedURLs.clear();
}
/**
* Check if a URL is a blob URL that should be revoked.
*/
export function isBlobURL(url: string): boolean {
return url.startsWith('blob:');
}
+150
View File
@@ -0,0 +1,150 @@
import { ExpressField, ExpressScene, TimelineElement } from '../types';
const FPS = 30;
/**
* Convert ExpressField[] from a scene into TimelineElement[] for use in EditorContext.
* This lets the Template Builder use the Studio's full rendering and editing stack.
*/
export function fieldsToElements(
scene: ExpressScene,
layerId: string = 'layer-1',
): TimelineElement[] {
const durationFrames = (scene.durationSeconds || 5) * FPS;
return scene.editableFields.map((field): TimelineElement => {
const base: TimelineElement = {
id: field.id,
layerId,
type: fieldTypeToElementType(field.type),
content: field.placeholder || field.label,
startFrame: 0,
endFrame: durationFrames,
x: field.position.x,
y: field.position.y,
width: field.position.w,
height: field.position.h,
fontSize: field.style.fontSize,
fontWeight: field.style.fontWeight,
color: field.style.color,
textAlign: field.style.textAlign as TimelineElement['textAlign'],
opacity: field.style.opacity, // Both use 0-100 scale
elementName: field.label,
// Shape-specific properties
shapeType: field.style.shapeType,
shapeFill: field.style.shapeFill,
shapeStroke: field.style.shapeStroke,
shapeStrokeWidth: field.style.shapeStrokeWidth,
shapeCornerRadius: field.style.shapeCornerRadius,
};
// Preserve brand metadata as notes for round-trip
if (field.brandSource || field.brandAssetId) {
base.notes = JSON.stringify({
__expressField: true,
brandSource: field.brandSource,
brandAssetId: field.brandAssetId,
required: field.required,
fieldType: field.type,
});
}
return base;
});
}
/**
* Convert TimelineElement[] back to ExpressField[] for serialization as ExpressTemplate.
* Preserves brand metadata stored in element.notes.
*/
export function elementsToFields(elements: TimelineElement[]): ExpressField[] {
return elements
.filter(el => !el.isBrandElement) // Don't export brand-injected elements
.map((el): ExpressField => {
// Recover brand metadata from notes if present
let brandMeta: {
brandSource?: ExpressField['brandSource'];
brandAssetId?: string;
required?: boolean;
fieldType?: ExpressField['type'];
} = {};
if (el.notes) {
try {
const parsed = JSON.parse(el.notes);
if (parsed.__expressField) {
brandMeta = parsed;
}
} catch { /* not JSON, ignore */ }
}
return {
id: el.id,
type: brandMeta.fieldType || elementTypeToFieldType(el.type),
label: el.elementName || el.content?.slice(0, 30) || 'Campo',
placeholder: el.content || '',
required: brandMeta.required ?? false,
brandSource: brandMeta.brandSource,
brandAssetId: brandMeta.brandAssetId,
position: {
x: el.x,
y: el.y,
w: el.width ?? 30,
h: el.height ?? 10,
},
style: {
fontSize: el.fontSize,
fontWeight: el.fontWeight,
textAlign: el.textAlign,
color: el.color,
opacity: el.opacity, // Both use 0-100 scale
fontFamily: el.fontFamily,
// Shape-specific properties
shapeType: el.shapeType,
shapeFill: el.shapeFill,
shapeStroke: el.shapeStroke,
shapeStrokeWidth: el.shapeStrokeWidth,
shapeCornerRadius: el.shapeCornerRadius,
},
};
});
}
/**
* Convert all scenes' fields to elements, keyed by sceneId.
* Used when loading an existing template for editing.
*/
export function templateToSceneElements(
scenes: ExpressScene[],
layerId: string = 'layer-1',
): Record<string, TimelineElement[]> {
const result: Record<string, TimelineElement[]> = {};
for (const scene of scenes) {
result[scene.id] = fieldsToElements(scene, layerId);
}
return result;
}
// ── Helpers ──
function fieldTypeToElementType(type: ExpressField['type']): TimelineElement['type'] {
switch (type) {
case 'text': return 'text';
case 'media': return 'image';
case 'logo': return 'image';
case 'shape': return 'shape';
default: return 'text';
}
}
function elementTypeToFieldType(type: TimelineElement['type']): ExpressField['type'] {
switch (type) {
case 'text': return 'text';
case 'image':
case 'video':
case 'sticker': return 'media';
case 'shape': return 'shape';
case 'color': return 'media';
default: return 'text';
}
}
+161
View File
@@ -0,0 +1,161 @@
/**
* captionGenerator — Converts word-level Whisper timestamps into TimelineElements.
*
* Groups words into caption phrases (by pause duration or word count).
* Each group becomes a text element positioned at the bottom of the canvas.
*/
import { TimelineElement } from '../types';
export interface WhisperWord {
word: string;
start: number; // seconds
end: number; // seconds
}
export interface CaptionStyle {
position: 'bottom' | 'center' | 'top';
fontSize: number;
color: string;
backgroundColor?: string;
maxWordsPerGroup: number;
}
export const DEFAULT_CAPTION_STYLE: CaptionStyle = {
position: 'bottom',
fontSize: 42,
color: '#ffffff',
backgroundColor: '#000000AA',
maxWordsPerGroup: 6,
};
export const CAPTION_PRESETS: { name: string; style: CaptionStyle }[] = [
{
name: 'Subtítulo Clásico',
style: {
position: 'bottom',
fontSize: 38,
color: '#ffffff',
backgroundColor: '#000000AA',
maxWordsPerGroup: 6,
},
},
{
name: 'TikTok',
style: {
position: 'center',
fontSize: 52,
color: '#ffffff',
backgroundColor: undefined,
maxWordsPerGroup: 4,
},
},
{
name: 'Karaoke',
style: {
position: 'bottom',
fontSize: 44,
color: '#FFDD00',
backgroundColor: '#000000CC',
maxWordsPerGroup: 5,
},
},
{
name: 'Minimalista',
style: {
position: 'bottom',
fontSize: 32,
color: '#ffffff',
backgroundColor: undefined,
maxWordsPerGroup: 8,
},
},
];
/**
* Group words into caption phrases.
* Split by:
* 1. Max words per group
* 2. Pause > pauseThreshold between words
*/
function groupWords(
words: WhisperWord[],
maxWords: number,
pauseThreshold = 0.5,
): WhisperWord[][] {
if (words.length === 0) return [];
const groups: WhisperWord[][] = [];
let currentGroup: WhisperWord[] = [words[0]];
for (let i = 1; i < words.length; i++) {
const gap = words[i].start - words[i - 1].end;
const shouldSplit = currentGroup.length >= maxWords || gap > pauseThreshold;
if (shouldSplit) {
groups.push(currentGroup);
currentGroup = [words[i]];
} else {
currentGroup.push(words[i]);
}
}
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
return groups;
}
/**
* Generate TimelineElements for auto-captions.
*
* @param words - Word-level timestamps from Whisper
* @param fps - Frames per second
* @param audioStartFrame - Frame offset of the audio element
* @param layerId - Target layer for caption elements
* @param style - Caption style preset
*/
export function generateCaptionElements(
words: WhisperWord[],
fps: number,
audioStartFrame: number,
layerId: string,
style: CaptionStyle = DEFAULT_CAPTION_STYLE,
): TimelineElement[] {
const groups = groupWords(words, style.maxWordsPerGroup);
// Y position based on style.position
const yPosition = style.position === 'top' ? 10 : style.position === 'center' ? 45 : 80;
return groups.map((group, idx) => {
const startSec = group[0].start;
const endSec = group[group.length - 1].end;
const text = group.map(w => w.word).join(' ');
// Store word timing info in the content as JSON for potential per-word highlighting
const startFrame = audioStartFrame + Math.round(startSec * fps);
const endFrame = audioStartFrame + Math.round(endSec * fps) + Math.round(fps * 0.3); // add 300ms padding
return {
id: `caption-${Date.now()}-${idx}`,
layerId,
type: 'text' as const,
content: text,
startFrame,
endFrame,
x: 50,
y: yPosition,
fontSize: style.fontSize,
color: style.color,
fontWeight: 700,
textAlign: 'center' as const,
textBackground: style.backgroundColor,
textBackgroundPadding: 10,
textBackgroundRadius: 8,
lineHeight: 1.3,
// Mark as auto-caption for potential future filtering
useBranding: false,
};
});
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Chroma Key Utilities
*
* Core pixel-processing algorithm for removing solid-color backgrounds
* from images and video frames. Operates on Canvas 2D ImageData.
*/
export interface ChromaKeyParams {
/** RGB tuple of the key color to remove */
keyColor: [number, number, number];
/** Tolerance in color distance (0-255 range, mapped from 0-100%) */
tolerance: number;
/** Edge softness in color distance units */
softness: number;
}
/** Preset colors for quick selection — optimized for common use cases */
export const CHROMA_KEY_PRESETS = [
{ color: '#ffffff', label: 'Blanco', icon: '⬜' },
{ color: '#000000', label: 'Negro', icon: '⬛' },
{ color: '#00ff00', label: 'Verde', icon: '🟩' },
{ color: '#0000ff', label: 'Azul', icon: '🟦' },
] as const;
/**
* Parse a hex color string to an RGB tuple.
* Supports #RGB, #RRGGBB, and plain RRGGBB formats.
*/
export function hexToRgb(hex: string): [number, number, number] {
let clean = hex.replace('#', '');
if (clean.length === 3) {
clean = clean[0] + clean[0] + clean[1] + clean[1] + clean[2] + clean[2];
}
const num = parseInt(clean, 16);
return [
(num >> 16) & 0xff,
(num >> 8) & 0xff,
num & 0xff,
];
}
/**
* Convert RGB to a hex color string (#RRGGBB).
*/
export function rgbToHex(r: number, g: number, b: number): string {
return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('');
}
/**
* Apply chroma key to canvas ImageData in-place.
*
* Uses Euclidean distance in RGB color space to determine how close
* each pixel is to the key color. Pixels within tolerance become fully
* transparent; pixels in the softness zone get partial transparency for
* smooth edges.
*
* @param imageData - The ImageData to process (modified in-place)
* @param params - Chroma key configuration
*/
export function applyChromaKey(
imageData: ImageData,
params: ChromaKeyParams
): void {
const { keyColor, tolerance, softness } = params;
const data = imageData.data;
const [kr, kg, kb] = keyColor;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
// Euclidean distance in RGB space
const dr = r - kr;
const dg = g - kg;
const db = b - kb;
const distance = Math.sqrt(dr * dr + dg * dg + db * db);
if (distance < tolerance) {
// Fully transparent — within the key color range
data[i + 3] = 0;
} else if (softness > 0 && distance < tolerance + softness) {
// Partial transparency — smooth edge transition
const alpha = Math.round(255 * ((distance - tolerance) / softness));
data[i + 3] = Math.min(data[i + 3], alpha);
// Spill suppression: reduce the key color influence on edge pixels
// This prevents a colored "halo" around the subject
const spillFactor = 1 - ((tolerance + softness - distance) / softness) * 0.5;
data[i] = Math.round(r + (r - kr) * (1 - spillFactor));
data[i + 1] = Math.round(g + (g - kg) * (1 - spillFactor));
data[i + 2] = Math.round(b + (b - kb) * (1 - spillFactor));
// Clamp values
data[i] = Math.max(0, Math.min(255, data[i]));
data[i + 1] = Math.max(0, Math.min(255, data[i + 1]));
data[i + 2] = Math.max(0, Math.min(255, data[i + 2]));
}
// else: pixel is outside the range, keep as-is
}
}
/**
* Map user-facing percentage values (0-100) to internal distance values.
* The max RGB distance is ~441 (sqrt(255² + 255² + 255²)).
*/
export function mapToleranceToDistance(tolerancePercent: number): number {
return (tolerancePercent / 100) * 441;
}
export function mapSoftnessToDistance(softnessPercent: number): number {
return (softnessPercent / 100) * 150;
}
+403
View File
@@ -0,0 +1,403 @@
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 */
export function getTemplateDuration(template: ExpressTemplate): number {
return template.scenes.reduce((sum, s) => sum + s.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,
): { 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) {
const sceneDurFrames = scene.durationSeconds * 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: scene.segmentVideoFit || (isIntro
? (designMD.introVideoFit || 'cover')
: (designMD.outroVideoFit || 'cover')) as 'cover' | 'contain' | 'fill',
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.style.mediaFit || 'cover') as 'cover' | 'contain' | 'fill',
} : {}),
...(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 };
}
+248
View File
@@ -0,0 +1,248 @@
/**
* Google Fonts API — Search, load, and cache fonts.
*
* Provides:
* - fetchGoogleFonts(): Fetch & cache the full font catalog (top 200 by popularity)
* - searchFonts(): Fuzzy search by family name
* - loadGoogleFont(): Inject CSS link + wait for font to render
* - getRecentFonts() / addRecentFont(): Recent fonts list (localStorage)
*/
export interface FontMeta {
family: string;
category: string; // sans-serif, serif, display, handwriting, monospace
variants: string[];
subsets: string[];
}
// ─── Cache keys ───
const CACHE_KEY = 'remix-google-fonts-cache';
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
const RECENT_KEY = 'remix-recent-fonts';
const MAX_RECENT = 10;
// ─── In-memory state ───
const loadedFonts = new Set<string>();
let cachedFontList: FontMeta[] | null = null;
// ─── Static fallback (top 200 by popularity) ───
const FALLBACK_FONTS: FontMeta[] = [
// Sans Serif
{ family: 'Inter', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Roboto', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Open Sans', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Montserrat', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Poppins', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Nunito', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Lato', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Raleway', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Work Sans', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Nunito Sans', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Rubik', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Outfit', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Space Grotesk', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'DM Sans', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Figtree', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Manrope', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Plus Jakarta Sans', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Ubuntu', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Karla', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Cabin', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Barlow', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Quicksand', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Josefin Sans', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Mulish', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Comfortaa', category: 'sans-serif', variants: ['400', '700'], subsets: ['latin'] },
// Serif
{ family: 'Playfair Display', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Merriweather', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Lora', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Crimson Text', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'PT Serif', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Bitter', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Source Serif 4', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Libre Baskerville', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'EB Garamond', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Cormorant Garamond', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Spectral', category: 'serif', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'DM Serif Display', category: 'serif', variants: ['400'], subsets: ['latin'] },
// Display
{ family: 'Bebas Neue', category: 'display', variants: ['400'], subsets: ['latin'] },
{ family: 'Oswald', category: 'display', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Anton', category: 'display', variants: ['400'], subsets: ['latin'] },
{ family: 'Archivo Black', category: 'display', variants: ['400'], subsets: ['latin'] },
{ family: 'Righteous', category: 'display', variants: ['400'], subsets: ['latin'] },
{ family: 'Passion One', category: 'display', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Staatliches', category: 'display', variants: ['400'], subsets: ['latin'] },
{ family: 'Bungee', category: 'display', variants: ['400'], subsets: ['latin'] },
{ family: 'Bangers', category: 'display', variants: ['400'], subsets: ['latin'] },
{ family: 'Teko', category: 'display', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Titan One', category: 'display', variants: ['400'], subsets: ['latin'] },
{ family: 'Lilita One', category: 'display', variants: ['400'], subsets: ['latin'] },
// Handwriting
{ family: 'Dancing Script', category: 'handwriting', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Pacifico', category: 'handwriting', variants: ['400'], subsets: ['latin'] },
{ family: 'Caveat', category: 'handwriting', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Satisfy', category: 'handwriting', variants: ['400'], subsets: ['latin'] },
{ family: 'Great Vibes', category: 'handwriting', variants: ['400'], subsets: ['latin'] },
{ family: 'Sacramento', category: 'handwriting', variants: ['400'], subsets: ['latin'] },
{ family: 'Permanent Marker', category: 'handwriting', variants: ['400'], subsets: ['latin'] },
{ family: 'Indie Flower', category: 'handwriting', variants: ['400'], subsets: ['latin'] },
{ family: 'Kaushan Script', category: 'handwriting', variants: ['400'], subsets: ['latin'] },
{ family: 'Yellowtail', category: 'handwriting', variants: ['400'], subsets: ['latin'] },
// Monospace
{ family: 'JetBrains Mono', category: 'monospace', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Fira Code', category: 'monospace', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Source Code Pro', category: 'monospace', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Roboto Mono', category: 'monospace', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'Space Mono', category: 'monospace', variants: ['400', '700'], subsets: ['latin'] },
{ family: 'IBM Plex Mono', category: 'monospace', variants: ['400', '700'], subsets: ['latin'] },
];
/**
* Fetch Google Fonts catalog. Uses localStorage cache with 24h TTL.
* Falls back to built-in static list if API call fails or no key is provided.
*/
export async function fetchGoogleFonts(apiKey?: string): Promise<FontMeta[]> {
if (cachedFontList) return cachedFontList;
// Check localStorage cache
try {
const raw = localStorage.getItem(CACHE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed.ts && Date.now() - parsed.ts < CACHE_TTL && parsed.fonts?.length > 0) {
cachedFontList = parsed.fonts;
return parsed.fonts;
}
}
} catch {}
// Fetch from API
if (apiKey) {
try {
const res = await fetch(
`https://www.googleapis.com/webfonts/v1/webfonts?key=${apiKey}&sort=popularity`
);
if (res.ok) {
const data = await res.json();
const fonts: FontMeta[] = data.items.slice(0, 200).map((item: any) => ({
family: item.family,
category: item.category,
variants: item.variants,
subsets: item.subsets,
}));
// Persist to localStorage
try {
localStorage.setItem(CACHE_KEY, JSON.stringify({ ts: Date.now(), fonts }));
} catch {}
cachedFontList = fonts;
return fonts;
}
} catch (err) {
console.warn('Google Fonts API fetch failed, using fallback:', err);
}
}
// Use fallback
cachedFontList = FALLBACK_FONTS;
return FALLBACK_FONTS;
}
/**
* Search fonts by family name (case-insensitive substring match).
*/
export function searchFonts(query: string, fonts: FontMeta[]): FontMeta[] {
if (!query.trim()) return fonts;
const q = query.toLowerCase();
return fonts.filter(f => f.family.toLowerCase().includes(q));
}
/**
* Load a Google Font by injecting a <link> into <head>.
* Waits for the font to become available via document.fonts.
* Safe to call multiple times — tracks loaded fonts internally.
*/
export async function loadGoogleFont(fontFamily: string): Promise<void> {
if (loadedFonts.has(fontFamily)) return;
// Check if already available (system font or previously loaded)
try {
const alreadyLoaded = document.fonts.check(`16px "${fontFamily}"`);
if (alreadyLoaded) {
loadedFonts.add(fontFamily);
return;
}
} catch {}
// Inject <link> to Google Fonts CSS
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(fontFamily)}:wght@400;700&display=swap`;
document.head.appendChild(link);
loadedFonts.add(fontFamily);
// Wait for the font to load (with timeout)
try {
await Promise.race([
document.fonts.ready,
new Promise(resolve => setTimeout(resolve, 3000)),
]);
} catch {}
}
/**
* Check if a font is already loaded.
*/
export function isFontLoaded(fontFamily: string): boolean {
return loadedFonts.has(fontFamily);
}
/**
* Get recently used fonts from localStorage.
*/
export function getRecentFonts(): string[] {
try {
const raw = localStorage.getItem(RECENT_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
/**
* Add a font to the recent list. Most recent first, max 10.
*/
export function addRecentFont(family: string): void {
try {
let recent = getRecentFonts().filter(f => f !== family);
recent.unshift(family);
if (recent.length > MAX_RECENT) recent = recent.slice(0, MAX_RECENT);
localStorage.setItem(RECENT_KEY, JSON.stringify(recent));
} catch {}
}
/**
* Group fonts by category for display.
*/
export function groupFontsByCategory(fonts: FontMeta[]): Record<string, FontMeta[]> {
const groups: Record<string, FontMeta[]> = {};
const categoryLabels: Record<string, string> = {
'sans-serif': 'Sans Serif',
'serif': 'Serif',
'display': 'Display',
'handwriting': 'Handwriting',
'monospace': 'Monospace',
};
for (const font of fonts) {
const label = categoryLabels[font.category] || font.category;
if (!groups[label]) groups[label] = [];
groups[label].push(font);
}
return groups;
}
+137
View File
@@ -0,0 +1,137 @@
/**
* Media Uploader — Persistent file storage via server API.
*
* Replaces blob URLs with persistent server URLs that survive page reloads.
* Uploads files to the Express backend which stores them on the filesystem.
*
* Usage:
* const { url, originalName } = await uploadMedia(file);
* // url is now a persistent path like "/api/media/abc123.png"
*
* Includes a hash-based dedup cache to avoid re-uploading the same file.
*/
export interface UploadResult {
url: string;
originalName: string;
}
export interface UploadProgress {
loaded: number;
total: number;
percent: number;
}
// Cache: file hash → server URL (avoids re-uploading identical files)
const uploadCache = new Map<string, string>();
/**
* Compute a fast hash of a File using its name + size + lastModified.
* Not cryptographic, but sufficient for dedup within a session.
*/
function fileFingerprint(file: File): string {
return `${file.name}-${file.size}-${file.lastModified}`;
}
/**
* Upload a file to the server and return a persistent URL.
*
* @param file - The file to upload
* @param onProgress - Optional progress callback (uses XHR for progress events)
* @returns Persistent URL and original filename
*/
export async function uploadMedia(
file: File,
onProgress?: (progress: UploadProgress) => void
): Promise<UploadResult> {
// Check dedup cache
const fingerprint = fileFingerprint(file);
const cached = uploadCache.get(fingerprint);
if (cached) {
onProgress?.({ loaded: file.size, total: file.size, percent: 100 });
return { url: cached, originalName: file.name };
}
const formData = new FormData();
formData.append('file', file);
if (onProgress) {
// Use XMLHttpRequest for progress tracking
return new Promise<UploadResult>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
onProgress({
loaded: e.loaded,
total: e.total,
percent: Math.round((e.loaded / e.total) * 100),
});
}
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const data = JSON.parse(xhr.responseText);
uploadCache.set(fingerprint, data.url);
resolve({ url: data.url, originalName: data.originalName });
} catch {
reject(new Error('Invalid server response'));
}
} else {
reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
}
});
xhr.addEventListener('error', () => reject(new Error('Network error during upload')));
xhr.addEventListener('abort', () => reject(new Error('Upload aborted')));
xhr.open('POST', '/api/upload');
xhr.send(formData);
});
}
// Simple fetch path (no progress needed)
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (!response.ok) {
const err = await response.text();
throw new Error(`Upload failed: ${response.status}${err}`);
}
const data = await response.json();
uploadCache.set(fingerprint, data.url);
return { url: data.url, originalName: data.originalName };
}
/**
* Check if a URL is a persistent server URL (not a blob).
*/
export function isServerURL(url: string): boolean {
return url.startsWith('/api/media/');
}
/**
* Migrate a blob URL to a persistent URL by uploading the blob.
* Returns the original URL if it's already persistent.
*/
export async function ensurePersistentURL(url: string): Promise<string> {
if (isServerURL(url) || !url.startsWith('blob:')) {
return url;
}
try {
const response = await fetch(url);
const blob = await response.blob();
const file = new File([blob], 'migrated-media', { type: blob.type });
const result = await uploadMedia(file);
return result.url;
} catch (err) {
console.warn('Failed to migrate blob URL to persistent storage:', err);
return url; // Graceful fallback — keep the blob URL
}
}
+167
View File
@@ -0,0 +1,167 @@
/**
* stockMediaApi — Client-side API for searching stock photos/videos.
*
* Proxies through the Express backend to avoid exposing API keys.
* Supports Pexels API with in-memory result caching.
*/
export interface StockPhoto {
id: number;
url: string; // Full page URL
thumbUrl: string; // Small thumbnail
mediumUrl: string; // Medium resolution
fullUrl: string; // Full resolution
photographer: string;
width: number;
height: number;
alt: string;
}
export interface StockVideo {
id: number;
url: string;
thumbUrl: string;
videoUrl: string; // Direct video file
photographer: string;
width: number;
height: number;
duration: number;
}
export interface StockSearchResult<T> {
items: T[];
totalResults: number;
page: number;
perPage: number;
hasMore: boolean;
}
// ═══ In-memory cache ═══
const cache = new Map<string, { data: any; ts: number }>();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
function getCached<T>(key: string): T | null {
const entry = cache.get(key);
if (entry && Date.now() - entry.ts < CACHE_TTL) {
return entry.data as T;
}
cache.delete(key);
return null;
}
function setCache(key: string, data: any) {
cache.set(key, { data, ts: Date.now() });
}
// ═══ API Functions ═══
export async function searchStockPhotos(
query: string = '',
page: number = 1,
perPage: number = 20
): Promise<StockSearchResult<StockPhoto>> {
const cacheKey = `photos:${query}:${page}:${perPage}`;
const cached = getCached<StockSearchResult<StockPhoto>>(cacheKey);
if (cached) return cached;
try {
const params = new URLSearchParams({
q: query,
page: String(page),
per_page: String(perPage),
});
const res = await fetch(`/api/stock/photos?${params}`);
if (!res.ok) throw new Error(`Stock search failed: ${res.status}`);
const data = await res.json();
const result: StockSearchResult<StockPhoto> = {
items: (data.photos || []).map((p: any) => ({
id: p.id,
url: p.url,
thumbUrl: p.src?.small || p.src?.tiny || '',
mediumUrl: p.src?.medium || p.src?.large || '',
fullUrl: p.src?.original || p.src?.large2x || '',
photographer: p.photographer || 'Unknown',
width: p.width,
height: p.height,
alt: p.alt || '',
})),
totalResults: data.total_results || 0,
page: data.page || page,
perPage: perPage,
hasMore: (data.page || page) * perPage < (data.total_results || 0),
};
setCache(cacheKey, result);
return result;
} catch (err) {
console.error('Stock photo search error:', err);
return { items: [], totalResults: 0, page, perPage, hasMore: false };
}
}
export async function searchStockVideos(
query: string = '',
page: number = 1,
perPage: number = 15
): Promise<StockSearchResult<StockVideo>> {
const cacheKey = `videos:${query}:${page}:${perPage}`;
const cached = getCached<StockSearchResult<StockVideo>>(cacheKey);
if (cached) return cached;
try {
const params = new URLSearchParams({
q: query,
page: String(page),
per_page: String(perPage),
});
const res = await fetch(`/api/stock/videos?${params}`);
if (!res.ok) throw new Error(`Stock video search failed: ${res.status}`);
const data = await res.json();
const result: StockSearchResult<StockVideo> = {
items: (data.videos || []).map((v: any) => {
// Pick the best video file (HD preferred)
const videoFiles = v.video_files || [];
const hdFile = videoFiles.find((f: any) => f.quality === 'hd') || videoFiles[0] || {};
return {
id: v.id,
url: v.url,
thumbUrl: v.image || '',
videoUrl: hdFile.link || '',
photographer: v.user?.name || 'Unknown',
width: hdFile.width || v.width || 1920,
height: hdFile.height || v.height || 1080,
duration: v.duration || 0,
};
}),
totalResults: data.total_results || 0,
page: data.page || page,
perPage: perPage,
hasMore: (data.page || page) * perPage < (data.total_results || 0),
};
setCache(cacheKey, result);
return result;
} catch (err) {
console.error('Stock video search error:', err);
return { items: [], totalResults: 0, page, perPage, hasMore: false };
}
}
// ═══ Download stock image to server (for persistent use) ═══
export async function downloadStockToServer(url: string, filename: string): Promise<string> {
const res = await fetch('/api/stock/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, filename }),
});
if (!res.ok) throw new Error('Download failed');
const data = await res.json();
return data.url; // Server-side persistent URL
}
+76
View File
@@ -0,0 +1,76 @@
import { TimelineElement } from '../types';
/**
* Immutably update a single timeline element by ID.
* Returns a new array with the updated element — never mutates the original.
*/
export function updateElement(
elements: TimelineElement[],
id: string,
updates: Partial<TimelineElement>
): TimelineElement[] {
return elements.map(el =>
el.id === id ? { ...el, ...updates } : el
);
}
/**
* Immutably update a timeline element at a specific index.
* Returns a new array — never mutates the original.
*/
export function updateElementAtIndex(
elements: TimelineElement[],
index: number,
updates: Partial<TimelineElement>
): TimelineElement[] {
return elements.map((el, i) =>
i === index ? { ...el, ...updates } : el
);
}
/**
* Immutably delete transition properties from an element by ID.
*/
export function updateElementTransition(
elements: TimelineElement[],
id: string,
field: 'transitionIn' | 'transitionOut',
value: TimelineElement['transitionIn'] | undefined
): TimelineElement[] {
return elements.map(el => {
if (el.id !== id) return el;
const updated = { ...el };
if (value === undefined) {
delete updated[field];
} else {
updated[field] = value;
}
return updated;
});
}
/**
* Immutably update keyframe properties on an element, supporting delete.
*/
export function updateElementKeyframes(
elements: TimelineElement[],
index: number,
enabled: boolean,
currentEl: TimelineElement
): TimelineElement[] {
return elements.map((el, i) => {
if (i !== index) return el;
if (enabled) {
return {
...el,
animEndX: currentEl.x,
animEndY: currentEl.y,
animEndScale: currentEl.scale ?? 1,
animEndOpacity: 100,
};
} else {
const { animEndX, animEndY, animEndScale, animEndOpacity, ...rest } = el;
return rest as TimelineElement;
}
});
}
+122
View File
@@ -0,0 +1,122 @@
/**
* uploadBlobContent — Resolves blob: URLs in TimelineElement[] to persistent server URLs.
*
* Remotion's server-side renderer cannot access browser blob URLs.
* This utility detects blob: content, uploads each to /api/upload,
* and returns new elements with persistent /api/media/... URLs.
*/
import type { TimelineElement } from '../types';
/** Check if a URL is a browser-local blob */
function isBlobUrl(url: string): boolean {
return url.startsWith('blob:');
}
/**
* Convert a blob URL to a File for upload.
* Fetches the blob, determines a reasonable filename, and wraps it.
*/
async function blobUrlToFile(blobUrl: string, fallbackName: string): Promise<File> {
const res = await fetch(blobUrl);
const blob = await res.blob();
const ext = blob.type.includes('video') ? '.mp4'
: blob.type.includes('png') ? '.png'
: blob.type.includes('webp') ? '.webp'
: blob.type.includes('gif') ? '.gif'
: '.jpg';
return new File([blob], `${fallbackName}${ext}`, { type: blob.type });
}
/**
* Upload a single File to the server and return an absolute persistent URL.
* Must be absolute because Remotion's server-side bundler runs on a different
* port and can't resolve relative paths back to our Express server.
*/
async function uploadFile(file: File): Promise<string> {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (!res.ok) {
throw new Error(`Upload failed: ${res.status} ${res.statusText}`);
}
const data = await res.json();
// Return absolute URL so Remotion's bundler (different port) can reach it
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000';
return `${origin}${data.url}`;
}
/**
* Cache to avoid re-uploading the same blob URL within a session.
* Maps blob URL → persistent URL.
*/
const uploadCache = new Map<string, string>();
/**
* Process an array of TimelineElements, uploading any blob: content URLs
* to persistent storage and returning new elements with server URLs.
*
* Only touches elements whose `content` is a blob: URL and whose type
* is 'image' or 'video'. All other elements pass through unchanged.
*/
export async function resolveBlobUrls(elements: TimelineElement[]): Promise<TimelineElement[]> {
const result: TimelineElement[] = [];
for (const el of elements) {
if ((el.type === 'image' || el.type === 'video') && el.content && isBlobUrl(el.content)) {
// Check cache first
let persistentUrl = uploadCache.get(el.content);
if (!persistentUrl) {
try {
const file = await blobUrlToFile(el.content, `export-${el.id}`);
persistentUrl = await uploadFile(file);
uploadCache.set(el.content, persistentUrl);
} catch (err) {
console.error(`Failed to upload blob for element ${el.id}:`, err);
// Keep the blob URL (render will fail gracefully for this element)
result.push(el);
continue;
}
}
result.push({ ...el, content: persistentUrl });
} else {
result.push(el);
}
}
return result;
}
/**
* Resolve blob URLs in a fieldData record.
* Used by batch exporter to upload per-piece media before rendering.
*/
export async function resolveBlobFieldData(
fieldData: Record<string, string>,
): Promise<Record<string, string>> {
const resolved: Record<string, string> = {};
for (const [key, value] of Object.entries(fieldData)) {
if (value && isBlobUrl(value)) {
let persistentUrl = uploadCache.get(value);
if (!persistentUrl) {
try {
const file = await blobUrlToFile(value, `field-${key}`);
persistentUrl = await uploadFile(file);
uploadCache.set(value, persistentUrl);
} catch (err) {
console.error(`Failed to upload blob for field ${key}:`, err);
resolved[key] = value;
continue;
}
}
resolved[key] = persistentUrl;
} else {
resolved[key] = value;
}
}
return resolved;
}