reelkit-cli 0.4.0 → 0.6.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 (50) hide show
  1. package/README.md +3 -2
  2. package/package.json +3 -2
  3. package/skill/SKILL.md +11 -5
  4. package/skill/reference/beat-sync.md +54 -0
  5. package/skill/reference/captions.md +18 -2
  6. package/skill/reference/component-authoring.md +12 -1
  7. package/skill/reference/continuity.md +99 -0
  8. package/skill/reference/kit.md +19 -1
  9. package/skill/reference/motion-design.md +9 -0
  10. package/skill/reference/references.md +11 -0
  11. package/skill/reference/remotion-composition.md +2 -1
  12. package/skill/reference/scene-treatments.md +1 -1
  13. package/skill/reference/scriptwriting.md +39 -5
  14. package/skill/reference/sound-design.md +11 -0
  15. package/skill/reference/styles.md +9 -0
  16. package/src/cli.ts +10 -4
  17. package/src/commands/assets.ts +46 -5
  18. package/src/commands/build.ts +99 -11
  19. package/src/commands/components.ts +220 -0
  20. package/src/commands/init.ts +9 -3
  21. package/src/commands/plan.ts +4 -2
  22. package/src/commands/ref.ts +11 -3
  23. package/src/contract/index.ts +23 -3
  24. package/src/pipeline/beatsnap.ts +52 -0
  25. package/src/pipeline/review.ts +54 -1
  26. package/src/pipeline/schema.ts +8 -0
  27. package/src/project/loudness.ts +68 -0
  28. package/src/project/manifest.ts +31 -1
  29. package/src/project/music.ts +25 -0
  30. package/src/project/project.ts +4 -2
  31. package/src/project/refmeasure.ts +45 -1
  32. package/src/remotion/Root.tsx +4 -2
  33. package/src/remotion/kit/Camera.tsx +22 -0
  34. package/src/remotion/kit/Captions.tsx +25 -8
  35. package/src/remotion/kit/Carry.tsx +38 -0
  36. package/src/remotion/kit/Music.tsx +19 -0
  37. package/src/remotion/kit/Sfx.tsx +12 -6
  38. package/src/remotion/kit/beat.ts +23 -0
  39. package/src/remotion/kit/caption-groups.ts +65 -0
  40. package/src/remotion/kit/docs.ts +19 -1
  41. package/src/remotion/kit/index.ts +7 -0
  42. package/src/remotion/kit/media.ts +17 -0
  43. package/src/remotion/kit/motion-math.ts +113 -0
  44. package/src/remotion/kit/music-math.ts +42 -0
  45. package/src/render/continuity.ts +87 -0
  46. package/src/render/master.ts +31 -0
  47. package/src/render/static-check.ts +156 -0
  48. package/src/render/validate.ts +30 -150
  49. package/src/testing/conformance.ts +49 -1
  50. package/src/testing/fake-api.ts +7 -1
