reelkit-cli 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +2 -2
  2. package/package.json +5 -1
  3. package/skill/SKILL.md +20 -9
  4. package/skill/THIRD_PARTY.md +102 -0
  5. package/skill/commands/launch-film.md +7 -0
  6. package/skill/reference/asset-reuse.md +13 -2
  7. package/skill/reference/backgrounds.md +63 -0
  8. package/skill/reference/beat-sync.md +25 -19
  9. package/skill/reference/captions.md +11 -5
  10. package/skill/reference/continuity.md +21 -2
  11. package/skill/reference/kit.md +139 -11
  12. package/skill/reference/launch-film.md +190 -0
  13. package/skill/reference/remotion-composition.md +4 -3
  14. package/skill/reference/scene-treatments.md +20 -0
  15. package/skill/reference/scriptwriting.md +4 -1
  16. package/skill/reference/three-d.md +134 -0
  17. package/skill/reference/voice-sync.md +108 -0
  18. package/src/agents.ts +23 -12
  19. package/src/api/client.ts +4 -1
  20. package/src/cli.ts +18 -7
  21. package/src/commands/assets.ts +310 -30
  22. package/src/commands/build.ts +148 -35
  23. package/src/commands/init.ts +1 -1
  24. package/src/commands/install.ts +1 -1
  25. package/src/commands/plan.ts +8 -5
  26. package/src/commands/ref.ts +5 -2
  27. package/src/contract/index.ts +4 -2
  28. package/src/pipeline/beatsnap.ts +72 -0
  29. package/src/pipeline/review.ts +44 -10
  30. package/src/pipeline/schema.ts +51 -4
  31. package/src/pipeline/timing.ts +27 -1
  32. package/src/project/background.ts +33 -0
  33. package/src/project/layers.ts +60 -0
  34. package/src/project/manifest.ts +59 -14
  35. package/src/project/music.ts +19 -5
  36. package/src/project/project.ts +4 -1
  37. package/src/project/soundreport.ts +347 -0
  38. package/src/project/svgcheck.ts +21 -0
  39. package/src/remotion/kit/Assemble3D.tsx +92 -0
  40. package/src/remotion/kit/BrowserFrame.tsx +83 -0
  41. package/src/remotion/kit/Camera.tsx +6 -4
  42. package/src/remotion/kit/Captions.tsx +33 -17
  43. package/src/remotion/kit/Card3D.tsx +211 -0
  44. package/src/remotion/kit/ChapterFrame.tsx +68 -0
  45. package/src/remotion/kit/CounterRoll.tsx +75 -0
  46. package/src/remotion/kit/GlassPanel.tsx +43 -0
  47. package/src/remotion/kit/Grounds.tsx +177 -0
  48. package/src/remotion/kit/Headline.tsx +97 -0
  49. package/src/remotion/kit/Hero3D.tsx +197 -0
  50. package/src/remotion/kit/HudOverlay.tsx +52 -0
  51. package/src/remotion/kit/ImageLayers.tsx +48 -0
  52. package/src/remotion/kit/Music.tsx +4 -4
  53. package/src/remotion/kit/NamedCursor.tsx +54 -0
  54. package/src/remotion/kit/Orbit3D.tsx +49 -0
  55. package/src/remotion/kit/Particles3D.tsx +74 -0
  56. package/src/remotion/kit/Place.tsx +12 -0
  57. package/src/remotion/kit/PromptBox.tsx +84 -0
  58. package/src/remotion/kit/Scene3D.tsx +70 -0
  59. package/src/remotion/kit/SceneFrame.tsx +88 -11
  60. package/src/remotion/kit/SoundCues.tsx +22 -0
  61. package/src/remotion/kit/TerminalLog.tsx +98 -0
  62. package/src/remotion/kit/Text3D.tsx +78 -0
  63. package/src/remotion/kit/TextOnImage.tsx +41 -0
  64. package/src/remotion/kit/Warp3D.tsx +59 -0
  65. package/src/remotion/kit/bg-math.ts +179 -0
  66. package/src/remotion/kit/caption-groups.ts +7 -3
  67. package/src/remotion/kit/caption-style.ts +45 -0
  68. package/src/remotion/kit/docs.ts +132 -11
  69. package/src/remotion/kit/image-layers-math.ts +115 -0
  70. package/src/remotion/kit/index.ts +43 -1
  71. package/src/remotion/kit/inter-bold-typeface.ts +3 -0
  72. package/src/remotion/kit/motion-math.ts +36 -2
  73. package/src/remotion/kit/music-math.ts +27 -10
  74. package/src/remotion/kit/quiet-three.ts +11 -0
  75. package/src/remotion/kit/sample-text.ts +55 -0
  76. package/src/remotion/kit/scene3d-context.ts +5 -0
  77. package/src/remotion/kit/seeded.ts +13 -0
  78. package/src/remotion/kit/sound-cues.ts +89 -0
  79. package/src/remotion/kit/sound-kinds.ts +122 -0
  80. package/src/remotion/kit/theme.ts +2 -0
  81. package/src/remotion/kit/three-fx-math.ts +192 -0
  82. package/src/remotion/kit/three-math.ts +145 -0
  83. package/src/remotion/kit/transition-math.ts +116 -0
  84. package/src/remotion/kit/ui-math.ts +145 -0
  85. package/src/remotion/kit/ui-theme.ts +25 -0
  86. package/src/remotion/kit/word-anchor.ts +107 -0
  87. package/src/render/contact-sheet.ts +39 -0
  88. package/src/render/continuity.ts +14 -4
  89. package/src/render/deps.ts +15 -3
  90. package/src/render/render.ts +15 -8
  91. package/src/render/sound-notes.ts +106 -0
  92. package/src/render/word-check.ts +181 -0
  93. package/src/testing/conformance.ts +12 -0
  94. package/src/testing/fake-api.ts +4 -4
  95. package/src/testing/fixtures.ts +3 -0
