reelkit-cli 0.5.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.
@@ -13,7 +13,12 @@
13
13
  // - A voiceover `text` must contain at least one non-space character: whitespace alone is 400 invalid_request.
14
14
  // - A library item `id` (pull, commit) matches `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`; a `deviceCode` is 1 to 200 characters of plain text.
15
15
  // - A `voiceId` is 1 to 64 letters and digits and must be one of the voices the server lists (`voices`), otherwise 400 invalid_request.
16
- // - Components are added to the library only by the owner's scripts, never through this API (UploadKindSchema has no `component`).
16
+ // - A user's component is uploaded for review through the same two calls as any other file (`kind: "component"`). The server checks its source
17
+ // at commit with the same static check a pulled component gets (only react, remotion and reelkit/kit imports, no network, no globals that reach
18
+ // outside the video) and refuses a source that fails it with 400 `invalid_request` and the first problem. It stays in `review`, is never found
19
+ // by search or the public listing, and is published only by the owner. A component upload is: `contentType` `text/plain`, `bytes` up to 65,536,
20
+ // `shareable` true, `filename` matching `^[A-Z][A-Za-z0-9]*\.tsx$`, a `description` of at least 20 characters, and `meta.example` a string of at most
21
+ // 2,000 characters that starts with `<` and the file's name (`<StatCard value="42" />` for StatCard.tsx). Anything else is 400 `invalid_request`.
17
22
  // - Upload is a single `PUT` to `uploadUrl` with exactly the declared content type and byte length, and no auth header.
18
23
  // Anything else is refused with a non-2xx status and stores nothing. That refusal comes from the storage service, so its body
19
24
  // is not an API error and a client must not parse it.
