blog2video-cpu / scripts /sync-generated-kit.mjs
Muhammad-Mehdi's picture
custom template modified
dd54b8c
Raw History Blame Contribute Delete
7.75 kB
#!/usr/bin/env node
/**
* Sync the generated-template craft kit from its canonical source to every
* consuming tree, and emit the export manifest both injection points read.
*
* WHY THIS EXISTS
* ---------------
* The kit source used to live in three byte-identical trees kept in sync BY
* HAND, and the list of kit exports was maintained by hand in TWO more places
* (the backend's _wrap_generated_code import block and the frontend's
* KIT_EXPORTS array). That seam had already drifted in production:
*
* - `CustomTable` was exported from the kit and present in the frontend's
* KIT_EXPORTS, but MISSING from the backend wrapper's import list β€” so a
* scene using it compiled in the browser preview and failed at render.
* - `types.ts` had diverged: the remotion-video copy carried hasVideo,
* caption fields and stock-footage video fields that neither frontend had.
*
* Adding kit primitives makes that worse, so the export list is now GENERATED
* from the canonical `kit/index.ts` and the trees are copied mechanically.
*
* USAGE
* node scripts/sync-generated-kit.mjs # write
* node scripts/sync-generated-kit.mjs --check # verify only (CI); non-zero on drift
*/
import { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join, dirname, relative } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
/** Canonical source: the render-truth tree. Its types.ts is the superset. */
const SOURCE_KIT = join(ROOT, "remotion-video/src/templates/generated/kit");
const SOURCE_TYPES = join(ROOT, "remotion-video/src/templates/generated/types.ts");
/** Every tree that consumes a copy of the kit. */
const DEST_ROOTS = [
join(ROOT, "frontend/src/components/remotion/generated"),
join(ROOT, "frontend-pdf2video/src/components/remotion/generated"),
];
const MANIFEST_BASENAME = "exportManifest.generated.ts";
/**
* Kit exports that are deliberately NOT injected into generated scene code.
*
* The manifest is the *injection* set, not simply "everything the kit exports".
* Two categories are excluded:
*
* - Composition-level scenes (DataChartScene / DataTableScene) β€” GeneratedVideo
* renders these itself for dedicated data-viz scenes. A scene component must
* never nest one inside itself.
* - Provider/plumbing (KitProvider) β€” SceneFrame wires the context; a scene
* mounting its own provider would detach the brand palette.
*
* Low-level colour/easing helpers (hexToRgb, easeOutQuint, typeScale, ...) are
* intentionally left OUT of this list: they are pure functions, harmless to
* expose, and previously unavailable only because two hand-maintained lists had
* drifted. Injecting them is a deliberate widening, not an accident.
*/
const NOT_INJECTED = new Set(["DataChartScene", "DataTableScene", "KitProvider"]);
const checkOnly = process.argv.includes("--check");
const problems = [];
let wrote = 0;
/**
* Parse the runtime (value) export names out of the canonical kit/index.ts.
*
* Handles the two forms the file uses:
* export { a, b as c, type D } from "./x";
* export * from "./y"; <- reported, cannot be resolved statically
*
* `type X` entries are dropped: they exist only at compile time and cannot be
* injected as runtime globals into generated scene code.
*/
function parseKitExports(indexSource) {
const names = new Set();
for (const m of indexSource.matchAll(/export\s*\{([^}]*)\}\s*from\s*['"][^'"]+['"]/g)) {
for (const raw of m[1].split(",")) {
let name = raw.trim();
if (!name) continue;
// `export { type Foo }` and `export { Foo as Bar }`
if (/^type\s/.test(name)) continue;
if (/\sas\s/.test(name)) name = name.split(/\sas\s/)[1].trim();
if (/^[A-Za-z_$][\w$]*$/.test(name) && !NOT_INJECTED.has(name)) names.add(name);
}
}
// A star re-export would silently hide exports from the manifest.
if (/export\s+\*\s+from/.test(indexSource)) {
problems.push(
"kit/index.ts uses `export * from ...`, which this script cannot resolve statically. " +
"Replace it with an explicit `export { ... } from ...` so the manifest stays complete.",
);
}
return [...names].sort();
}
/** Write a file, or in --check mode record a mismatch instead. */
function emit(destPath, content, label) {
const exists = existsSync(destPath);
const current = exists ? readFileSync(destPath, "utf8") : null;
if (current === content) return;
if (checkOnly) {
problems.push(exists ? `out of date: ${label}` : `missing: ${label}`);
return;
}
mkdirSync(dirname(destPath), { recursive: true });
writeFileSync(destPath, content, "utf8");
wrote += 1;
console.log(` wrote ${label}`);
}
// ─── 1. Build the export manifest ────────────────────────────────────────────
const indexPath = join(SOURCE_KIT, "index.ts");
if (!existsSync(indexPath)) {
console.error(`FATAL: canonical kit index not found at ${indexPath}`);
process.exit(2);
}
const exportNames = parseKitExports(readFileSync(indexPath, "utf8"));
if (exportNames.length === 0) {
console.error("FATAL: parsed zero kit exports β€” refusing to emit an empty manifest.");
process.exit(2);
}
const manifest =
`// @generated by scripts/sync-generated-kit.mjs β€” DO NOT EDIT BY HAND.\n` +
`// Run \`node scripts/sync-generated-kit.mjs\` after changing kit/index.ts.\n` +
`//\n` +
`// The single source of truth for which kit values are injected into\n` +
`// AI-generated scene code. Read by the frontend preview compiler and by the\n` +
`// backend render wrapper, so preview and render can never disagree.\n` +
`export const KIT_EXPORT_NAMES = [\n` +
exportNames.map((n) => ` "${n}",`).join("\n") +
`\n] as const;\n`;
// ─── 2. Fan out ──────────────────────────────────────────────────────────────
console.log(checkOnly ? "Checking generated-kit sync..." : "Syncing generated kit...");
emit(join(SOURCE_KIT, MANIFEST_BASENAME), manifest, `kit/${MANIFEST_BASENAME} (canonical)`);
const kitFiles = readdirSync(SOURCE_KIT).filter((f) => /\.(ts|tsx)$/.test(f));
for (const destRoot of DEST_ROOTS) {
if (!existsSync(destRoot)) {
console.log(` skip (tree absent): ${relative(ROOT, destRoot)}`);
continue;
}
for (const file of kitFiles) {
emit(
join(destRoot, "kit", file),
readFileSync(join(SOURCE_KIT, file), "utf8"),
`${relative(ROOT, join(destRoot, "kit", file))}`,
);
}
// types.ts lives beside kit/, not inside it. Copying the canonical superset
// closes the existing divergence; every extra field is optional, so the
// frontends keep compiling.
if (existsSync(SOURCE_TYPES)) {
emit(
join(destRoot, "types.ts"),
readFileSync(SOURCE_TYPES, "utf8"),
`${relative(ROOT, join(destRoot, "types.ts"))}`,
);
}
}
// ─── 3. Report ───────────────────────────────────────────────────────────────
if (problems.length > 0) {
console.error(`\n${problems.length} problem(s):`);
for (const p of problems) console.error(` - ${p}`);
if (checkOnly) console.error("\nRun: node scripts/sync-generated-kit.mjs");
process.exit(1);
}
console.log(
checkOnly
? `OK β€” all trees in sync (${exportNames.length} kit exports).`
: `Done β€” ${wrote} file(s) written, ${exportNames.length} kit exports.`,
);