@@ -0,0 +1,107 @@
1
+ // Word anchors: where in the film a spoken word falls, so that what illustrates a word can be placed on it.
2
+ // Everything here is a pure function over a manifest scene (`manifest.scenes[i]`, with its `words`), so it is the same in the studio, in a test and in a render.
3
+ // Frames are counted from the start of the scene (what its own useCurrentFrame() gives) unless `absolute: true` adds the scene's startFrame.
4
+ import type { WordTiming } from "../../pipeline/schema";
5
+
6
+ // The manifest's frame rate is always this one; pass `fps` only for a scene that came from somewhere else.
7
+ export const WORD_FPS = 30;
8
+ // An entrance needs a few frames to become visible, so it starts this many frames before its word.
9
+ export const DEFAULT_LEAD = 3;
10
+ // A beat counts as "at the word" when it is no more than this many frames from the word's start.
11
+ export const DEFAULT_BEAT_WINDOW = 3;
12
+ // The thing a word names is never seen more than this many frames before the word starts.
13
+ export const MAX_EARLY_FRAMES = 3;
14
+
15
+ export type WordScene = { id?: string; startFrame: number; durationFrames?: number; words: WordTiming[] };
16
+
17
+ export type WordFrameOptions = {
18
+ // The 1-based occurrence of the word when the scene says it more than once (default 1).
19
+ nth?: number;
20
+ // "start" (default): the frame the word starts. "end": the frame it ends (for a phrase, the end of its last word).
21
+ edge?: "start" | "end";
22
+ // Add the scene's startFrame, giving a frame of the whole film.
23
+ absolute?: boolean;
24
+ fps?: number;
25
+ };
26
+
27
+ // A word as it is compared: lower case, with everything but letters and digits removed ("Meetings," is "meetings", "don't" is "dont").
28
+ export const normalizeWord = (w: string): string => w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
29
+
30
+ const spoken = (scene: WordScene): { norm: string; word: WordTiming }[] =>
31
+ scene.words.map((word) => ({ norm: normalizeWord(word.word), word })).filter((w) => w.norm !== "");
32
+
33
+ // The words that make up `phrase`, matched against the scene's words, with where the phrase's `nth` occurrence is.
34
+ function findPhrase(scene: WordScene, phrase: string, nth: number): { first: WordTiming; last: WordTiming } {
35
+ const parts = phrase.split(/\s+/).map(normalizeWord).filter(Boolean);
36
+ const list = spoken(scene);
37
+ const where = scene.id ? `scene "${scene.id}"` : "the scene";
38
+ if (!parts.length) throw new Error(`wordFrame: the word is empty. ${where} says: ${list.map((w) => w.word.word).join(" ")}`);
39
+ if (!Number.isInteger(nth) || nth < 1) throw new Error(`wordFrame: nth must be 1 or more, got ${nth}.`);
40
+ let seen = 0;
41
+ for (let i = 0; i + parts.length <= list.length; i++) {
42
+ if (parts.every((p, k) => list[i + k]!.norm === p) && ++seen === nth) return { first: list[i]!.word, last: list[i + parts.length - 1]!.word };
43
+ }
44
+ const had = seen ? ` (it is said ${seen} time${seen === 1 ? "" : "s"}, not ${nth})` : "";
45
+ throw new Error(`wordFrame: "${phrase}"${nth > 1 ? ` (occurrence ${nth})` : ""} is not in ${where}${had}. Its words are: ${list.map((w) => w.word.word).join(" ")}`);
46
+ }
47
+
48
+ // The frame, inside the scene, at which `word` starts (or ends, with edge: "end"). `word` ignores case and punctuation; a phrase of several words
49
+ // ("whole week") matches consecutive words and gives the first one's start. A word the scene does not say throws an Error that lists the scene's
50
+ // words, so a misspelt word stops the render instead of silently timing to frame 0.
51
+ export function wordFrame(scene: WordScene, word: string, opts: WordFrameOptions = {}): number {
52
+ const { first, last } = findPhrase(scene, word, opts.nth ?? 1);
53
+ const fps = opts.fps ?? WORD_FPS;
54
+ const sec = opts.edge === "end" ? last.endSec : first.startSec;
55
+ let frame = Math.max(0, Math.round(sec * fps));
56
+ if (scene.durationFrames !== undefined) frame = Math.min(frame, Math.max(0, scene.durationFrames - 1));
57
+ return frame + (opts.absolute ? scene.startFrame : 0);
58
+ }
59
+
60
+ // Several words at once, in the order given.
61
+ export const wordFrames = (scene: WordScene, words: string[], opts: WordFrameOptions = {}): number[] => words.map((w) => wordFrame(scene, w, opts));
62
+
63
+ // The scene with this id. Throws an Error that lists the ids when there is none.
64
+ export function sceneById<S extends { id: string }>(manifest: { scenes: S[] }, id: string): S {
65
+ const found = manifest.scenes.find((s) => s.id === id);
66
+ if (!found) throw new Error(`sceneById: there is no scene "${id}". The scenes are: ${manifest.scenes.map((s) => s.id).join(", ")}`);
67
+ return found;
68
+ }
69
+
70
+ export type OnWordOptions = WordFrameOptions & {
71
+ // How many frames before the word the entrance starts (default 3).
72
+ lead?: number;
73
+ };
74
+
75
+ // The frame at which to START an entrance (a `delay`, a `from`, an `at`) so that it is seen landing on its word: the word's frame minus `lead`
76
+ // (default 3), never below 0.
77
+ export function onWord(scene: WordScene, word: string, opts: OnWordOptions = {}): number {
78
+ const lead = opts.lead ?? DEFAULT_LEAD;
79
+ const at = wordFrame(scene, word, { ...opts, absolute: false });
80
+ return Math.max(0, at - lead) + (opts.absolute ? scene.startFrame : 0);
81
+ }
82
+
83
+ export type OnWordBeatOptions = OnWordOptions & {
84
+ // A beat counts only when it is at most this many frames from the word's start (default 3).
85
+ window?: number;
86
+ };
87
+
88
+ // Beat and word together. The voice is the clock for what illustrates a word, so the word wins; the beat is used only when one lies within `window`
89
+ // frames of the word (default 3), and then only to let the entrance land on it. The result is never earlier than onWord's, so what a word names
90
+ // is never seen more than 3 frames before the word starts. `music` is manifest.music (its beatFrames are frames of the whole film); without a
91
+ // track it is onWord.
92
+ export function onWordBeat(scene: WordScene, word: string, music: { beatFrames: number[] } | undefined, opts: OnWordBeatOptions = {}): number {
93
+ const lead = opts.lead ?? DEFAULT_LEAD;
94
+ const window = opts.window ?? DEFAULT_BEAT_WINDOW;
95
+ const at = wordFrame(scene, word, { ...opts, absolute: false });
96
+ const wordAbs = scene.startFrame + at;
97
+ const base = Math.max(0, at - lead);
98
+ let start = base;
99
+ let best: number | undefined;
100
+ for (const b of music?.beatFrames ?? []) {
101
+ // A beat before the word is never used: it would show the thing early.
102
+ if (b < wordAbs || b - wordAbs > window) continue;
103
+ if (best === undefined || b - wordAbs < best - wordAbs) best = b;
104
+ }
105
+ if (best !== undefined) start = Math.max(base, best - scene.startFrame - lead);
106
+ return start + (opts.absolute ? scene.startFrame : 0);
107
+ }
@@ -0,0 +1,39 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const exec = promisify(execFile);
5
+
6
+ // The grid of a sheet of `count` tiles for a frame of `aspect` (width over height): 6 across for a tall frame, 4 for a wide one, so that the whole sheet
7
+ // is at most about `maxWidth` pixels across and a phone-shaped frame is still readable.
8
+ export function sheetGrid(count: number, aspect: number, maxWidth = 2400): { cols: number; rows: number; tileW: number; tileH: number } {
9
+ const cols = Math.max(1, Math.min(count, aspect < 1 ? 6 : aspect > 1.2 ? 4 : 5));
10
+ const tileW = Math.min(540, Math.floor(maxWidth / cols / 2) * 2);
11
+ const tileH = Math.max(2, Math.round(tileW / aspect / 2) * 2);
12
+ return { cols, rows: Math.ceil(count / cols), tileW, tileH };
13
+ }
14
+
15
+ // The drawn text is plain ASCII (the default font has no arrows): the arrow of a word label is drawn as >.
16
+ const clean = (s: string) => s.replace(/\u25B8/g, ">").replace(/[^A-Za-z0-9 _.>#-]/g, "");
17
+
18
+ // One picture of many: the files in order, each with its label in a box on its top left corner. Falls back to the same sheet without labels if this ffmpeg
19
+ // has no text drawing; the caller still lists the order. `newRowAt` starts a new row at that tile (the tiles before it are padded with empty ones), so a
20
+ // second kind of frame sits in rows of its own. Returns the grid, and whether the labels were drawn.
21
+ export async function contactSheet(files: string[], labels: string[], out: string, aspect: number, opts: { newRowAt?: number } = {}): Promise<{ cols: number; rows: number; width: number; height: number; labelled: boolean }> {
22
+ const base = sheetGrid(files.length, aspect);
23
+ const at = opts.newRowAt !== undefined && opts.newRowAt > 0 && opts.newRowAt < files.length ? opts.newRowAt : undefined;
24
+ const blanks = at === undefined ? 0 : (base.cols - (at % base.cols)) % base.cols;
25
+ const g = { ...base, rows: Math.ceil((files.length + blanks) / base.cols) };
26
+ const build = (labelled: boolean) => {
27
+ const tile = (i: number) => `[${i}:v]scale=${g.tileW}:${g.tileH},setsar=1${labelled ? `,drawtext=text='${clean(labels[i] ?? "")}':x=6:y=6:fontsize=${Math.max(12, Math.round(g.tileW / 22))}:fontcolor=white:box=1:boxcolor=black@0.65:boxborderw=4` : ""}[t${i}]`;
28
+ const parts = files.map((_, i) => tile(i));
29
+ // The empty tiles are extra inputs of plain colour, one frame each.
30
+ const blankInputs = Array.from({ length: blanks }, () => ["-f", "lavfi", "-i", `color=c=0x202020:s=${g.tileW}x${g.tileH}:r=25`]).flat();
31
+ const blankParts = Array.from({ length: blanks }, (_, k) => `[${files.length + k}:v]trim=end_frame=1,setpts=PTS-STARTPTS,format=yuvj420p,setsar=1[b${k}]`);
32
+ const order = [...files.map((_, i) => (i === at ? [...Array.from({ length: blanks }, (_, k) => `[b${k}]`), `[t${i}]`] : [`[t${i}]`])).flat()];
33
+ const filter = `${[...parts, ...blankParts].join(";")};${order.join("")}concat=n=${files.length + blanks}:v=1:a=0,tile=${g.cols}x${g.rows}:padding=2:color=0x202020[out]`;
34
+ return ["-y", "-loglevel", "error", ...files.flatMap((f) => ["-i", f]), ...blankInputs, "-filter_complex", filter, "-map", "[out]", "-frames:v", "1", "-q:v", "4", out];
35
+ };
36
+ let labelled = true;
37
+ try { await exec("ffmpeg", build(true)); } catch { labelled = false; await exec("ffmpeg", build(false)); }
38
+ return { cols: g.cols, rows: g.rows, width: g.cols * g.tileW + (g.cols - 1) * 2, height: g.rows * g.tileH + (g.rows - 1) * 2, labelled };
39
+ }
@@ -10,7 +10,8 @@ export const CARRIED_AT = 0.25;
10
10
  const MIN_EDGE_SHARE = 0.002;
11
11
 
12
12
  export type Gray = ArrayLike<number>;
13
- export type BoundaryKind = "carried" | "cut" | "empty";
13
+ // "cut (intended)" is a change the plan marked with cutIn: it is reported but never counts against the score.
14
+ export type BoundaryKind = "carried" | "cut" | "empty" | "cut (intended)";
14
15
 
15
16
  // A pixel is an edge where the brightness steps by more than the threshold to the pixel on its right or below it.
16
17
  export function edgeMap(gray: Gray, w: number, h: number, threshold = EDGE_THRESHOLD): Uint8Array {
@@ -57,6 +58,13 @@ export function dilate(map: Uint8Array, w: number, h: number, radius: number): U
57
58
  return out;
58
59
  }
59
60
 
61
+ // The later scene is compared at two moments, its first fully visible frame and one a few frames on while a move is still settling (a plate
62
+ // that becomes a card has not yet reached its edges at the first). The better of the two says whether something was carried.
63
+ export function compareAcross(earlier: Gray, laters: Gray[], w: number, h: number): ReturnType<typeof compareFrames> {
64
+ const all = laters.map((l) => compareFrames(earlier, l, w, h));
65
+ return all.reduce((best, r) => (r.survived > best.survived ? r : best));
66
+ }
67
+
60
68
  export function compareFrames(earlier: Gray, later: Gray, w: number, h: number): { survived: number; edges: number; kind: BoundaryKind } {
61
69
  const band = captionBand(w, h);
62
70
  const before = clearBottom(edgeMap(earlier, w, h), w, h, band);
@@ -72,15 +80,17 @@ export type Boundary = { from: string; to: string; survived: number; kind: Bound
72
80
 
73
81
  // The score is carried over carried plus cut: a change with nothing on screen to compare says nothing either way.
74
82
  export function summariseContinuity(boundaries: Boundary[]): { score: number | null; lines: string[] } {
83
+ const intended = boundaries.filter((b) => b.kind === "cut (intended)").length;
75
84
  const carried = boundaries.filter((b) => b.kind === "carried").length;
76
85
  const cut = boundaries.filter((b) => b.kind === "cut");
77
86
  const counted = carried + cut.length;
78
- const empty = boundaries.length - counted;
79
- if (!counted) return { score: null, lines: ["No scene change could be measured: the picture is nearly empty at the end of every scene."] };
87
+ const empty = boundaries.length - counted - intended;
88
+ const hard = intended ? ` ${intended} ${intended === 1 ? "is" : "are"} intended hard cut${intended === 1 ? "" : "s"}, not counted.` : "";
89
+ if (!counted) return { score: null, lines: [intended && !empty ? `Every scene change that could be measured is an intended hard cut.` : "No scene change could be measured: the picture is nearly empty at the end of every scene."] };
80
90
  return {
81
91
  score: carried / counted,
82
92
  lines: [
83
- `${carried} of ${counted} scene changes carry something across${empty ? ` (${empty} more had nothing on screen to compare)` : ""}.`,
93
+ `${carried} of ${counted} scene changes carry something across${empty ? ` (${empty} more had nothing on screen to compare)` : ""}.${hard}`,
84
94
  ...cut.map((b) => `${b.from} → ${b.to}: nothing carries over. Keep one element on screen through the change and move it into the next scene (see reference/continuity.md).`),
85
95
  ],
86
96
  };
@@ -1,8 +1,20 @@
1
+ import { existsSync, readFileSync } from "node:fs";
1
2
  import { createRequire } from "node:module";
2
- import { dirname } from "node:path";
3
+ import { dirname, join } from "node:path";
3
4
 
4
5
  const require = createRequire(import.meta.url);
5
6
 
6
7
  // The directory of an installed dependency of this package, found through its package.json so it does not depend on
7
- // where the package keeps its main file or on how the package manager lays out node_modules.
8
- export const packageDir = (name: string): string => dirname(require.resolve(`${name}/package.json`));
8
+ // where the package keeps its main file or on how the package manager lays out node_modules. A package that does not export its
9
+ // package.json (three does not) is found from its main file instead, by walking up to the folder that holds its own package.json.
10
+ export const packageDir = (name: string): string => {
11
+ try {
12
+ return dirname(require.resolve(`${name}/package.json`));
13
+ } catch {
14
+ for (let dir = dirname(require.resolve(name)); dir !== dirname(dir); dir = dirname(dir)) {
15
+ const file = join(dir, "package.json");
16
+ if (existsSync(file) && (JSON.parse(readFileSync(file, "utf8")) as { name?: string }).name === name) return dir;
17
+ }
18
+ throw new Error(`Could not find the installed package ${name}.`);
19
+ }
20
+ };
@@ -14,9 +14,10 @@ registerRoot(makeRoot(Video));
14
14
  `;
15
15
 
16
16
  // The project folder has no node_modules. Each dependency is pinned to the one copy installed with this package, so the
17
- // kit, Root and the user's composition all share a single react and remotion (a second react breaks hooks).
17
+ // kit, Root and the user's composition all share a single react and remotion (a second react breaks hooks), and the 3D layer a single three
18
+ // (two copies of three break instanceof checks). User code still may not import them: only the kit does.
18
19
  export function dependencyAliases(): Record<string, string> {
19
- return Object.fromEntries(["react", "react-dom", "remotion"].map((n) => [n, packageDir(n)]));
20
+ return Object.fromEntries(["react", "react-dom", "remotion", "three", "@react-three/fiber", "@remotion/three"].map((n) => [n, packageDir(n)]));
20
21
  }
21
22
 
22
23
  // Writes the bundler's entry file into the project. The composition itself stays where the user's Claude wrote it.
@@ -57,18 +58,24 @@ export const ensureRenderBrowser: EnsureBrowser = async (onDownload) => {
57
58
  await ensureBrowser({ onBrowserDownload: () => { onDownload(); return { version: null, onProgress: () => {} }; } });
58
59
  };
59
60
 
61
+ // Remotion's headless browser has no GPU, so three.js needs a software GL. "angle" is the setting that draws WebGL here; it is used only for a film that
62
+ // has a 3D scene, so every 2D render keeps the browser's own defaults and looks exactly as before. This is the one place that says it.
63
+ export const GL_BACKEND = "angle" as const;
64
+ export type RenderOptions = { uses3D?: boolean };
65
+ const chromium = (o?: RenderOptions) => (o?.uses3D ? { chromiumOptions: { gl: GL_BACKEND } } : {});
66
+
60
67
  const asInput = (props: VideoProps) => props as unknown as Record<string, unknown>;
61
68
 
62
- export async function renderStills(serveUrl: string, props: VideoProps, frames: number[], outDir: string) {
69
+ export async function renderStills(serveUrl: string, props: VideoProps, frames: number[], outDir: string, options?: RenderOptions) {
63
70
  await mkdir(outDir, { recursive: true });
64
- const composition = await selectComposition({ serveUrl, id: "Video", inputProps: asInput(props) });
71
+ const composition = await selectComposition({ serveUrl, id: "Video", inputProps: asInput(props), ...chromium(options) });
65
72
  for (const frame of frames) {
66
- await renderStill({ composition, serveUrl, frame, output: join(outDir, `still-${frame}.png`), inputProps: asInput(props) });
73
+ await renderStill({ composition, serveUrl, frame, output: join(outDir, `still-${frame}.png`), inputProps: asInput(props), ...chromium(options) });
67
74
  }
68
75
  }
69
76
 
70
- export async function renderVideo(serveUrl: string, props: VideoProps, outPath: string) {
77
+ export async function renderVideo(serveUrl: string, props: VideoProps, outPath: string, options?: RenderOptions) {
71
78
  await mkdir(dirname(outPath), { recursive: true });
72
- const composition = await selectComposition({ serveUrl, id: "Video", inputProps: asInput(props) });
73
- await renderMedia({ composition, serveUrl, codec: "h264", outputLocation: outPath, inputProps: asInput(props) });
79
+ const composition = await selectComposition({ serveUrl, id: "Video", inputProps: asInput(props), ...chromium(options) });
80
+ await renderMedia({ composition, serveUrl, codec: "h264", outputLocation: outPath, inputProps: asInput(props), ...chromium(options) });
74
81
  }
@@ -0,0 +1,106 @@
1
+ import ts from "typescript";
2
+ import { sourceFile } from "./static-check";
3
+
4
+ // Advice about the sound of a film with no voice, read from the composition's source. It is a count of what the code places, never a
5
+ // listening test: a sound that is computed, looped over or hidden behind a variable cannot be counted, so then nothing is said.
6
+ export type SoundCount = {
7
+ // Sfx elements and literal SoundCues entries.
8
+ placed: number;
9
+ // True when some cue is computed (a map, a helper, a variable that is not a plain list), so that the count is only a lower bound.
10
+ uncertain: boolean;
11
+ music: boolean;
12
+ // True when a cue or an effect is placed before this frame; `unknownStart` when one has a start that is not a plain number.
13
+ early: boolean;
14
+ unknownStart: boolean;
15
+ };
16
+
17
+ const numberOf = (e: ts.Expression | undefined): number | undefined => {
18
+ if (!e) return undefined;
19
+ if (ts.isNumericLiteral(e)) return Number(e.text);
20
+ if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(e.operand)) return -Number(e.operand.text);
21
+ if (ts.isParenthesizedExpression(e)) return numberOf(e.expression);
22
+ return undefined;
23
+ };
24
+
25
+ const insideLoop = (node: ts.Node): boolean => {
26
+ for (let p = node.parent; p; p = p.parent) {
27
+ if (ts.isCallExpression(p) && ts.isPropertyAccessExpression(p.expression) && ["map", "forEach", "flatMap"].includes(p.expression.name.text)) return true;
28
+ }
29
+ return false;
30
+ };
31
+
32
+ // The value of a prop, looking through a name that the file declares as a constant.
33
+ function propValue(attr: ts.JsxAttribute, sf: ts.SourceFile): ts.Expression | undefined {
34
+ const init = attr.initializer;
35
+ if (!init || !ts.isJsxExpression(init) || !init.expression) return undefined;
36
+ const e = init.expression;
37
+ if (!ts.isIdentifier(e)) return e;
38
+ let found: ts.Expression | undefined;
39
+ const visit = (n: ts.Node) => {
40
+ if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.name.text === e.text && n.initializer && !found) found = n.initializer;
41
+ ts.forEachChild(n, visit);
42
+ };
43
+ visit(sf);
44
+ return found ?? e;
45
+ }
46
+
47
+ export function countSounds(source: string, file: string, earlyFrames: number): SoundCount {
48
+ const sf = sourceFile(source, file);
49
+ const out: SoundCount = { placed: 0, uncertain: false, music: false, early: false, unknownStart: false };
50
+ const attr = (el: ts.JsxOpeningLikeElement, name: string) => el.attributes.properties.find((a): a is ts.JsxAttribute => ts.isJsxAttribute(a) && a.name.getText(sf) === name);
51
+ const atOf = (e: ts.Expression | undefined, absent: number | undefined) => {
52
+ const n = e === undefined ? absent : numberOf(e);
53
+ if (n === undefined) out.unknownStart = true;
54
+ else if (n < earlyFrames) out.early = true;
55
+ };
56
+ const visit = (node: ts.Node) => {
57
+ const el = ts.isJsxSelfClosingElement(node) ? node : ts.isJsxElement(node) ? node.openingElement : undefined;
58
+ if (el) {
59
+ const tag = el.tagName.getText(sf);
60
+ if (tag === "Music") out.music = true;
61
+ if (tag === "Sfx") {
62
+ out.placed += 1;
63
+ if (insideLoop(el)) out.uncertain = true;
64
+ const at = attr(el, "at");
65
+ if (!at) out.early = true;
66
+ else {
67
+ const v = at.initializer && ts.isJsxExpression(at.initializer) ? at.initializer.expression : undefined;
68
+ atOf(v, undefined);
69
+ }
70
+ }
71
+ if (tag === "SoundCues") {
72
+ const cues = attr(el, "cues");
73
+ const value = cues && propValue(cues, sf);
74
+ if (!value || !ts.isArrayLiteralExpression(value) || value.elements.some((c) => !ts.isObjectLiteralExpression(c)) || insideLoop(el)) out.uncertain = true;
75
+ else {
76
+ out.placed += value.elements.length;
77
+ for (const c of value.elements as ts.NodeArray<ts.ObjectLiteralExpression>) {
78
+ const at = c.properties.find((p): p is ts.PropertyAssignment => ts.isPropertyAssignment(p) && p.name.getText(sf) === "at");
79
+ atOf(at?.initializer, undefined);
80
+ }
81
+ }
82
+ }
83
+ }
84
+ ts.forEachChild(node, visit);
85
+ };
86
+ visit(sf);
87
+ return out;
88
+ }
89
+
90
+ // The notes for a film with no voice. `seconds` is the film's length, `musicSet` whether a track was pulled with --music.
91
+ export function soundNotes(sources: { name: string; source: string }[], opts: { seconds: number; fps: number; musicSet: boolean }): string[] {
92
+ const earlyFrames = Math.round(opts.fps / 2);
93
+ const counts = sources.map((s) => countSounds(s.source, s.name, earlyFrames));
94
+ const placed = counts.reduce((n, c) => n + c.placed, 0);
95
+ const uncertain = counts.some((c) => c.uncertain);
96
+ const notes: string[] = [];
97
+ if (!opts.musicSet) notes.push("No music track is set. A film with no voice is carried by its music: choose one and pull it with `reelkit assets pull <id> --music` (see reference/launch-film.md).");
98
+ if (!uncertain && placed < opts.seconds / 2) {
99
+ notes.push(`The code places ${placed} sound cue${placed === 1 ? "" : "s"} (Sfx elements and SoundCues entries) in ${opts.seconds.toFixed(1)}s of film, fewer than one per two seconds. This is a count of what the composition places, not a listening test; a film with no voice usually has about one placed effect per second on top of the music (see reference/launch-film.md).`);
100
+ }
101
+ const startsAtOnce = counts.some((c) => c.music || c.early);
102
+ if (!startsAtOnce && !uncertain && !counts.some((c) => c.unknownStart)) {
103
+ notes.push("Nothing is placed in the first half second: the code has no Music, and no Sfx or SoundCues cue that starts there. This is a count of what the composition places, not a listening test; start a sound on the first frame (see reference/launch-film.md).");
104
+ }
105
+ return notes;
106
+ }
@@ -0,0 +1,181 @@
1
+ // What a composition's source says statically about the spoken words it is timed to: which helper calls name a word, in which scene, and whether
2
+ // the word is in that scene's narration. Pure text in, findings out (the TypeScript syntax tree, never regular expressions on code), so `check`
3
+ // and `preview` can both use it.
4
+ import ts from "typescript";
5
+ import { normalizeWord, wordFrame, type WordScene } from "../remotion/kit/word-anchor";
6
+ import { sourceFile, unwrap } from "./static-check";
7
+
8
+ export const WORD_HELPERS = ["wordFrame", "wordFrames", "onWord", "onWordBeat"] as const;
9
+ const isHelper = (n: string): n is (typeof WORD_HELPERS)[number] => (WORD_HELPERS as readonly string[]).includes(n);
10
+
11
+ export type WordCall = {
12
+ fn: (typeof WORD_HELPERS)[number];
13
+ // The scene, when the call's first argument is a scene found by a literal id (sceneById(manifest, "id"), or manifest.scenes[2], or S.id and S["id"]).
14
+ sceneId?: string;
15
+ // One entry per literal word the call names; a word that is not a literal is left out.
16
+ words: { word: string; nth?: number }[];
17
+ line: number;
18
+ };
19
+
20
+ type PlanScene = { id: string; narration: string };
21
+
22
+ const literalText = (e: ts.Expression | undefined): string | undefined => {
23
+ const x = e && unwrap(e);
24
+ return x && ts.isStringLiteralLike(x) ? x.text : undefined;
25
+ };
26
+ const literalNumber = (e: ts.Expression | undefined): number | undefined => {
27
+ const x = e && unwrap(e);
28
+ return x && ts.isNumericLiteral(x) ? Number(x.text) : undefined;
29
+ };
30
+
31
+ // Where a source says `<something>.scenes`: the manifest's list of scenes, however the manifest is called.
32
+ const isScenesList = (e: ts.Expression): boolean => { const x = unwrap(e); return ts.isPropertyAccessExpression(x) && x.name.text === "scenes"; };
33
+
34
+ // Calls of the word helpers in `source`. `ids` are the plan's scene ids in order, so that a scene reached by position or by `S.hook` can be named.
35
+ export function wordCalls(source: string, ids: string[], file = "Video.tsx"): WordCall[] {
36
+ const sf = sourceFile(source, file);
37
+ // Every `const name = expression` and `const [a, b] = list` in the file, flat: the names a composition gives its scenes are not scoped carefully.
38
+ const consts = new Map<string, ts.Expression>();
39
+ const positions = new Map<string, number>();
40
+ const visitDecl = (node: ts.Node) => {
41
+ if (ts.isVariableDeclaration(node) && node.initializer) {
42
+ if (ts.isIdentifier(node.name)) consts.set(node.name.text, node.initializer);
43
+ else if (ts.isArrayBindingPattern(node.name) && isScenesList(node.initializer)) {
44
+ node.name.elements.forEach((el, i) => { if (ts.isBindingElement(el) && ts.isIdentifier(el.name)) positions.set(el.name.text, i); });
45
+ }
46
+ }
47
+ ts.forEachChild(node, visitDecl);
48
+ };
49
+ visitDecl(sf);
50
+
51
+ const resolve = (e: ts.Expression, depth = 0): string | undefined => {
52
+ if (depth > 6) return undefined;
53
+ const x = unwrap(e);
54
+ if (ts.isCallExpression(x) && ts.isIdentifier(x.expression) && x.expression.text === "sceneById") {
55
+ const id = literalText(x.arguments[1]);
56
+ return id !== undefined && ids.includes(id) ? id : undefined;
57
+ }
58
+ if (ts.isIdentifier(x)) {
59
+ const pos = positions.get(x.text);
60
+ if (pos !== undefined) return ids[pos];
61
+ const init = consts.get(x.text);
62
+ return init ? resolve(init, depth + 1) : undefined;
63
+ }
64
+ if (ts.isElementAccessExpression(x) && isScenesList(x.expression)) {
65
+ const i = literalNumber(x.argumentExpression);
66
+ return i !== undefined ? ids[i] : undefined;
67
+ }
68
+ // A record of scenes by id: `const S = Object.fromEntries(manifest.scenes.map(...))`, then S.hook or S["hook"].
69
+ if (ts.isPropertyAccessExpression(x) || ts.isElementAccessExpression(x)) {
70
+ const key = ts.isPropertyAccessExpression(x) ? x.name.text : literalText(x.argumentExpression);
71
+ const base = unwrap(x.expression);
72
+ if (key !== undefined && ids.includes(key) && ts.isIdentifier(base)) {
73
+ const init = consts.get(base.text);
74
+ if (init && init.getText(sf).includes("scenes")) return key;
75
+ }
76
+ }
77
+ return undefined;
78
+ };
79
+
80
+ const out: WordCall[] = [];
81
+ const visit = (node: ts.Node) => {
82
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && isHelper(node.expression.text)) {
83
+ const fn = node.expression.text;
84
+ const [scene, arg] = node.arguments;
85
+ const options = node.arguments.slice(2).map((a) => unwrap(a)).find((a): a is ts.ObjectLiteralExpression => ts.isObjectLiteralExpression(a));
86
+ const nthProp = options?.properties.find((p): p is ts.PropertyAssignment => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === "nth");
87
+ const nth = literalNumber(nthProp?.initializer);
88
+ const named: string[] = [];
89
+ if (arg) {
90
+ const single = literalText(arg);
91
+ if (fn === "wordFrames") { const list = unwrap(arg); if (ts.isArrayLiteralExpression(list)) for (const el of list.elements) { const t = literalText(el); if (t !== undefined) named.push(t); } }
92
+ else if (single !== undefined) named.push(single);
93
+ }
94
+ out.push({
95
+ fn, ...(scene ? (() => { const id = resolve(scene); return id !== undefined ? { sceneId: id } : {}; })() : {}),
96
+ words: named.map((word) => ({ word, ...(nth !== undefined ? { nth } : {}) })),
97
+ line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1,
98
+ });
99
+ }
100
+ ts.forEachChild(node, visit);
101
+ };
102
+ visit(sf);
103
+ return out;
104
+ }
105
+
106
+ // Whether this narration says `word` (a word or a phrase of consecutive words, ignoring case and punctuation) at least `nth` times.
107
+ export function narrationHas(narration: string, word: string, nth = 1): boolean {
108
+ const parts = word.split(/\s+/).map(normalizeWord).filter(Boolean);
109
+ if (!parts.length) return false;
110
+ const list = narration.split(/\s+/).map(normalizeWord).filter(Boolean);
111
+ let seen = 0;
112
+ for (let i = 0; i + parts.length <= list.length; i++) if (parts.every((p, k) => list[i + k] === p) && ++seen >= nth) return true;
113
+ return false;
114
+ }
115
+
116
+ // The errors: a literal word that the narration of a literally named scene does not contain.
117
+ export function wordErrors(file: string, calls: WordCall[], scenes: PlanScene[]): string[] {
118
+ const errors: string[] = [];
119
+ for (const c of calls) {
120
+ const scene = c.sceneId ? scenes.find((s) => s.id === c.sceneId) : undefined;
121
+ if (!scene) continue;
122
+ for (const w of c.words) {
123
+ if (narrationHas(scene.narration, w.word, w.nth ?? 1)) continue;
124
+ errors.push(`${file} line ${c.line}: ${c.fn}(... "${w.word}"${w.nth && w.nth > 1 ? `, { nth: ${w.nth} }` : ""}) names a word that scene ${scene.id} never says. Its narration is: "${scene.narration}". The render would stop on it; use a word from the narration.`);
125
+ }
126
+ }
127
+ return errors;
128
+ }
129
+
130
+ // True when the file times anything to a spoken word: it calls a word helper, or reads a scene's `.words` for something other than the captions.
131
+ export function usesWordTiming(source: string, file = "Video.tsx"): boolean {
132
+ const sf = sourceFile(source, file);
133
+ let used = false;
134
+ const visit = (node: ts.Node) => {
135
+ if (used) return;
136
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && isHelper(node.expression.text)) { used = true; return; }
137
+ if ((ts.isPropertyAccessExpression(node) && node.name.text === "words") || (ts.isElementAccessExpression(node) && literalText(node.argumentExpression) === "words")) {
138
+ // `words={s.words}` on <Captions> is not timing: it is the captions' own input.
139
+ let p: ts.Node | undefined = node.parent;
140
+ while (p && (ts.isParenthesizedExpression(p) || ts.isAsExpression(p) || ts.isNonNullExpression(p))) p = p.parent;
141
+ const inCaptions = p && ts.isJsxExpression(p) && ts.isJsxAttribute(p.parent) && p.parent.name.getText(sf) === "words"
142
+ && (() => { let q: ts.Node | undefined = p.parent.parent.parent; return Boolean(q && (ts.isJsxSelfClosingElement(q) || ts.isJsxOpeningElement(q)) && q.tagName.getText(sf) === "Captions"); })();
143
+ if (!inCaptions) used = true;
144
+ }
145
+ ts.forEachChild(node, visit);
146
+ };
147
+ visit(sf);
148
+ return used;
149
+ }
150
+
151
+ // How many frames after a word starts the preview looks: the thing the word names should already be there.
152
+ export const WORD_FRAME_AFTER = 4;
153
+ // At most this many word frames go on the contact sheet.
154
+ export const MAX_WORD_FRAMES = 16;
155
+
156
+ export type WordTarget = { sceneId: string; word: string; nth?: number; wordStart: number; frame: number; label: string };
157
+
158
+ // The words the preview can look at: every literal word of a helper call whose scene is known, as the frame 4 frames after the word starts (kept
159
+ // inside its scene), in film order, without repeats, at most `cap`. `skipped` counts what could not be shown: a word or a scene that is not a
160
+ // literal (the code does not say which), and what was left out past the cap.
161
+ export function wordTargets(calls: WordCall[], manifest: { scenes: (WordScene & { id: string; durationFrames: number })[] }, cap = MAX_WORD_FRAMES): { targets: WordTarget[]; skipped: number; undecidable: number; capped: number } {
162
+ const seen = new Set<string>();
163
+ const found: WordTarget[] = [];
164
+ let undecidable = 0;
165
+ for (const c of calls) {
166
+ const scene = c.sceneId ? manifest.scenes.find((s) => s.id === c.sceneId) : undefined;
167
+ if (!scene || !c.words.length) { undecidable += Math.max(1, c.words.length); continue; }
168
+ for (const w of c.words) {
169
+ const key = `${scene.id}|${normalizeWord(w.word) || w.word}|${w.nth ?? 1}`;
170
+ if (seen.has(key)) continue;
171
+ seen.add(key);
172
+ let start: number;
173
+ try { start = wordFrame(scene, w.word, { nth: w.nth, absolute: true }); } catch { undecidable++; continue; }
174
+ const last = scene.startFrame + scene.durationFrames - 1;
175
+ found.push({ sceneId: scene.id, word: w.word, ...(w.nth && w.nth > 1 ? { nth: w.nth } : {}), wordStart: start, frame: Math.min(start + WORD_FRAME_AFTER, last), label: `${w.word}${w.nth && w.nth > 1 ? ` #${w.nth}` : ""} \u25B8 ${scene.id}` });
176
+ }
177
+ }
178
+ found.sort((a, b) => a.wordStart - b.wordStart);
179
+ const targets = found.slice(0, cap);
180
+ return { targets, skipped: undecidable + (found.length - targets.length), undecidable, capped: found.length - targets.length };
181
+ }
@@ -356,6 +356,18 @@ export function runConformance(name: string, start: (opts: StartOptions) => Prom
356
356
  expect((await api("me", {})).quota.images.used).toBe(2);
357
357
  });
358
358
 
359
+ it("images: a request for format svg returns an svg, and a request with no format still returns a raster image", async () => {
360
+ const api = await as("user-gen00008");
361
+ const svg = await api("images", { prompt: "a red circle, transparent background", aspect: "1:1", shareable: false, tags: [], format: "svg" });
362
+ expect(svg.ext).toBe("svg");
363
+ expect(svg.contentType).toBe("image/svg+xml");
364
+ await download(svg.url, join(dest, "mark.svg"));
365
+ expect(readFileSync(join(dest, "mark.svg"), "utf8")).toMatch(/^\s*<svg/);
366
+ const raster = await api("images", { prompt: "a quiet lake", aspect: "16:9", shareable: false, tags: [] });
367
+ expect(raster.ext).not.toBe("svg");
368
+ expect((await api("me", {})).quota.images.used).toBe(2);
369
+ });
370
+
359
371
  it("a shared image's title is the first 60 characters of the prompt, cut on a code point, never inside an emoji", async () => {
360
372
  const api = await as("user-gen00007");
361
373
  const prompt = "\u{1F30B}".repeat(70);
@@ -7,7 +7,7 @@ import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
8
  import { MAX_UPLOAD_BYTES, routes, type ErrorCode, type LibraryItem, type RouteName, type Voice } from "../contract";
9
9
  import { staticCheck } from "../render/validate";
10
- import { PIXEL_PNG, silentWav } from "./fixtures";
10
+ import { FAKE_SVG, PIXEL_PNG, silentWav } from "./fixtures";
11
11
 
12
12
  type Blob = { filename: string; contentType: string; bytes: Uint8Array };
13
13
  // owner is the uploader's user id; a seeded item has none. An upload is not an item for anyone until it is committed.
@@ -212,15 +212,15 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
212
212
  const durationSec = list.length * WORD_SEC;
213
213
  return { url: fileUrl({ filename: "vo.wav", contentType: "audio/wav", bytes: silentWav(durationSec) }), ext: "wav", contentType: "audio/wav", durationSec, words: list, chars: text.length };
214
214
  },
215
- images: ({ prompt, aspect, shareable, tags }, { userId }) => {
215
+ images: ({ prompt, aspect, shareable, tags, format }, { userId }) => {
216
216
  const m = meter(userId);
217
217
  if (m.images + 1 > limits.images) throw new Fail(429, "quota_exceeded", `Image quota used up. It resets on ${resetDate().slice(0, 10)}.`);
218
218
  m.images++;
219
219
  usage.images++;
220
220
  const id = nextId("image");
221
- const file = { filename: "image.png", contentType: "image/png", bytes: PIXEL_PNG };
221
+ const file = format === "svg" ? { filename: "image.svg", contentType: "image/svg+xml", bytes: new TextEncoder().encode(FAKE_SVG) } : { filename: "image.png", contentType: "image/png", bytes: PIXEL_PNG };
222
222
  if (shareable) store.set(id, { item: { id, kind: "image", title: cut(prompt, 60), description: prompt, tags, meta: { aspect }, visibility: "review" }, file, filename: file.filename, contentType: file.contentType, owner: userId, committed: true });
223
- return { url: fileUrl(file), ext: "png", contentType: "image/png", ...(shareable ? { libraryId: id } : {}) };
223
+ return { url: fileUrl(file), ext: format === "svg" ? "svg" : "png", contentType: file.contentType, ...(shareable ? { libraryId: id } : {}) };
224
224
  },
225
225
  clipStart: (input, { userId }) => {
226
226
  if (opts.noClipProvider) throw new Fail(400, "invalid_request", "Clip generation is not available on this server yet.");
@@ -42,3 +42,6 @@ export const Video: React.FC<VideoProps> = ({ manifest, urls }) => {
42
42
  );
43
43
  };
44
44
  `;
45
+
46
+ // A tiny valid vector graphic, what the fake API returns for format "svg".
47
+ export const FAKE_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="#e02020"/><rect x="30" y="30" width="40" height="40" fill="#2040e0"/></svg>';