reelkit-cli 0.5.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 (104) hide show
  1. package/README.md +3 -2
  2. package/package.json +7 -2
  3. package/skill/SKILL.md +22 -11
  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 +32 -17
  9. package/skill/reference/captions.md +11 -5
  10. package/skill/reference/component-authoring.md +12 -1
  11. package/skill/reference/continuity.md +21 -2
  12. package/skill/reference/kit.md +140 -11
  13. package/skill/reference/launch-film.md +190 -0
  14. package/skill/reference/remotion-composition.md +4 -3
  15. package/skill/reference/scene-treatments.md +20 -0
  16. package/skill/reference/scriptwriting.md +4 -1
  17. package/skill/reference/three-d.md +134 -0
  18. package/skill/reference/voice-sync.md +108 -0
  19. package/src/agents.ts +23 -12
  20. package/src/api/client.ts +4 -1
  21. package/src/cli.ts +25 -8
  22. package/src/commands/assets.ts +334 -36
  23. package/src/commands/build.ts +172 -36
  24. package/src/commands/components.ts +220 -0
  25. package/src/commands/init.ts +9 -3
  26. package/src/commands/install.ts +1 -1
  27. package/src/commands/plan.ts +8 -5
  28. package/src/commands/ref.ts +5 -2
  29. package/src/contract/index.ts +27 -5
  30. package/src/pipeline/beatsnap.ts +72 -0
  31. package/src/pipeline/review.ts +67 -7
  32. package/src/pipeline/schema.ts +51 -4
  33. package/src/pipeline/timing.ts +27 -1
  34. package/src/project/background.ts +33 -0
  35. package/src/project/layers.ts +60 -0
  36. package/src/project/loudness.ts +68 -0
  37. package/src/project/manifest.ts +68 -14
  38. package/src/project/music.ts +19 -5
  39. package/src/project/project.ts +7 -2
  40. package/src/project/refmeasure.ts +1 -1
  41. package/src/project/soundreport.ts +347 -0
  42. package/src/project/svgcheck.ts +21 -0
  43. package/src/remotion/kit/Assemble3D.tsx +92 -0
  44. package/src/remotion/kit/BrowserFrame.tsx +83 -0
  45. package/src/remotion/kit/Camera.tsx +6 -4
  46. package/src/remotion/kit/Captions.tsx +33 -17
  47. package/src/remotion/kit/Card3D.tsx +211 -0
  48. package/src/remotion/kit/ChapterFrame.tsx +68 -0
  49. package/src/remotion/kit/CounterRoll.tsx +75 -0
  50. package/src/remotion/kit/GlassPanel.tsx +43 -0
  51. package/src/remotion/kit/Grounds.tsx +177 -0
  52. package/src/remotion/kit/Headline.tsx +97 -0
  53. package/src/remotion/kit/Hero3D.tsx +197 -0
  54. package/src/remotion/kit/HudOverlay.tsx +52 -0
  55. package/src/remotion/kit/ImageLayers.tsx +48 -0
  56. package/src/remotion/kit/Music.tsx +4 -4
  57. package/src/remotion/kit/NamedCursor.tsx +54 -0
  58. package/src/remotion/kit/Orbit3D.tsx +49 -0
  59. package/src/remotion/kit/Particles3D.tsx +74 -0
  60. package/src/remotion/kit/Place.tsx +12 -0
  61. package/src/remotion/kit/PromptBox.tsx +84 -0
  62. package/src/remotion/kit/Scene3D.tsx +70 -0
  63. package/src/remotion/kit/SceneFrame.tsx +88 -11
  64. package/src/remotion/kit/Sfx.tsx +12 -6
  65. package/src/remotion/kit/SoundCues.tsx +22 -0
  66. package/src/remotion/kit/TerminalLog.tsx +98 -0
  67. package/src/remotion/kit/Text3D.tsx +78 -0
  68. package/src/remotion/kit/TextOnImage.tsx +41 -0
  69. package/src/remotion/kit/Warp3D.tsx +59 -0
  70. package/src/remotion/kit/bg-math.ts +179 -0
  71. package/src/remotion/kit/caption-groups.ts +7 -3
  72. package/src/remotion/kit/caption-style.ts +45 -0
  73. package/src/remotion/kit/docs.ts +133 -11
  74. package/src/remotion/kit/image-layers-math.ts +115 -0
  75. package/src/remotion/kit/index.ts +43 -1
  76. package/src/remotion/kit/inter-bold-typeface.ts +3 -0
  77. package/src/remotion/kit/media.ts +5 -3
  78. package/src/remotion/kit/motion-math.ts +36 -2
  79. package/src/remotion/kit/music-math.ts +27 -10
  80. package/src/remotion/kit/quiet-three.ts +11 -0
  81. package/src/remotion/kit/sample-text.ts +55 -0
  82. package/src/remotion/kit/scene3d-context.ts +5 -0
  83. package/src/remotion/kit/seeded.ts +13 -0
  84. package/src/remotion/kit/sound-cues.ts +89 -0
  85. package/src/remotion/kit/sound-kinds.ts +122 -0
  86. package/src/remotion/kit/theme.ts +2 -0
  87. package/src/remotion/kit/three-fx-math.ts +192 -0
  88. package/src/remotion/kit/three-math.ts +145 -0
  89. package/src/remotion/kit/transition-math.ts +116 -0
  90. package/src/remotion/kit/ui-math.ts +145 -0
  91. package/src/remotion/kit/ui-theme.ts +25 -0
  92. package/src/remotion/kit/word-anchor.ts +107 -0
  93. package/src/render/contact-sheet.ts +39 -0
  94. package/src/render/continuity.ts +14 -4
  95. package/src/render/deps.ts +15 -3
  96. package/src/render/master.ts +31 -0
  97. package/src/render/render.ts +15 -8
  98. package/src/render/sound-notes.ts +106 -0
  99. package/src/render/static-check.ts +156 -0
  100. package/src/render/validate.ts +3 -150
  101. package/src/render/word-check.ts +181 -0
  102. package/src/testing/conformance.ts +61 -1
  103. package/src/testing/fake-api.ts +11 -5
  104. package/src/testing/fixtures.ts +3 -0