@@ -105,6 +110,11 @@ const Meta = z.record(z.string(), z.unknown()).refine((m) => new TextEncoder().e
105
110
 
106
111
  // The most a single upload may be: 200 MB.
107
112
  export const MAX_UPLOAD_BYTES = 209_715_200;
113
+ // A component's source is at most 64 KB, its file name is PascalCase, and its description and example have the sizes below.
114
+ export const MAX_COMPONENT_BYTES = 65_536;
115
+ export const COMPONENT_FILE = /^[A-Z][A-Za-z0-9]*\.tsx$/;
116
+ export const MIN_COMPONENT_DESCRIPTION = 20;
117
+ export const MAX_COMPONENT_EXAMPLE = 2000;
108
118
  // The video types a cutout takes.
109
119
  export const CutoutTypeSchema = z.enum(["video/mp4", "video/quicktime", "video/webm"]);
110
120
  export type CutoutType = z.infer<typeof CutoutTypeSchema>;
@@ -124,8 +134,8 @@ export const ApiErrorSchema = z.object({ error: z.object({ code: z.string(), mes
124
134
 
125
135
  export const LibraryKindSchema = z.enum(["image", "overlay", "sfx", "music", "component", "clip"]);
126
136
  export type LibraryKind = z.infer<typeof LibraryKindSchema>;
127
- // What a user may upload: everything except components, which are code and come only from Reelkit.
128
- export const UploadKindSchema = LibraryKindSchema.exclude(["component"]);
137
+ // What a user may upload: every kind. A component is code and is held for review (see the rules at the top).
138
+ export const UploadKindSchema = LibraryKindSchema;
129
139
  export type UploadKind = z.infer<typeof UploadKindSchema>;
130
140
 
131
141
  export const LibraryItemSchema = z.object({
@@ -183,6 +193,16 @@ export const routes = {
183
193
  kind: UploadKindSchema, title: text(200).min(1), description: text(2000), tags: z.array(text(40)).max(20),
184
194
  meta: Meta, filename: text(200).min(1), contentType: text(100), bytes: z.number().int().positive().max(MAX_UPLOAD_BYTES),
185
195
  shareable: z.boolean(),
196
+ }).superRefine((r, ctx) => {
197
+ if (r.kind !== "component") return;
198
+ const bad = (path: string, message: string) => ctx.addIssue({ code: "custom", path: [path], message });
199
+ if (r.contentType.toLowerCase() !== "text/plain") bad("contentType", "a component must be text/plain");
200
+ if (r.bytes > MAX_COMPONENT_BYTES) bad("bytes", `a component is at most ${MAX_COMPONENT_BYTES} bytes`);
201
+ if (!COMPONENT_FILE.test(r.filename)) bad("filename", "a component file is named like StatCard.tsx");
202
+ if (!r.shareable) bad("shareable", "a component is only uploaded to be shared for review");
203
+ if (r.description.trim().length < MIN_COMPONENT_DESCRIPTION) bad("description", `a component needs a description of at least ${MIN_COMPONENT_DESCRIPTION} characters`);
204
+ const example = r.meta.example;
205
+ if (typeof example !== "string" || example.length > MAX_COMPONENT_EXAMPLE || !example.startsWith(`<${r.filename.replace(/\.tsx$/, "")}`)) bad("meta.example", `a component needs meta.example, at most ${MAX_COMPONENT_EXAMPLE} characters, that starts with <Name`);
186
206
  }),
187
207
  z.object({ id: z.string(), uploadUrl: z.string() })),
188
208
  libraryCommit: route("POST", "/library/upload/commit", true, z.object({ id: ItemId }), z.object({ item: LibraryItemSchema })),
@@ -58,5 +58,31 @@ export function reviewPlan(plan: ScenePlan, ctx: { footage?: AssetRecord }): str
58
58
  }
59
59
  const rhythm = rhythmNote(plan);
60
60
  if (rhythm) issues.push(rhythm);
61
+ issues.push(...structureNotes(plan, ctx));
61
62
  return issues;
62
63
  }
64
+
65
+ const hasPicture = (s: ScenePlan["scenes"][number]) => s.treatment === "illustration" || s.treatment === "clip" || s.userAssetIds.length > 0;
66
+
67
+ // The first sentence of some narration, up to . ! ? … or their Hebrew and Arabic forms.
68
+ export function firstSentence(text: string): string {
69
+ const m = /^[\s\S]*?[.!?…؟׃。!?]+(?=\s|$)/.exec(text.trim());
70
+ return (m ? m[0] : text).trim();
71
+ }
72
+
73
+ // What the plan alone says about whether the video will look like something: pictures, an opening that shows, an opening that gets to the point.
74
+ // Used by `plan check` and, because the composition is built from the plan, by `check` too.
75
+ export function structureNotes(plan: ScenePlan, ctx: { footage?: AssetRecord }): string[] {
76
+ const notes: string[] = [];
77
+ if (ctx.footage) return notes;
78
+ if (plan.scenes.length >= 4 && !plan.scenes.some(hasPicture)) {
79
+ notes.push("Every scene is type and shapes: no illustration, no clip and none of the user's own files. Give at least one scene a picture so the video has something to look at; see reference/scene-treatments.md.");
80
+ }
81
+ const first = plan.scenes[0]!;
82
+ if (!hasPicture(first)) {
83
+ notes.push(`The opening has no picture, clip or user asset (scene ${first.id}). The first second decides whether the video is watched, so open on something to look at; see reference/scriptwriting.md.`);
84
+ }
85
+ const n = words(firstSentence(first.narration));
86
+ if (n > 12) notes.push(`The first sentence of the opening is ${n} words. Open with a sentence of 12 words or fewer; see reference/scriptwriting.md.`);
87
+ return notes;
88
+ }
@@ -0,0 +1,68 @@
1
+ import { audioDuration, tool } from "./refmeasure";
2
+
3
+ // How loud a sound is, measured on this machine, and how much to turn it up or down so that the same `volume` number sounds equally loud
4
+ // for every file.
5
+ // A one-shot (a sound effect under 3 s) is levelled by its loudest moment: its peak is brought to -3 dBFS.
6
+ // A longer sound (music, a long effect) is levelled by how loud it is overall: about -18 LUFS, integrated.
7
+ // The gain is limited to 18 dB either way, so a nearly silent or a broken file is not blown up into noise.
8
+ export const ONE_SHOT_SECONDS = 3;
9
+ export const ONE_SHOT_PEAK_DB = -3;
10
+ export const LONG_TARGET_LUFS = -18;
11
+ export const MAX_GAIN_DB = 18;
12
+
13
+ export type SoundLevel = { durationSec: number; peakDb?: number; lufs?: number };
14
+
15
+ const round = (n: number, digits: number) => Math.round(n * 10 ** digits) / 10 ** digits;
16
+
17
+ // The gain in dB that brings a measured sound to its target, or 0 when it could not be measured (silence).
18
+ export function gainDbFor(level: SoundLevel): number {
19
+ const oneShot = level.durationSec < ONE_SHOT_SECONDS;
20
+ const measured = oneShot ? level.peakDb : level.lufs ?? level.peakDb;
21
+ if (measured === undefined || !Number.isFinite(measured)) return 0;
22
+ const target = oneShot || level.lufs === undefined ? ONE_SHOT_PEAK_DB : LONG_TARGET_LUFS;
23
+ return round(Math.max(-MAX_GAIN_DB, Math.min(MAX_GAIN_DB, target - measured)), 1);
24
+ }
25
+
26
+ export const dbToLinear = (db: number) => round(10 ** (db / 20), 4);
27
+
28
+ // ffmpeg's volumedetect: the loudest sample, in dBFS. Undefined for digital silence.
29
+ export async function peakOf(path: string): Promise<number | undefined> {
30
+ const { stderr } = await tool("ffmpeg", ["-hide_banner", "-nostats", "-i", path, "-vn", "-af", "volumedetect", "-f", "null", "-"]);
31
+ const peak = Number(/max_volume:\s*(-?[0-9.]+)\s*dB/.exec(stderr)?.[1]);
32
+ return Number.isFinite(peak) ? peak : undefined;
33
+ }
34
+
35
+ // What loudnorm's first pass reports; its numbers are text in the JSON it prints, and "-inf" for silence.
36
+ export type LoudnormReport = { input_i: string; input_tp: string; input_lra: string; input_thresh: string; target_offset: string };
37
+
38
+ export function parseLoudnorm(stderr: string): LoudnormReport | undefined {
39
+ const end = stderr.lastIndexOf("}");
40
+ const start = stderr.lastIndexOf("{", end);
41
+ if (start < 0 || end < start) return undefined;
42
+ try {
43
+ const r = JSON.parse(stderr.slice(start, end + 1)) as LoudnormReport;
44
+ return typeof r.input_i === "string" ? r : undefined;
45
+ } catch { return undefined; }
46
+ }
47
+
48
+ export async function measureLoudnorm(path: string, target: { i: number; tp: number }): Promise<LoudnormReport | undefined> {
49
+ const { stderr } = await tool("ffmpeg", ["-hide_banner", "-nostats", "-i", path, "-vn", "-af", `loudnorm=I=${target.i}:TP=${target.tp}:LRA=11:print_format=json`, "-f", "null", "-"]);
50
+ return parseLoudnorm(stderr);
51
+ }
52
+
53
+ // Measures a sound for levelling. A failure to measure is not the caller's failure: it returns undefined and the sound is used as it is.
54
+ export async function levelOf(path: string): Promise<(SoundLevel & { gainDb: number }) | undefined> {
55
+ try {
56
+ const durationSec = await audioDuration(path);
57
+ if (!(durationSec > 0)) return undefined;
58
+ const peakDb = await peakOf(path);
59
+ let lufs: number | undefined;
60
+ if (durationSec >= ONE_SHOT_SECONDS) {
61
+ const r = await measureLoudnorm(path, { i: LONG_TARGET_LUFS, tp: -1.5 });
62
+ const v = Number(r?.input_i);
63
+ if (Number.isFinite(v)) lufs = round(v, 1);
64
+ }
65
+ const level: SoundLevel = { durationSec, ...(peakDb !== undefined ? { peakDb } : {}), ...(lufs !== undefined ? { lufs } : {}) };
66
+ return { ...level, gainDb: gainDbFor(level) };
67
+ } catch { return undefined; }
68
+ }
@@ -1,6 +1,7 @@
1
1
  import { PACE_SPEED, type AssetManifest, type ScenePlan, type WordTiming } from "../pipeline/schema";
2
2
  import { extendBeats, snapToBeats, toBeatFrames } from "../pipeline/beatsnap";
3
3
  import { dimensionsFor, FPS, layoutScenes } from "../pipeline/timing";
4
+ import { dbToLinear } from "./loudness";
4
5
  import type { MusicRecord } from "./music";
5
6
  import { FILES, type Project } from "./project";
6
7
 
@@ -51,11 +52,19 @@ export function buildManifest(project: Project, plan: ScenePlan): AssetManifest
51
52
  totalFrames = footageFrames;
52
53
  }
53
54
 
55
+ // The levelling of every sound that was measured when it was pulled, as linear gains by path.
56
+ const soundGain: Record<string, number> = {};
57
+ for (const e of Object.values(project.readJsonOr<Record<string, { path: string; kind: string; gainDb?: number }>>(FILES.library, {}))) {
58
+ if ((e.kind === "sfx" || e.kind === "music") && typeof e.gainDb === "number") soundGain[e.path] = dbToLinear(e.gainDb);
59
+ }
60
+ if (music) soundGain[music.key] = dbToLinear(music.gainDb);
61
+
54
62
  const manifest: AssetManifest = {
55
63
  fps: FPS,
56
64
  ...dimensionsFor(plan.aspect, footage),
57
65
  totalFrames,
58
66
  captions: plan.captions ?? "phrase",
67
+ ...(Object.keys(soundGain).length ? { soundGain } : {}),
59
68
  ...(footage ? { footageKey: footage.key } : {}),
60
69
  ...(music ? { music: { key: music.key, ...(music.bpm ? { bpm: music.bpm } : {}), beatFrames: music.bpm ? beatFrames.filter((f) => f < totalFrames) : [] } } : {}),
61
70
  scenes: plan.scenes.map((scene, i) => ({
@@ -5,14 +5,16 @@ import { AspectSchema, type AssetRecord } from "../pipeline/schema";
5
5
 
6
6
  export const FILES = {
7
7
  config: "reelkit.json", plan: "plan.json", manifest: "manifest.json",
8
- assetIndex: "assets/index.json", voiceovers: "assets/voiceovers.json", images: "assets/images.json", clips: "assets/clips.json", library: "assets/library.json", music: "assets/music.json",
8
+ assetIndex: "assets/index.json", voiceovers: "assets/voiceovers.json", images: "assets/images.json", clips: "assets/clips.json", library: "assets/library.json", music: "assets/music.json", searches: "assets/searches.json", shared: "assets/shared.json",
9
9
  } as const;
10
10
 
11
11
  // "path: message", or just the message for a problem at the root of the file.
12
12
  export const issueLines = (e: { issues: { path: PropertyKey[]; message: string }[] }) =>
13
13
  e.issues.map((i) => (i.path.length ? `${i.path.map(String).join(".")}: ${i.message}` : i.message));
14
14
 
15
- export const ProjectConfigSchema = z.object({ aspect: AspectSchema, name: z.string().optional(), footage: z.string().optional() });
15
+ export const ProjectConfigSchema = z.object({ aspect: AspectSchema, name: z.string().optional(), footage: z.string().optional(),
16
+ // false when the project was made with --private: nothing is shared with the library automatically.
17
+ shareComponents: z.boolean().optional() });
16
18
  export type ProjectConfig = z.infer<typeof ProjectConfigSchema>;
17
19
 
18
20
  // One video's working folder. Every command reads and writes its state here.
@@ -9,7 +9,7 @@ const exec = promisify(execFile);
9
9
  export const REF_ASPECTS = ["9:16", "16:9", "1:1", "4:5"] as const;
10
10
  export type RefAspect = (typeof REF_ASPECTS)[number];
11
11
 
12
- async function tool(cmd: "ffmpeg" | "ffprobe", args: string[]): Promise<{ stdout: Buffer; stderr: string }> {
12
+ export async function tool(cmd: "ffmpeg" | "ffprobe", args: string[]): Promise<{ stdout: Buffer; stderr: string }> {
13
13
  try {
14
14
  const r = await exec(cmd, args, { maxBuffer: 256 * 1024 * 1024, encoding: "buffer" });
15
15
  return { stdout: r.stdout, stderr: r.stderr.toString("utf8") };
@@ -1,11 +1,17 @@
1
1
  import React from "react";
2
- import { useMedia } from "./media";
2
+ import { useGain, useMedia } from "./media";
3
3
  import { Audio, Sequence } from "remotion";
4
4
 
5
5
  // Plays one sound effect starting at a frame. at is counted from the start of the enclosing scene, or of the video
6
6
  // when placed outside the scenes. Keep volume well under the voiceover.
7
- export const Sfx: React.FC<{ src: string; at?: number; volume?: number }> = ({ src, at = 0, volume = 0.35 }) => (
8
- <Sequence from={Math.max(0, Math.round(at))} layout="none">
9
- <Audio src={useMedia(src)} volume={volume} />
10
- </Sequence>
11
- );
7
+ // A sound pulled from the library is levelled when it is pulled (the manifest's soundGain), and that is applied here by itself, so the same
8
+ // volume sounds equally loud for every file: 0.2 to 0.45 is the range for an effect.
9
+ export const Sfx: React.FC<{ src: string; at?: number; volume?: number }> = ({ src, at = 0, volume = 0.35 }) => {
10
+ const url = useMedia(src);
11
+ const gain = useGain(src);
12
+ return (
13
+ <Sequence from={Math.max(0, Math.round(at))} layout="none">
14
+ <Audio src={url} volume={volume * gain} />
15
+ </Sequence>
16
+ );
17
+ };
@@ -107,6 +107,7 @@ ScreenOverlay { src: string; durationSec?: number; opacity?: number }
107
107
 
108
108
  Sfx { src: string; at?: number; volume?: number }
109
109
  Plays one sound effect from the shared library, starting at the frame given by "at", counted from the start of the enclosing scene. Pull one with: reelkit assets search "<description>" --kind sfx, then reelkit assets pull <id>.
110
+ A pulled sound is levelled when it is pulled (the manifest's soundGain), and Sfx and Music apply that by themselves: the same volume sounds equally loud for every file. Nothing to pass.
110
111
  <Sfx src={urls["assets/lib/<id>/clip.mp3"]} at={10} volume={0.35} />
111
112
 
112
113
  Music { src: string; volume?: number; duckTo?: number }
@@ -15,11 +15,13 @@ export function useMedia(src: string): string {
15
15
  export const useManifest = () => useContext(ManifestContext);
16
16
 
17
17
  // The linear gain that levels a sound, from the manifest's soundGain. The sound may be named by its project path or by its link.
18
- export function useGain(src: string): number {
19
- const urls = useContext(UrlsContext);
20
- const gains = useContext(ManifestContext)?.soundGain;
18
+ export function gainFor(gains: Record<string, number> | undefined, urls: Record<string, string>, src: string): number {
21
19
  if (!gains) return 1;
22
20
  if (gains[src] !== undefined) return gains[src]!;
23
21
  const path = Object.keys(gains).find((k) => urls[k] === src);
24
22
  return path ? gains[path]! : 1;
25
23
  }
24
+
25
+ export function useGain(src: string): number {
26
+ return gainFor(useContext(ManifestContext)?.soundGain, useContext(UrlsContext), src);
27
+ }
@@ -0,0 +1,31 @@
1
+ import { renameSync, rmSync } from "node:fs";
2
+ import { loudnessLufs, tool } from "../project/refmeasure";
3
+ import { measureLoudnorm } from "../project/loudness";
4
+
5
+ // The loudness a finished video is brought to: -14 LUFS integrated, the level the big video platforms play at, with the loudest instant
6
+ // kept under -1 dBTP so that re-encoding on upload cannot clip.
7
+ export const MASTER_LUFS = -14;
8
+ export const MASTER_TRUE_PEAK_DB = -1;
9
+
10
+ export type Mastered = { inputLufs: number; outputLufs: number };
11
+ // What a test may replace: the whole pass.
12
+ export type Master = (path: string) => Promise<Mastered>;
13
+
14
+ // Two-pass loudnorm: the first pass measures, the second applies exactly the gain that measurement asks for (linear, so the dynamics stay).
15
+ // The video stream is copied untouched. The result replaces the file only when everything worked; otherwise the original stays as it was.
16
+ export const masterLoudness: Master = async (path) => {
17
+ const first = await measureLoudnorm(path, { i: MASTER_LUFS, tp: MASTER_TRUE_PEAK_DB });
18
+ const inputLufs = Number(first?.input_i);
19
+ if (!first || !Number.isFinite(inputLufs)) throw new Error("the video has no sound to measure");
20
+ const out = path.replace(/(\.[^./]+)?$/, ".mastered$1");
21
+ const filter = `loudnorm=I=${MASTER_LUFS}:TP=${MASTER_TRUE_PEAK_DB}:LRA=11:measured_I=${first.input_i}:measured_TP=${first.input_tp}:measured_LRA=${first.input_lra}:measured_thresh=${first.input_thresh}:offset=${first.target_offset}:linear=true`;
22
+ try {
23
+ await tool("ffmpeg", ["-v", "error", "-y", "-i", path, "-c:v", "copy", "-af", filter, "-ar", "48000", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", out]);
24
+ const outputLufs = await loudnessLufs(out);
25
+ if (outputLufs === undefined) throw new Error("the mastered file has no readable loudness");
26
+ renameSync(out, path);
27
+ return { inputLufs: Math.round(inputLufs * 10) / 10, outputLufs };
28
+ } finally {
29
+ rmSync(out, { force: true });
30
+ }
31
+ };
@@ -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