Initial commit — Bradly branding editor platform
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
@@ -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:');
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user