@@ -0,0 +1,156 @@
1
+ import ts from "typescript";
2
+
3
+ // The check a component's source gets before it is used or shared: which modules it may import, which names it may not touch, and that
4
+ // it exports what its file name says. Kept apart from the rest of validation because it needs nothing from this package's own folder
5
+ // or from the machine: it is pure text in, problems out, so a server can bundle it.
6
+ export const MAIN_FILE = "Video.tsx";
7
+ export const FILE_NAME = /^[A-Z][A-Za-z0-9]*\.tsx$/;
8
+ export const componentName = (file: string) => file.replace(/\.tsx$/, "");
9
+
10
+ export const KIT = "reelkit/kit";
11
+ const BASE_IMPORTS = new Set(["react", "remotion", KIT]);
12
+
13
+ // This check keeps honest compositions deterministic and self-contained. It is NOT a security boundary:
14
+ // 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.
15
+ // It looks at the code (the TypeScript syntax tree), never at text, so words in on-screen copy, strings and comments are not flagged.
16
+ // Only a reference to the global counts: `style.process`, `{ fetch: 1 }`, and a name the file declares itself (a component called
17
+ // `Worker`, a parameter called `window`) are fine. Scope comes from the TypeScript binder, so a declaration only covers where it is in scope.
18
+ const FORBIDDEN_NAMES: Record<string, string> = {
19
+ require: "require()", eval: "eval()", fetch: "fetch()",
20
+ process: "process", globalThis: "globalThis", window: "window", self: "self",
21
+ XMLHttpRequest: "XMLHttpRequest", WebSocket: "WebSocket", EventSource: "EventSource",
22
+ navigator: "navigator", localStorage: "localStorage", sessionStorage: "sessionStorage", indexedDB: "indexedDB",
23
+ Function: "Function", Worker: "Worker", SharedWorker: "SharedWorker", importScripts: "importScripts", child_process: "child_process",
24
+ };
25
+
26
+ export const sourceFile = (source: string, file = "Video.tsx") => ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
27
+
28
+ // True when this identifier names a property, a label or a JSX attribute rather than referring to a variable.
29
+ function isNamePosition(node: ts.Identifier): boolean {
30
+ const p = node.parent;
31
+ if (!p) return false;
32
+ if ((ts.isPropertyAccessExpression(p) || ts.isQualifiedName(p)) && (ts.isQualifiedName(p) ? p.right : p.name) === node) return true;
33
+ if ((ts.isPropertyAssignment(p) || ts.isPropertyDeclaration(p) || ts.isPropertySignature(p) || ts.isMethodDeclaration(p) || ts.isMethodSignature(p)
34
+ || ts.isGetAccessorDeclaration(p) || ts.isSetAccessorDeclaration(p) || ts.isEnumMember(p) || ts.isJsxAttribute(p)) && p.name === node) return true;
35
+ if (ts.isBindingElement(p) && p.propertyName === node) return true;
36
+ if ((ts.isImportSpecifier(p) || ts.isExportSpecifier(p)) && p.propertyName === node) return true;
37
+ if ((ts.isLabeledStatement(p) || ts.isBreakOrContinueStatement(p)) && p.label === node) return true;
38
+ return false;
39
+ }
40
+
41
+ // Looks through the wrappers that do not change what an expression is: (x), x as T, x!, x satisfies T, <T>x.
42
+ export function unwrap(e: ts.Expression): ts.Expression {
43
+ while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isNonNullExpression(e) || ts.isSatisfiesExpression(e) || ts.isTypeAssertionExpression(e)) e = e.expression;
44
+ return e;
45
+ }
46
+
47
+ // A one-file program with no libraries: a name the file declares has a symbol with a declaration in it; a global has none.
48
+ function declaredHere(sf: ts.SourceFile): (id: ts.Identifier) => boolean {
49
+ const host: ts.CompilerHost = {
50
+ getSourceFile: (name) => (name === sf.fileName ? sf : undefined),
51
+ getDefaultLibFileName: () => "lib.d.ts", writeFile: () => {}, getCurrentDirectory: () => "", getCanonicalFileName: (f) => f,
52
+ useCaseSensitiveFileNames: () => true, getNewLine: () => "\n", fileExists: (f) => f === sf.fileName, readFile: () => undefined, getDirectories: () => [],
53
+ };
54
+ const program = ts.createProgram([sf.fileName], { noLib: true, noResolve: true, jsx: ts.JsxEmit.Preserve, target: ts.ScriptTarget.Latest }, host);
55
+ const checker = program.getTypeChecker();
56
+ return (id) => {
57
+ const p = id.parent;
58
+ const symbol = p && ts.isShorthandPropertyAssignment(p) && p.name === id ? checker.getShorthandAssignmentValueSymbol(p) : checker.getSymbolAtLocation(id);
59
+ return Boolean(symbol?.declarations?.some((d) => d.getSourceFile() === sf));
60
+ };
61
+ }
62
+
63
+ const textOf = (n: ts.Node | undefined) => (n && (ts.isStringLiteralLike(n)) ? n.text : undefined);
64
+
65
+ type Finding = { specifiers: string[]; forbidden: string[] };
66
+
67
+ function inspectSource(source: string, file?: string): Finding {
68
+ const specifiers: string[] = [];
69
+ const forbidden = new Set<string>();
70
+ const add = (s: string | undefined) => { if (s !== undefined) specifiers.push(s); };
71
+
72
+ const sf = sourceFile(source, file);
73
+ const declared = declaredHere(sf);
74
+ // True when the expression is the global with this name, not a variable the file declares.
75
+ const isGlobal = (e: ts.Expression | undefined, name: string) => {
76
+ const x = e && unwrap(e);
77
+ return Boolean(x && ts.isIdentifier(x) && x.text === name && !declared(x));
78
+ };
79
+ const flagMember = (object: ts.Expression | undefined, member: string | undefined) => {
80
+ if (isGlobal(object, "Math") && member === "random") forbidden.add("Math.random (use random(seed) from remotion)");
81
+ if (isGlobal(object, "Date") && member === "now") forbidden.add("the clock (Date)");
82
+ };
83
+ // The key a destructuring element reads: `random` in `{ random }` and in `{ random: r }`, "random" in `{ ["random"]: r }`.
84
+ const keyOf = (name: ts.PropertyName | undefined) =>
85
+ name && (ts.isIdentifier(name) || ts.isStringLiteralLike(name) ? name.text : ts.isComputedPropertyName(name) ? textOf(name.expression) : undefined);
86
+
87
+ const visit = (node: ts.Node, inType: boolean) => {
88
+ if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) add(textOf(node.moduleSpecifier));
89
+ else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) add(textOf(node.moduleReference.expression));
90
+ else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) add(textOf(node.argument.literal));
91
+ else if (ts.isCallExpression(node)) {
92
+ const callee = node.expression;
93
+ if (callee.kind === ts.SyntaxKind.ImportKeyword) { forbidden.add("dynamic import()"); add(textOf(node.arguments[0])); }
94
+ else if (isGlobal(callee, "require")) add(textOf(node.arguments[0]));
95
+ else if (ts.isPropertyAccessExpression(callee) && isGlobal(callee.expression, "require")) add(textOf(node.arguments[0]));
96
+ if (isGlobal(callee, "Function")) forbidden.add("Function()");
97
+ if (isGlobal(callee, "Date")) forbidden.add("the clock (Date)");
98
+ } else if (ts.isNewExpression(node)) {
99
+ if (isGlobal(node.expression, "Function")) forbidden.add("new Function");
100
+ if (isGlobal(node.expression, "Date")) forbidden.add("the clock (Date)");
101
+ }
102
+
103
+ // Math.random and Date.now, also written Math["random"], (Math).random, or taken out by destructuring.
104
+ if (ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) {
105
+ flagMember(node.expression, ts.isPropertyAccessExpression(node) ? node.name.text : textOf(node.argumentExpression));
106
+ } else if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name) && node.initializer) {
107
+ for (const el of node.name.elements) flagMember(node.initializer, keyOf(el.propertyName) ?? (ts.isIdentifier(el.name) ? el.name.text : undefined));
108
+ } else if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isObjectLiteralExpression(unwrap(node.left))) {
109
+ for (const prop of (unwrap(node.left) as ts.ObjectLiteralExpression).properties) {
110
+ if (ts.isShorthandPropertyAssignment(prop)) flagMember(node.right, prop.name.text);
111
+ else if (ts.isPropertyAssignment(prop)) flagMember(node.right, keyOf(prop.name));
112
+ }
113
+ }
114
+ if (!inType && ts.isIdentifier(node) && !isNamePosition(node) && Object.hasOwn(FORBIDDEN_NAMES, node.text) && !declared(node)) {
115
+ // A plain call or `new` of Function is already reported as Function() or new Function.
116
+ const p = node.parent;
117
+ const reported = node.text === "Function" && (ts.isCallExpression(p) || ts.isNewExpression(p)) && p.expression === node;
118
+ if (!reported) forbidden.add(FORBIDDEN_NAMES[node.text]);
119
+ }
120
+
121
+ const nextInType = inType || (ts.isTypeNode(node) && !ts.isExpressionWithTypeArguments(node));
122
+ ts.forEachChild(node, (c) => visit(c, nextInType));
123
+ };
124
+ visit(sf, false);
125
+ return { specifiers, forbidden: [...forbidden] };
126
+ }
127
+
128
+ // Every module a file pulls in: import and export declarations, import(), require() and require.x(), however they are spaced or commented.
129
+ export function importSpecifiers(source: string): string[] {
130
+ return inspectSource(source).specifiers;
131
+ }
132
+
133
+ // Video.tsx may import sibling component files; a component must be self-contained so it can be reused on its own.
134
+ export function staticCheck(source: string, file: string, siblings: string[]): string[] {
135
+ const errors: string[] = [];
136
+ const isMain = file === MAIN_FILE;
137
+ const allowedSiblings = new Set(isMain ? siblings.filter((s) => s !== MAIN_FILE).map((s) => `./${componentName(s)}`) : []);
138
+ const found = inspectSource(source, file);
139
+ for (const s of found.specifiers) {
140
+ if (BASE_IMPORTS.has(s) || allowedSiblings.has(s)) continue;
141
+ errors.push(
142
+ isMain
143
+ ? `${file}: import not allowed: "${s}". Use react, remotion, ${KIT}, or a component file that exists ("./Name").`
144
+ : `${file}: import not allowed: "${s}". A component may only import react, remotion and ${KIT}.`,
145
+ );
146
+ }
147
+ for (const name of found.forbidden) errors.push(`${file}: not allowed: ${name}.`);
148
+ const exported = isMain ? "Video" : componentName(file);
149
+ const exportsIt = sourceFile(source, file).statements.some((s) =>
150
+ ts.isVariableStatement(s) && s.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
151
+ && s.declarationList.flags & ts.NodeFlags.Const && s.declarationList.declarations.some((d) => ts.isIdentifier(d.name) && d.name.text === exported));
152
+ if (!exportsIt) errors.push(`${file} must contain \`export const ${exported}\`.`);
153
+ return errors;
154
+ }
155
+
156
+ // The project folder has no node_modules. These map its imports to the copies installed with this package.
@@ -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
 