@@ -7,157 +7,10 @@ export const PKG_ROOT = fileURLToPath(new URL("../..", import.meta.url));
7
7
  export const KIT_INDEX = join(PKG_ROOT, "src", "remotion", "kit", "index.ts");
8
8
  export const ROOT_FILE = join(PKG_ROOT, "src", "remotion", "Root.tsx");
9
9
 
10
- export const MAIN_FILE = "Video.tsx";
11
- export const FILE_NAME = /^[A-Z][A-Za-z0-9]*\.tsx$/;
12
- export const componentName = (file: string) => file.replace(/\.tsx$/, "");
10
+ // The static check lives in its own module, which a server can bundle; everything it exports is available from here as before.
11
+ export { FILE_NAME, MAIN_FILE, componentName, importSpecifiers, staticCheck } from "./static-check";
12
+ import { KIT, sourceFile, unwrap } from "./static-check";
13
13
 
14
- const KIT = "reelkit/kit";
15
- const BASE_IMPORTS = new Set(["react", "remotion", KIT]);
16
-
17
- // This check keeps honest compositions deterministic and self-contained. It is NOT a security boundary:
18
- // code in src/ runs on the user's machine when it is bundled and rendered, and anyone determined can get around a list of names.
19
- // It looks at the code (the TypeScript syntax tree), never at text, so words in on-screen copy, strings and comments are not flagged.
20
- // Only a reference to the global counts: `style.process`, `{ fetch: 1 }`, and a name the file declares itself (a component called
21
- // `Worker`, a parameter called `window`) are fine. Scope comes from the TypeScript binder, so a declaration only covers where it is in scope.
22
- const FORBIDDEN_NAMES: Record<string, string> = {
23
- require: "require()", eval: "eval()", fetch: "fetch()",
24
- process: "process", globalThis: "globalThis", window: "window", self: "self",
25
- XMLHttpRequest: "XMLHttpRequest", WebSocket: "WebSocket", EventSource: "EventSource",
26
- navigator: "navigator", localStorage: "localStorage", sessionStorage: "sessionStorage", indexedDB: "indexedDB",
27
- Function: "Function", Worker: "Worker", SharedWorker: "SharedWorker", importScripts: "importScripts", child_process: "child_process",
28
- };
29
-
30
- const sourceFile = (source: string, file = "Video.tsx") => ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
31
-
32
- // True when this identifier names a property, a label or a JSX attribute rather than referring to a variable.
33
- function isNamePosition(node: ts.Identifier): boolean {
34
- const p = node.parent;
35
- if (!p) return false;
36
- if ((ts.isPropertyAccessExpression(p) || ts.isQualifiedName(p)) && (ts.isQualifiedName(p) ? p.right : p.name) === node) return true;
37
- if ((ts.isPropertyAssignment(p) || ts.isPropertyDeclaration(p) || ts.isPropertySignature(p) || ts.isMethodDeclaration(p) || ts.isMethodSignature(p)
38
- || ts.isGetAccessorDeclaration(p) || ts.isSetAccessorDeclaration(p) || ts.isEnumMember(p) || ts.isJsxAttribute(p)) && p.name === node) return true;
39
- if (ts.isBindingElement(p) && p.propertyName === node) return true;
40
- if ((ts.isImportSpecifier(p) || ts.isExportSpecifier(p)) && p.propertyName === node) return true;
41
- if ((ts.isLabeledStatement(p) || ts.isBreakOrContinueStatement(p)) && p.label === node) return true;
42
- return false;
43
- }
44
-
45
- // Looks through the wrappers that do not change what an expression is: (x), x as T, x!, x satisfies T, <T>x.
46
- function unwrap(e: ts.Expression): ts.Expression {
47
- while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isNonNullExpression(e) || ts.isSatisfiesExpression(e) || ts.isTypeAssertionExpression(e)) e = e.expression;
48
- return e;
49
- }
50
-
51
- // A one-file program with no libraries: a name the file declares has a symbol with a declaration in it; a global has none.
52
- function declaredHere(sf: ts.SourceFile): (id: ts.Identifier) => boolean {
53
- const host: ts.CompilerHost = {
54
- getSourceFile: (name) => (name === sf.fileName ? sf : undefined),
55
- getDefaultLibFileName: () => "lib.d.ts", writeFile: () => {}, getCurrentDirectory: () => "", getCanonicalFileName: (f) => f,
56
- useCaseSensitiveFileNames: () => true, getNewLine: () => "\n", fileExists: (f) => f === sf.fileName, readFile: () => undefined, getDirectories: () => [],
57
- };
58
- const program = ts.createProgram([sf.fileName], { noLib: true, noResolve: true, jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest }, host);
59
- const checker = program.getTypeChecker();
60
- return (id) => {
61
- const p = id.parent;
62
- const symbol = p && ts.isShorthandPropertyAssignment(p) && p.name === id ? checker.getShorthandAssignmentValueSymbol(p) : checker.getSymbolAtLocation(id);
63
- return Boolean(symbol?.declarations?.some((d) => d.getSourceFile() === sf));
64
- };
65
- }
66
-
67
- const textOf = (n: ts.Node | undefined) => (n && (ts.isStringLiteralLike(n)) ? n.text : undefined);
68
-
69
- type Finding = { specifiers: string[]; forbidden: string[] };
70
-
71
- function inspectSource(source: string, file?: string): Finding {
72
- const specifiers: string[] = [];
73
- const forbidden = new Set<string>();
74
- const add = (s: string | undefined) => { if (s !== undefined) specifiers.push(s); };
75
-
76
- const sf = sourceFile(source, file);
77
- const declared = declaredHere(sf);
78
- // True when the expression is the global with this name, not a variable the file declares.
79
- const isGlobal = (e: ts.Expression | undefined, name: string) => {
80
- const x = e && unwrap(e);
81
- return Boolean(x && ts.isIdentifier(x) && x.text === name && !declared(x));
82
- };
83
- const flagMember = (object: ts.Expression | undefined, member: string | undefined) => {
84
- if (isGlobal(object, "Math") && member === "random") forbidden.add("Math.random (use random(seed) from remotion)");
85
- if (isGlobal(object, "Date") && member === "now") forbidden.add("the clock (Date)");
86
- };
87
- // The key a destructuring element reads: `random` in `{ random }` and in `{ random: r }`, "random" in `{ ["random"]: r }`.
88
- const keyOf = (name: ts.PropertyName | undefined) =>
89
- name && (ts.isIdentifier(name) || ts.isStringLiteralLike(name) ? name.text : ts.isComputedPropertyName(name) ? textOf(name.expression) : undefined);
90
-
91
- const visit = (node: ts.Node, inType: boolean) => {
92
- if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) add(textOf(node.moduleSpecifier));
93
- else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) add(textOf(node.moduleReference.expression));
94
- else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) add(textOf(node.argument.literal));
95
- else if (ts.isCallExpression(node)) {
96
- const callee = node.expression;
97
- if (callee.kind === ts.SyntaxKind.ImportKeyword) { forbidden.add("dynamic import()"); add(textOf(node.arguments[0])); }
98
- else if (isGlobal(callee, "require")) add(textOf(node.arguments[0]));
99
- else if (ts.isPropertyAccessExpression(callee) && isGlobal(callee.expression, "require")) add(textOf(node.arguments[0]));
100
- if (isGlobal(callee, "Function")) forbidden.add("Function()");
101
- if (isGlobal(callee, "Date")) forbidden.add("the clock (Date)");
102
- } else if (ts.isNewExpression(node)) {
103
- if (isGlobal(node.expression, "Function")) forbidden.add("new Function");
104
- if (isGlobal(node.expression, "Date")) forbidden.add("the clock (Date)");
105
- }
106
-
107
- // Math.random and Date.now, also written Math["random"], (Math).random, or taken out by destructuring.
108
- if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
109
- flagMember(node.expression, ts.isPropertyAccessExpression(node) ? node.name.text : textOf(node.argumentExpression));
110
- } else if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name) && node.initializer) {
111
- for (const el of node.name.elements) flagMember(node.initializer, keyOf(el.propertyName) ?? (ts.isIdentifier(el.name) ? el.name.text : undefined));
112
- } else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isObjectLiteralExpression(unwrap(node.left))) {
113
- for (const prop of (unwrap(node.left) as ts.ObjectLiteralExpression).properties) {
114
- if (ts.isShorthandPropertyAssignment(prop)) flagMember(node.right, prop.name.text);
115
- else if (ts.isPropertyAssignment(prop)) flagMember(node.right, keyOf(prop.name));
116
- }
117
- }
118
- if (!inType && ts.isIdentifier(node) && !isNamePosition(node) && Object.hasOwn(FORBIDDEN_NAMES, node.text) && !declared(node)) {
119
- // A plain call or `new` of Function is already reported as Function() or new Function.
120
- const p = node.parent;
121
- const reported = node.text === "Function" && (ts.isCallExpression(p) || ts.isNewExpression(p)) && p.expression === node;
122
- if (!reported) forbidden.add(FORBIDDEN_NAMES[node.text]);
123
- }
124
-
125
- const nextInType = inType || (ts.isTypeNode(node) && !ts.isExpressionWithTypeArguments(node));
126
- ts.forEachChild(node, (c) => visit(c, nextInType));
127
- };
128
- visit(sf, false);
129
- return { specifiers, forbidden: [...forbidden] };
130
- }
131
-
132
- // Every module a file pulls in: import and export declarations, import(), require() and require.x(), however they are spaced or commented.
133
- export function importSpecifiers(source: string): string[] {
134
- return inspectSource(source).specifiers;
135
- }
136
-
137
- // Video.tsx may import sibling component files; a component must be self-contained so it can be reused on its own.
138
- export function staticCheck(source: string, file: string, siblings: string[]): string[] {
139
- const errors: string[] = [];
140
- const isMain = file === MAIN_FILE;
141
- const allowedSiblings = new Set(isMain ? siblings.filter((s) => s !== MAIN_FILE).map((s) => `./${componentName(s)}`) : []);
142
- const found = inspectSource(source, file);
143
- for (const s of found.specifiers) {
144
- if (BASE_IMPORTS.has(s) || allowedSiblings.has(s)) continue;
145
- errors.push(
146
- isMain
147
- ? `${file}: import not allowed: "${s}". Use react, remotion, ${KIT}, or a component file that exists ("./Name").`
148
- : `${file}: import not allowed: "${s}". A component may only import react, remotion and ${KIT}.`,
149
- );
150
- }
151
- for (const name of found.forbidden) errors.push(`${file}: not allowed: ${name}.`);
152
- const exported = isMain ? "Video" : componentName(file);
153
- const exportsIt = sourceFile(source, file).statements.some((s) =>
154
- ts.isVariableStatement(s) && s.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
155
- && s.declarationList.flags & ts.NodeFlags.Const && s.declarationList.declarations.some((d) => ts.isIdentifier(d.name) && d.name.text === exported));
156
- if (!exportsIt) errors.push(`${file} must contain \`export const ${exported}\`.`);
157
- return errors;
158
- }
159
-
160
- // The project folder has no node_modules. These map its imports to the copies installed with this package.
161
14
  const typesReact = packageDir("@types/react");
