/** * JIT compiler for AI-generated Remotion component code. * Uses @babel/standalone to transpile JSX, then Function() factory * to create a React component with injected Remotion APIs. */ import React from "react"; import { useCurrentFrame, useVideoConfig, interpolate, spring, Easing, AbsoluteFill, Sequence, Img, random, } from "remotion"; import * as Remotion from "remotion"; import * as Recharts from "recharts"; import * as RemotionTransitions from "@remotion/transitions"; import { Player } from "@remotion/player"; import { getTemplateConfig } from "../components/remotion/templateConfig"; import { CaptionTrack } from "../components/remotion/CaptionTrack"; import { BackgroundMusic } from "../components/remotion/BackgroundMusic"; import * as Kit from "../components/remotion/generated/kit"; // Craft-kit exports injected into JIT-compiled AI scene code, mirroring the // import list in the backend's _wrap_generated_code so preview === render. const KIT_EXPORTS = [ "SceneFrame", "useKit", "CountUpValue", "StatCard", "StatGrid", "MetricRow", "RevealText", "HighlightPhrase", "FitText", "CodeBlock", "KenBurnsImage", "Decor", "CustomChart", "CustomTable", "cardStyle", "derivePalette", "withAlpha", "staggerEntrance", "headlinePop", "panelRise", "masterOpacity", "countUpString", "drawProgress", "seededRand", // Layout skeletons (intra-video structural variety) "CenteredFocal", "AsymmetricSplit", "FullBleedHero", "OffsetCardStack", "SideRail", // Intro scaffold (bookend richness) "IntroStage", // Signature artifacts — the brand's recurring animated motif "SignatureArtifact", "CornerFrame", "StreakField", "KineticTicker", "BigGlyphBackdrop", "PulseRing", "AccentSweep", "DiagonalShards", "HalftoneField", "StarburstBadge", "LightDust", "OrbitRings", ] as const; export interface SceneProps { displayText: string; narrationText: string; imageUrl?: string; imageObjectPosition?: string; imageZoom?: number; /** True when a stock-footage clip is filling this scene's visual slot. * GeneratedVideo/the preview renders the clip itself — the component must * leave that slot's area empty/transparent rather than treating the scene * as if it has no visual at all. imageUrl is undefined in this case. */ hasVideo?: boolean; sceneIndex: number; totalScenes: number; logoUrl?: string; brandImages?: string[]; brandColors: { primary: string; secondary: string; accent: string; background: string; text: string; }; aspectRatio: "landscape" | "portrait"; /** Structured content fields — populated when blog content contains lists, stats, quotes, etc. */ contentType?: "plain" | "bullets" | "metrics" | "code" | "quote" | "comparison" | "timeline" | "steps" | "dataviz"; bullets?: string[]; metrics?: { value: string; label: string; suffix?: string }[]; codeLines?: string[]; codeLanguage?: string; quote?: string; quoteAuthor?: string; comparisonLeft?: { label: string; description: string }; comparisonRight?: { label: string; description: string }; timelineItems?: { label: string; description: string }[]; steps?: string[]; /** Data-viz fields — used by the dedicated kit chart/table scenes (DataChartScene/DataTableScene). */ chartTable?: { headers?: string[]; rows?: (string | number)[][] }; chartType?: string; chartSummary?: string; titleFontSize?: number; descriptionFontSize?: number; headingFont?: string; bodyFont?: string; } export type CompileResult = | { success: true; component: React.FC } | { success: false; error: string }; export type MultiFileCompileResult = | { success: true; component: React.ComponentType; exports: Record } | { success: false; error: string }; // Lazy-loaded Babel reference let babelPromise: Promise | null = null; function loadBabel() { if (!babelPromise) { babelPromise = import("@babel/standalone"); } return babelPromise; } /** * Pre-load Babel so it's ready when needed. Call this early * (e.g. when user navigates to custom templates page). */ export function preloadBabel(): void { loadBabel(); } /** * Compile a code string into a React component. * The code should define `const SceneComponent = (props) => { ... }` * with no import/export statements. */ export async function compileComponentCode( code: string ): Promise { // console.log("[F7-DEBUG] compileComponentCode called: code length =", code.length, "chars"); try { const Babel = await loadBabel(); // console.log("[F7-DEBUG] Babel loaded successfully"); // Strip any import/export statements the LLM might have added const cleaned = code .replace(/^import\s+.*$/gm, "") .replace(/^export\s+(default\s+)?/gm, ""); // Transpile JSX → plain JS const result = Babel.transform(cleaned, { presets: ["react"], filename: "generated.tsx", }); if (!result?.code) { return { success: false, error: "Babel transform returned empty code" }; } // Safe wrapper around interpolate — ensures inputRange is strictly monotonic // even when the LLM generates dynamic ranges that resolve to equal values at runtime. const safeInterpolate: typeof interpolate = (frame, inputRange, outputRange, options?) => { const safe = (inputRange as number[]).map((v, i) => i === 0 ? v : Math.max(v, (inputRange as number[])[i - 1] + 1) ) as typeof inputRange; return interpolate(frame, safe, outputRange, options); }; // Create factory function that receives Remotion APIs + craft kit as parameters // eslint-disable-next-line no-new-func const factory = new Function( "React", "useCurrentFrame", "useVideoConfig", "interpolate", "spring", "Easing", "AbsoluteFill", "Sequence", "Img", "random", ...KIT_EXPORTS, result.code + "\nreturn SceneComponent;" ); const SceneComponent = factory( React, useCurrentFrame, useVideoConfig, safeInterpolate, spring, Easing, AbsoluteFill, Sequence, Img, random, ...KIT_EXPORTS.map((name) => (Kit as Record)[name]) ); if (typeof SceneComponent !== "function") { // console.error("[F7-DEBUG] SceneComponent is not a function, got:", typeof SceneComponent); return { success: false, error: "Generated code did not produce a valid SceneComponent function", }; } // console.log("[F7-DEBUG] Compilation SUCCESS — SceneComponent is a valid function"); return { success: true, component: SceneComponent as React.FC }; } catch (err) { const message = err instanceof Error ? err.message : String(err); console.error("[F7-DEBUG] Compilation FAILED:", message); return { success: false, error: message }; } } /** * Compile a single self-contained TSX preview component. * * Designed for the marquee preview file shipped with each crafted template * (see backend/templates/CRAFTED_TEMPLATE_FOLDER_SPEC.md §4). Authoring * rules: default-export a component accepting `{ thumbnailMode?: boolean }`, * with no `import` statements (they are stripped). * * The runtime injects these as free variables — the preview can reference * them directly without imports: * - `React`, `useState`, `useEffect`, `useRef`, `useMemo`, `useCallback` * - `Player` (from `@remotion/player`) * - `getTemplateConfig` (built-in template lookup) * - Remotion primitives for inline scenes (so the preview can define * its own composition + layouts using the same APIs the real video * uses): `useCurrentFrame`, `useVideoConfig`, `interpolate`, `spring`, * `Easing`, `AbsoluteFill`, `Sequence`, `Img`, `random`. */ export async function compilePreviewComponent( code: string, ): Promise<{ success: true; component: React.ComponentType<{ thumbnailMode?: boolean }> } | { success: false; error: string }> { try { const Babel = await loadBabel(); const cleaned = code .replace(/^\s*import\s+[^;\n]*;?\s*$/gm, "") .replace(/^\s*export\s+default\s+/gm, "const __PreviewComponent__ = ") .replace(/^\s*export\s+/gm, ""); const transformed = Babel.transform(cleaned, { presets: ["react", "typescript"], filename: "preview.tsx", }); if (!transformed?.code) { return { success: false, error: "Babel transform returned empty code" }; } // Mirror the safe-interpolate wrapper from compileComponentCode so // dynamic inputRanges that resolve to equal values don't crash at runtime. const safeInterpolate: typeof interpolate = (frame, inputRange, outputRange, options?) => { const safe = (inputRange as number[]).map((v, i) => i === 0 ? v : Math.max(v, (inputRange as number[])[i - 1] + 1) ) as typeof inputRange; return interpolate(frame, safe, outputRange, options); }; // eslint-disable-next-line no-new-func const factory = new Function( "React", "useState", "useEffect", "useRef", "useMemo", "useCallback", "Player", "getTemplateConfig", "useCurrentFrame", "useVideoConfig", "interpolate", "spring", "Easing", "AbsoluteFill", "Sequence", "Img", "random", transformed.code + "\nreturn typeof __PreviewComponent__ !== 'undefined' ? __PreviewComponent__ : null;", ); const component = factory( React, React.useState, React.useEffect, React.useRef, React.useMemo, React.useCallback, Player, getTemplateConfig, useCurrentFrame, useVideoConfig, safeInterpolate, spring, Easing, AbsoluteFill, Sequence, Img, random, ); if (typeof component !== "function") { return { success: false, error: "Preview file did not produce a default-exported component" }; } return { success: true, component: component as React.ComponentType<{ thumbnailMode?: boolean }> }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { success: false, error: message }; } } /** * Compile a self-contained TS/JSON data module (no React, no JSX). * * Designed for crafted template data files like `frontend/layoutFields.ts` * that ship in the bundle and export plain data via `export const X = {…}` * or `export default {…}`. Imports/exports are stripped, the file is run * through Babel's TypeScript preset (with module → CommonJS), and the * resulting `module.exports` object is returned for the caller to read. * * Returns `null` on any failure (compile error, malformed source, throw). * Callers should treat that as "no override" and fall back accordingly. */ export async function compileDataModule( source: string, ): Promise | null> { if (!source || !source.trim()) return null; // Plain JSON shortcut — no Babel needed. const trimmed = source.trim(); if (trimmed.startsWith("{") || trimmed.startsWith("[")) { try { const parsed = JSON.parse(trimmed); return { default: parsed, ...(parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {}) }; } catch { // fall through to Babel path } } try { const Babel = await loadBabel(); const transformed = Babel.transform(source, { presets: ["typescript"], plugins: ["transform-modules-commonjs"], filename: "data.ts", }); if (!transformed?.code) return null; const moduleObj = { exports: {} as Record }; const noopRequire = (): Record => ({}); // eslint-disable-next-line no-new-func const factory = new Function("exports", "module", "require", transformed.code); factory(moduleObj.exports, moduleObj, noopRequire); return moduleObj.exports; } catch (err) { console.warn("[compileDataModule] failed:", err); return null; } } function normalizeRelPath(path: string): string { return String(path || "").replace(/\\/g, "/").replace(/^\/+/, ""); } function dirOf(path: string): string { const norm = normalizeRelPath(path); const idx = norm.lastIndexOf("/"); return idx >= 0 ? norm.slice(0, idx) : ""; } function joinPath(baseDir: string, rel: string): string { const stack = baseDir ? baseDir.split("/").filter(Boolean) : []; for (const part of rel.split("/")) { if (!part || part === ".") continue; if (part === "..") { stack.pop(); continue; } stack.push(part); } return stack.join("/"); } function resolveRelativeModule( fromPath: string, specifier: string, files: Map ): string | null { const fromDir = dirOf(fromPath); const base = joinPath(fromDir, specifier); const root = normalizeRelPath(fromPath).split("/")[0] || ""; const candidates = [ base, `${base}.ts`, `${base}.tsx`, `${base}.js`, `${base}.jsx`, `${base}/index.ts`, `${base}/index.tsx`, `${base}/index.js`, `${base}/index.jsx`, ].map(normalizeRelPath); // If a relative import traverses above the module graph root (e.g. ../../SocialIcons // from frontend/layouts/*), try resolving it inside the same root namespace first. // Example fallback: SocialIcons.tsx -> frontend/SocialIcons.tsx if (root && base && !base.startsWith(`${root}/`) && base !== root) { candidates.push( normalizeRelPath(`${root}/${base}`), normalizeRelPath(`${root}/${base}.ts`), normalizeRelPath(`${root}/${base}.tsx`), normalizeRelPath(`${root}/${base}.js`), normalizeRelPath(`${root}/${base}.jsx`), normalizeRelPath(`${root}/${base}/index.ts`), normalizeRelPath(`${root}/${base}/index.tsx`), normalizeRelPath(`${root}/${base}/index.js`), normalizeRelPath(`${root}/${base}/index.jsx`), ); } for (const candidate of candidates) { if (files.has(candidate)) return candidate; } return null; } function pickComponentExport(exportsObj: Record): React.ComponentType | null { const defaultExport = exportsObj.default; if (typeof defaultExport === "function") { return defaultExport as React.ComponentType; } const keys = Object.keys(exportsObj); const preferred = keys.find((k) => /(Composition|Video|GeneratedVideo)$/i.test(k)); if (preferred && typeof exportsObj[preferred] === "function") { return exportsObj[preferred] as React.ComponentType; } const firstFn = keys.find((k) => typeof exportsObj[k] === "function"); return firstFn ? (exportsObj[firstFn] as React.ComponentType) : null; } export async function compileModuleGraphEntry( filesMap: Record, entryRelPath: string, publicAssetUrls?: Record | null, ): Promise { try { const Babel = await loadBabel(); const files = new Map(); for (const [k, v] of Object.entries(filesMap || {})) { if (typeof k !== "string" || typeof v !== "string") continue; files.set(normalizeRelPath(k), v); } const entry = normalizeRelPath(entryRelPath); if (!entry || !files.has(entry)) { return { success: false, error: `Entry not found in frontend_files: ${entryRelPath}` }; } const staticFileOverrides = publicAssetUrls && typeof publicAssetUrls === "object" ? publicAssetUrls : null; const resolveStaticFile = (filePath: string) => { const key = String(filePath || "") .replace(/\\/g, "/") .replace(/^\.+\//, "") .replace(/^\/+/, ""); const mapped = staticFileOverrides?.[key] || staticFileOverrides?.[`public/${key}`]; if (mapped) return mapped; // Crafted bundles compile outside Vite's normal module graph. If the // package has not provided a CDN mapping yet, fall back to the app's // public root so local previews can still load template assets. if (key.startsWith("templates/")) return `/${key}`; return Remotion.staticFile(filePath); }; const remotionRuntime = staticFileOverrides && Object.keys(staticFileOverrides).length > 0 ? ({ ...Remotion, staticFile: resolveStaticFile, } as typeof Remotion) : ({ ...Remotion, staticFile: resolveStaticFile } as typeof Remotion); const moduleCache = new Map>(); const compiling = new Set(); const makeMissingModuleStub = (specifier: string): Record => { const noOpComponent = () => null; const noOpFn = () => undefined; const spec = String(specifier || ""); const RuntimeLogoOverlay = ({ src, position = "bottom_right", maxOpacity = 0.9, size: sizePercent = 100, aspectRatio = "landscape", }: { src?: string; position?: string; maxOpacity?: number; size?: number; aspectRatio?: string; }) => { const frame = Remotion.useCurrentFrame(); const { width, height } = Remotion.useVideoConfig(); if (!src) return null; const isPortrait = aspectRatio === "portrait" || height > width; const opacity = Remotion.interpolate(frame, [0, 20], [0, maxOpacity], { extrapolateRight: "clamp", }); const percent = typeof sizePercent === "number" && sizePercent > 0 ? sizePercent : 100; const baseSize = isPortrait ? Math.round(width * 0.12) : Math.round(width * 0.105); const size = Math.round(baseSize * (percent / 100)); const margin = isPortrait ? Math.round(width * 0.032) : Math.round(width * 0.022); const posStyle: React.CSSProperties = { position: "absolute", zIndex: 100, opacity, width: size, height: size, filter: "drop-shadow(0 1px 4px rgba(0,0,0,0.25))", }; switch (position) { case "top_left": posStyle.top = margin; posStyle.left = margin; break; case "top_right": posStyle.top = margin; posStyle.right = margin; break; case "bottom_left": posStyle.bottom = margin; posStyle.left = margin; break; case "bottom_right": default: posStyle.bottom = margin; posStyle.right = margin; break; } return React.createElement( "div", { style: posStyle }, React.createElement(Remotion.Img, { src, style: { width: "100%", height: "100%", objectFit: "contain" }, }), ); }; if (spec.toLowerCase().includes("playbackspeed")) { return { __esModule: true, default: {}, getPlaybackSpeed: (speed?: number) => { const s = Number(speed); return Number.isFinite(s) && s > 0 ? s : 1; }, getSceneDurationFrames: (seconds?: number, fps?: number, speed?: number) => { const sec = Number(seconds); const framesPerSecond = Number(fps); const playback = Number(speed); const safeSec = Number.isFinite(sec) && sec > 0 ? sec : 5; const safeFps = Number.isFinite(framesPerSecond) && framesPerSecond > 0 ? framesPerSecond : 30; const safePlayback = Number.isFinite(playback) && playback > 0 ? playback : 1; return Math.max(1, Math.round((safeSec * safeFps) / safePlayback)); }, }; } if (spec.toLowerCase().includes("logooverlay")) { return { __esModule: true, default: RuntimeLogoOverlay, LogoOverlay: RuntimeLogoOverlay, }; } if (spec.toLowerCase().includes("socialicons")) { return { __esModule: true, default: noOpComponent, SocialIcons: noOpComponent, }; } // Captions: crafted compositions (e.g. fj_research) import "../CaptionTrack", // a shared component not shipped inside the bundle. Provide the real one so // captions render in the editor preview, matching the final render. if (spec.toLowerCase().includes("captiontrack")) { return { __esModule: true, default: CaptionTrack, CaptionTrack, }; } // Background music: crafted compositions import "../BackgroundMusic", another // shared component not shipped inside the bundle. Provide the real one so BGM // plays in the editor preview, matching the final render. if (spec.toLowerCase().includes("backgroundmusic")) { return { __esModule: true, default: BackgroundMusic, BackgroundMusic, }; } if (spec === "react/jsx-runtime" || spec === "react/jsx-dev-runtime") { const jsx = (type: unknown, props: Record | null, key?: unknown) => { const { children, ...rest } = (props || {}) as { children?: unknown }; if (key !== undefined) (rest as Record).key = key; return React.createElement( type as React.ElementType, rest as React.Attributes, children as React.ReactNode, ); }; return { __esModule: true, jsx, jsxs: jsx, jsxDEV: jsx, Fragment: React.Fragment, }; } // Generic fallback for optional side-effect/shared imports (fonts, style helpers, etc.). return new Proxy( { __esModule: true, default: noOpComponent }, { get(target, prop) { if (prop in target) return (target as Record)[String(prop)]; return noOpFn; }, } ) as Record; }; const loadModule = (modulePath: string): Record => { const normPath = normalizeRelPath(modulePath); if (moduleCache.has(normPath)) return moduleCache.get(normPath)!; if (compiling.has(normPath)) { return {}; } const source = files.get(normPath); if (source == null) { throw new Error(`Missing module: ${normPath}`); } compiling.add(normPath); const transformed = Babel.transform(source, { presets: ["react", "typescript"], plugins: ["transform-modules-commonjs"], filename: normPath, }); if (!transformed?.code) { throw new Error(`Babel transform failed for ${normPath}`); } const module = { exports: {} as Record }; const localRequire = (specifier: string): unknown => { const spec = String(specifier || "").trim(); if (!spec) return {}; if (spec.startsWith(".")) { const resolved = resolveRelativeModule(normPath, spec, files); if (!resolved) { return makeMissingModuleStub(spec); } return loadModule(resolved); } if (spec === "react") { return React; } if (spec === "react/jsx-runtime" || spec === "react/jsx-dev-runtime") { return makeMissingModuleStub(spec); } if (spec === "remotion") { return remotionRuntime; } if (spec === "recharts") { return Recharts; } if (spec === "@remotion/transitions") { return RemotionTransitions; } // Tolerate side-effect-only externals (fonts/styles/aliases) in browser preview runtime. return {}; }; // React is injected as a free variable so files that use JSX without // an explicit `import React from "react"` (the modern style supported by // the Vite/SWC build) still compile under Babel's classic JSX transform. // eslint-disable-next-line no-new-func const factory = new Function("exports", "require", "module", "React", transformed.code); factory(module.exports, localRequire, module, React); compiling.delete(normPath); moduleCache.set(normPath, module.exports); return module.exports; }; const exportsObj = loadModule(entry); const component = pickComponentExport(exportsObj); if (!component) { return { success: false, error: "No React component export found in frontend entry module." }; } return { success: true, component, exports: exportsObj }; } catch (err) { const message = err instanceof Error ? err.message : String(err); return { success: false, error: message }; } }