@@ -189,3 +42,30 @@ export function typecheck(files: string[]): string[] {
189
42
  return `${basename(d.file!.fileName)} line ${pos.line + 1}: ${ts.flattenDiagnosticMessageText(d.messageText, "\n")}`;
190
43
  });
191
44
  }
45
+
46
+ const isLiteral = (e: ts.Expression): boolean => {
47
+ const x = unwrap(e);
48
+ if (ts.isStringLiteralLike(x) || ts.isNumericLiteral(x) || x.kind === ts.SyntaxKind.TrueKeyword || x.kind === ts.SyntaxKind.FalseKeyword || x.kind === ts.SyntaxKind.NullKeyword) return true;
49
+ if (ts.isPrefixUnaryExpression(x)) return ts.isNumericLiteral(x.operand);
50
+ if (ts.isArrayLiteralExpression(x)) return x.elements.every(isLiteral);
51
+ if (ts.isObjectLiteralExpression(x)) return x.properties.every((p) => ts.isPropertyAssignment(p) && (ts.isIdentifier(p.name) || ts.isStringLiteralLike(p.name)) && isLiteral(p.initializer));
52
+ return false;
53
+ };
54
+
55
+ // Every use of <tag> in the file, as it would be written on its own: the opening tag with its props, closed with "/>". `literal` is false
56
+ // when a prop is anything but a literal value (a variable, urls[...], s.words, a function, a spread), which would mean nothing outside this file.
57
+ export function jsxUses(source: string, file: string, tag: string): { example: string; literal: boolean }[] {
58
+ const sf = sourceFile(source, file);
59
+ const out: { example: string; literal: boolean }[] = [];
60
+ const visit = (node: ts.Node) => {
61
+ const opening = ts.isJsxSelfClosingElement(node) ? node : ts.isJsxElement(node) ? node.openingElement : undefined;
62
+ if (opening && opening.tagName.getText(sf) === tag) {
63
+ const literal = opening.attributes.properties.every((a) => ts.isJsxAttribute(a) && (!a.initializer || ts.isStringLiteralLike(a.initializer) || (ts.isJsxExpression(a.initializer) && a.initializer.expression !== undefined && isLiteral(a.initializer.expression))));
64
+ const props = opening.attributes.properties.map((a) => a.getText(sf).replace(/\s*\n\s*/g, " ")).join(" ");
65
+ out.push({ example: `<${tag}${props ? ` ${props}` : ""} />`, literal });
66
+ }
67
+ ts.forEachChild(node, visit);
68
+ };
69
+ visit(sf);
70
+ return out;
71
+ }
@@ -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));
@@ -6,6 +6,7 @@ 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 { staticCheck } from "../render/validate";
9
10
  import { PIXEL_PNG, silentWav } from "./fixtures";
10
11
 
11
12
  type Blob = { filename: string; contentType: string; bytes: Uint8Array };
@@ -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 };