162
15
  export const remotionDir = packageDir("remotion");
163
16
 
@@ -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
+ }
@@ -166,7 +166,7 @@ export function runConformance(name: string, start: (opts: StartOptions) => Prom
166
166
  ["an image type for an sfx", { kind: "sfx", contentType: "image/png" }],
167
167
  ["zero bytes", { bytes: 0 }],
168
168
  ["more than 200 MB", { bytes: MAX_UPLOAD_BYTES + 1 }],
169
- ["a component", { kind: "component", filename: "X.tsx", contentType: "text/plain" }],
169
+ ["a component without a description or an example", { kind: "component", filename: "X.tsx", contentType: "text/plain" }],
170
170
  ])("refuses %s with invalid_request", async (_what, over) => {
171
171
  const e = await failure((await as("user-up00008"))("libraryUpload", upload(over) as never));
172
172
  expect(e.code).toBe("invalid_request");
@@ -182,6 +182,54 @@ export function runConformance(name: string, start: (opts: StartOptions) => Prom
182
182
  });
183
183
  });
184
184
 
185
+ describe("component uploads", () => {
186
+ const SOURCE = new TextEncoder().encode('import React from "react";\nexport const ZipBadge: React.FC<{ label: string }> = ({ label }) => <b>{label}</b>;\n');
187
+ const component = (over: object = {}) => upload({
188
+ kind: "component", title: "ZipBadge", description: "A small bold badge that shows a label.", tags: ["badge"], meta: { example: '<ZipBadge label="New" />' },
189
+ filename: "ZipBadge.tsx", contentType: "text/plain", bytes: SOURCE.length, ...over,
190
+ });
191
+
192
+ it("is committed into review, can be pulled by its owner, and is never in search or the public listing", async () => {
193
+ const mine = await as("user-cmp00001"), other = await as("user-cmp00002");
194
+ const up = await mine("libraryUpload", component());
195
+ expect((await put(up.uploadUrl, SOURCE, "text/plain")).ok).toBe(true);
196
+ const { item } = await mine("libraryCommit", { id: up.id });
197
+ expect(item).toMatchObject({ id: up.id, kind: "component", visibility: "review" });
198
+ expect((await mine("librarySearch", { q: "zipbadge badge", kind: "component" })).items.map((i) => i.id)).not.toContain(up.id);
199
+ expect((await other("librarySearch", { q: "zipbadge badge" })).items.map((i) => i.id)).not.toContain(up.id);
200
+ expect((await anon("publicLibrary", { q: "zipbadge badge" })).items.map((i) => i.id)).not.toContain(up.id);
201
+ expect((await mine("me", {})).contributions).toBe(0);
202
+ expect((await failure(other("libraryPull", { id: up.id }))).code).toBe("not_found");
203
+ });
204
+
205
+ it("refuses at commit a source that fails the static check, with the first problem", async () => {
206
+ const mine = await as("user-cmp00003");
207
+ const bad = new TextEncoder().encode('import fs from "node:fs";\nexport const ZipBadge = () => null;\n');
208
+ const up = await mine("libraryUpload", component({ bytes: bad.length }));
209
+ await put(up.uploadUrl, bad, "text/plain");
210
+ const e = await failure(mine("libraryCommit", { id: up.id }));
211
+ expect(e.code).toBe("invalid_request");
212
+ expect(e.message).toContain("node:fs");
213
+ expect((await failure(mine("libraryPull", { id: up.id }))).code).toBe("not_found");
214
+ });
215
+
216
+ it.each([
217
+ ["a file name that is not PascalCase .tsx", { filename: "zipBadge.tsx" }],
218
+ ["a file name that is not .tsx", { filename: "ZipBadge.ts" }],
219
+ ["a content type that is not text/plain", { contentType: "image/png" }],
220
+ ["more than 64 KB", { bytes: 65_537 }],
221
+ ["a description under 20 characters", { description: "a badge" }],
222
+ ["no example", { meta: {} }],
223
+ ["an example that does not start with the component's name", { meta: { example: '<Other label="x" />' } }],
224
+ ["an example over 2000 characters", { meta: { example: `<ZipBadge label="${"x".repeat(2000)}" />` } }],
225
+ ["a component that is not meant to be shared", { shareable: false }],
226
+ ])("refuses %s with invalid_request", async (_what, over) => {
227
+ const e = await failure((await as("user-cmp00004"))("libraryUpload", component(over) as never));
228
+ expect(e.code).toBe("invalid_request");
229
+ expect(e.message.length).toBeGreaterThan(0);
230
+ });
231
+ });
232
+
185
233
  describe("the library", () => {
186
234
  it("a seeded published item is found, pulled and downloaded byte for byte", async () => {
187
235
  const bytes = new Uint8Array(Array.from({ length: 300 }, (_, i) => i % 251));
@@ -308,6 +356,18 @@ export function runConformance(name: string, start: (opts: StartOptions) => Prom
308
356
  expect((await api("me", {})).quota.images.used).toBe(2);
309
357
  });
310
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
+
311
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 () => {
312
372
  const api = await as("user-gen00007");
313
373
  const prompt = "\u{1F30B}".repeat(70);
@@ -6,7 +6,8 @@ import type { AddressInfo } from "node:net";
6
6
  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
- import { PIXEL_PNG, silentWav } from "./fixtures";
9
+ import { staticCheck } from "../render/validate";
10
+ import { FAKE_SVG, PIXEL_PNG, silentWav } from "./fixtures";
10
11
 
11
12
  type Blob = { filename: string; contentType: string; bytes: Uint8Array };
12
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.
@@ -23,7 +24,7 @@ const WORD_SEC = 0.4;
23
24
  // of long media (music, overlays, clips) and a component's source do not. Any kind with a preview of its own would use that instead
24
25
  // (the fake has none).
25
26
  const PREVIEW_OF_ORIGINAL: readonly string[] = ["image", "sfx"];
26
- const MIME_PREFIX: Record<string, string> = { image: "image/", clip: "video/", overlay: "video/", sfx: "audio/", music: "audio/" };
27
+ const MIME_PREFIX: Record<string, string> = { component: "text/plain", image: "image/", clip: "video/", overlay: "video/", sfx: "audio/", music: "audio/" };
27
28
  // The device-flow user of the fake is "tester"; anyone else is named from their id, as the API names a user with no profile.
28
29
  const handleOf = (userId: string) => (userId === "u-test" ? "tester" : `user-${userId.slice(0, 8)}`);
29
30
  const nextMonthStart = (now = new Date()) => new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
@@ -190,6 +191,11 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
190
191
  const s = store.get(id);
191
192
  if (!s || s.owner !== userId) throw new Fail(404, "not_found", `No upload with id ${id}.`);
192
193
  if (!s.file) throw new Fail(400, "invalid_request", `Nothing was uploaded for ${id}. Send the file to the upload URL first.`);
194
+ // A user's component is checked like a pulled one would be, before it is anything but an upload. It stays in review for the owner.
195
+ if (!s.committed && s.item.kind === "component") {
196
+ const problem = staticCheck(new TextDecoder().decode(s.file.bytes), s.filename, [])[0];
197
+ if (problem) throw new Fail(400, "invalid_request", `The component was refused: ${problem}`);
198
+ }
193
199
  // Committing again returns the same item and counts once.
194
200
  if (!s.committed) { s.committed = true; usage.uploads++; if (s.item.visibility === "review") meter(userId).uploads++; }
195
201
  return { item: s.item };
@@ -206,15 +212,15 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
206
212
  const durationSec = list.length * WORD_SEC;
207
213
  return { url: fileUrl({ filename: "vo.wav", contentType: "audio/wav", bytes: silentWav(durationSec) }), ext: "wav", contentType: "audio/wav", durationSec, words: list, chars: text.length };
208
214
  },
209
- images: ({ prompt, aspect, shareable, tags }, { userId }) => {
215
+ images: ({ prompt, aspect, shareable, tags, format }, { userId }) => {
210
216
  const m = meter(userId);
211
217
  if (m.images + 1 > limits.images) throw new Fail(429, "quota_exceeded", `Image quota used up. It resets on ${resetDate().slice(0, 10)}.`);
212
218
  m.images++;
213
219
  usage.images++;
214
220
  const id = nextId("image");
215
- 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 };
216
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 });
217
- 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 } : {}) };
218
224
  },
219
225
  clipStart: (input, { userId }) => {
220
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>';