blog2video-test / frontend /src /utils /compileComponent.ts
Muhammad-Mehdi's picture
custom templates wired
fc41158
Raw
History Blame Contribute Delete
24.8 kB
/**
* 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<SceneProps> }
| { success: false; error: string };
export type MultiFileCompileResult =
| { success: true; component: React.ComponentType<any>; exports: Record<string, unknown> }
| { success: false; error: string };
// Lazy-loaded Babel reference
let babelPromise: Promise<typeof import("@babel/standalone")> | 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<CompileResult> {
// 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<string, unknown>)[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<SceneProps> };
} 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<Record<string, unknown> | 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<string, unknown> };
const noopRequire = (): Record<string, unknown> => ({});
// 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, string>
): 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<string, unknown>): React.ComponentType<any> | null {
const defaultExport = exportsObj.default;
if (typeof defaultExport === "function") {
return defaultExport as React.ComponentType<any>;
}
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<any>;
}
const firstFn = keys.find((k) => typeof exportsObj[k] === "function");
return firstFn ? (exportsObj[firstFn] as React.ComponentType<any>) : null;
}
export async function compileModuleGraphEntry(
filesMap: Record<string, string>,
entryRelPath: string,
publicAssetUrls?: Record<string, string> | null,
): Promise<MultiFileCompileResult> {
try {
const Babel = await loadBabel();
const files = new Map<string, string>();
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<string, Record<string, unknown>>();
const compiling = new Set<string>();
const makeMissingModuleStub = (specifier: string): Record<string, unknown> => {
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<string, unknown> | null, key?: unknown) => {
const { children, ...rest } = (props || {}) as { children?: unknown };
if (key !== undefined) (rest as Record<string, unknown>).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, unknown>)[String(prop)];
return noOpFn;
},
}
) as Record<string, unknown>;
};
const loadModule = (modulePath: string): Record<string, unknown> => {
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<string, unknown> };
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 };
}
}