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.
- package/README.md +3 -2
- package/package.json +7 -2
- package/skill/SKILL.md +22 -11
- package/skill/THIRD_PARTY.md +102 -0
- package/skill/commands/launch-film.md +7 -0
- package/skill/reference/asset-reuse.md +13 -2
- package/skill/reference/backgrounds.md +63 -0
- package/skill/reference/beat-sync.md +32 -17
- package/skill/reference/captions.md +11 -5
- package/skill/reference/component-authoring.md +12 -1
- package/skill/reference/continuity.md +21 -2
- package/skill/reference/kit.md +140 -11
- package/skill/reference/launch-film.md +190 -0
- package/skill/reference/remotion-composition.md +4 -3
- package/skill/reference/scene-treatments.md +20 -0
- package/skill/reference/scriptwriting.md +4 -1
- package/skill/reference/three-d.md +134 -0
- package/skill/reference/voice-sync.md +108 -0
- package/src/agents.ts +23 -12
- package/src/api/client.ts +4 -1
- package/src/cli.ts +25 -8
- package/src/commands/assets.ts +334 -36
- package/src/commands/build.ts +172 -36
- package/src/commands/components.ts +220 -0
- package/src/commands/init.ts +9 -3
- package/src/commands/install.ts +1 -1
- package/src/commands/plan.ts +8 -5
- package/src/commands/ref.ts +5 -2
- package/src/contract/index.ts +27 -5
- package/src/pipeline/beatsnap.ts +72 -0
- package/src/pipeline/review.ts +67 -7
- package/src/pipeline/schema.ts +51 -4
- package/src/pipeline/timing.ts +27 -1
- package/src/project/background.ts +33 -0
- package/src/project/layers.ts +60 -0
- package/src/project/loudness.ts +68 -0
- package/src/project/manifest.ts +68 -14
- package/src/project/music.ts +19 -5
- package/src/project/project.ts +7 -2
- package/src/project/refmeasure.ts +1 -1
- package/src/project/soundreport.ts +347 -0
- package/src/project/svgcheck.ts +21 -0
- package/src/remotion/kit/Assemble3D.tsx +92 -0
- package/src/remotion/kit/BrowserFrame.tsx +83 -0
- package/src/remotion/kit/Camera.tsx +6 -4
- package/src/remotion/kit/Captions.tsx +33 -17
- package/src/remotion/kit/Card3D.tsx +211 -0
- package/src/remotion/kit/ChapterFrame.tsx +68 -0
- package/src/remotion/kit/CounterRoll.tsx +75 -0
- package/src/remotion/kit/GlassPanel.tsx +43 -0
- package/src/remotion/kit/Grounds.tsx +177 -0
- package/src/remotion/kit/Headline.tsx +97 -0
- package/src/remotion/kit/Hero3D.tsx +197 -0
- package/src/remotion/kit/HudOverlay.tsx +52 -0
- package/src/remotion/kit/ImageLayers.tsx +48 -0
- package/src/remotion/kit/Music.tsx +4 -4
- package/src/remotion/kit/NamedCursor.tsx +54 -0
- package/src/remotion/kit/Orbit3D.tsx +49 -0
- package/src/remotion/kit/Particles3D.tsx +74 -0
- package/src/remotion/kit/Place.tsx +12 -0
- package/src/remotion/kit/PromptBox.tsx +84 -0
- package/src/remotion/kit/Scene3D.tsx +70 -0
- package/src/remotion/kit/SceneFrame.tsx +88 -11
- package/src/remotion/kit/Sfx.tsx +12 -6
- package/src/remotion/kit/SoundCues.tsx +22 -0
- package/src/remotion/kit/TerminalLog.tsx +98 -0
- package/src/remotion/kit/Text3D.tsx +78 -0
- package/src/remotion/kit/TextOnImage.tsx +41 -0
- package/src/remotion/kit/Warp3D.tsx +59 -0
- package/src/remotion/kit/bg-math.ts +179 -0
- package/src/remotion/kit/caption-groups.ts +7 -3
- package/src/remotion/kit/caption-style.ts +45 -0
- package/src/remotion/kit/docs.ts +133 -11
- package/src/remotion/kit/image-layers-math.ts +115 -0
- package/src/remotion/kit/index.ts +43 -1
- package/src/remotion/kit/inter-bold-typeface.ts +3 -0
- package/src/remotion/kit/media.ts +5 -3
- package/src/remotion/kit/motion-math.ts +36 -2
- package/src/remotion/kit/music-math.ts +27 -10
- package/src/remotion/kit/quiet-three.ts +11 -0
- package/src/remotion/kit/sample-text.ts +55 -0
- package/src/remotion/kit/scene3d-context.ts +5 -0
- package/src/remotion/kit/seeded.ts +13 -0
- package/src/remotion/kit/sound-cues.ts +89 -0
- package/src/remotion/kit/sound-kinds.ts +122 -0
- package/src/remotion/kit/theme.ts +2 -0
- package/src/remotion/kit/three-fx-math.ts +192 -0
- package/src/remotion/kit/three-math.ts +145 -0
- package/src/remotion/kit/transition-math.ts +116 -0
- package/src/remotion/kit/ui-math.ts +145 -0
- package/src/remotion/kit/ui-theme.ts +25 -0
- package/src/remotion/kit/word-anchor.ts +107 -0
- package/src/render/contact-sheet.ts +39 -0
- package/src/render/continuity.ts +14 -4
- package/src/render/deps.ts +15 -3
- package/src/render/master.ts +31 -0
- package/src/render/render.ts +15 -8
- package/src/render/sound-notes.ts +106 -0
- package/src/render/static-check.ts +156 -0
- package/src/render/validate.ts +3 -150
- package/src/render/word-check.ts +181 -0
- package/src/testing/conformance.ts +61 -1
- package/src/testing/fake-api.ts +11 -5
- package/src/testing/fixtures.ts +3 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Word anchors: where in the film a spoken word falls, so that what illustrates a word can be placed on it.
|
|
2
|
+
// Everything here is a pure function over a manifest scene (`manifest.scenes[i]`, with its `words`), so it is the same in the studio, in a test and in a render.
|
|
3
|
+
// Frames are counted from the start of the scene (what its own useCurrentFrame() gives) unless `absolute: true` adds the scene's startFrame.
|
|
4
|
+
import type { WordTiming } from "../../pipeline/schema";
|
|
5
|
+
|
|
6
|
+
// The manifest's frame rate is always this one; pass `fps` only for a scene that came from somewhere else.
|
|
7
|
+
export const WORD_FPS = 30;
|
|
8
|
+
// An entrance needs a few frames to become visible, so it starts this many frames before its word.
|
|
9
|
+
export const DEFAULT_LEAD = 3;
|
|
10
|
+
// A beat counts as "at the word" when it is no more than this many frames from the word's start.
|
|
11
|
+
export const DEFAULT_BEAT_WINDOW = 3;
|
|
12
|
+
// The thing a word names is never seen more than this many frames before the word starts.
|
|
13
|
+
export const MAX_EARLY_FRAMES = 3;
|
|
14
|
+
|
|
15
|
+
export type WordScene = { id?: string; startFrame: number; durationFrames?: number; words: WordTiming[] };
|
|
16
|
+
|
|
17
|
+
export type WordFrameOptions = {
|
|
18
|
+
// The 1-based occurrence of the word when the scene says it more than once (default 1).
|
|
19
|
+
nth?: number;
|
|
20
|
+
// "start" (default): the frame the word starts. "end": the frame it ends (for a phrase, the end of its last word).
|
|
21
|
+
edge?: "start" | "end";
|
|
22
|
+
// Add the scene's startFrame, giving a frame of the whole film.
|
|
23
|
+
absolute?: boolean;
|
|
24
|
+
fps?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// A word as it is compared: lower case, with everything but letters and digits removed ("Meetings," is "meetings", "don't" is "dont").
|
|
28
|
+
export const normalizeWord = (w: string): string => w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, "");
|
|
29
|
+
|
|
30
|
+
const spoken = (scene: WordScene): { norm: string; word: WordTiming }[] =>
|
|
31
|
+
scene.words.map((word) => ({ norm: normalizeWord(word.word), word })).filter((w) => w.norm !== "");
|
|
32
|
+
|
|
33
|
+
// The words that make up `phrase`, matched against the scene's words, with where the phrase's `nth` occurrence is.
|
|
34
|
+
function findPhrase(scene: WordScene, phrase: string, nth: number): { first: WordTiming; last: WordTiming } {
|
|
35
|
+
const parts = phrase.split(/\s+/).map(normalizeWord).filter(Boolean);
|
|
36
|
+
const list = spoken(scene);
|
|
37
|
+
const where = scene.id ? `scene "${scene.id}"` : "the scene";
|
|
38
|
+
if (!parts.length) throw new Error(`wordFrame: the word is empty. ${where} says: ${list.map((w) => w.word.word).join(" ")}`);
|
|
39
|
+
if (!Number.isInteger(nth) || nth < 1) throw new Error(`wordFrame: nth must be 1 or more, got ${nth}.`);
|
|
40
|
+
let seen = 0;
|
|
41
|
+
for (let i = 0; i + parts.length <= list.length; i++) {
|
|
42
|
+
if (parts.every((p, k) => list[i + k]!.norm === p) && ++seen === nth) return { first: list[i]!.word, last: list[i + parts.length - 1]!.word };
|
|
43
|
+
}
|
|
44
|
+
const had = seen ? ` (it is said ${seen} time${seen === 1 ? "" : "s"}, not ${nth})` : "";
|
|
45
|
+
throw new Error(`wordFrame: "${phrase}"${nth > 1 ? ` (occurrence ${nth})` : ""} is not in ${where}${had}. Its words are: ${list.map((w) => w.word.word).join(" ")}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The frame, inside the scene, at which `word` starts (or ends, with edge: "end"). `word` ignores case and punctuation; a phrase of several words
|
|
49
|
+
// ("whole week") matches consecutive words and gives the first one's start. A word the scene does not say throws an Error that lists the scene's
|
|
50
|
+
// words, so a misspelt word stops the render instead of silently timing to frame 0.
|
|
51
|
+
export function wordFrame(scene: WordScene, word: string, opts: WordFrameOptions = {}): number {
|
|
52
|
+
const { first, last } = findPhrase(scene, word, opts.nth ?? 1);
|
|
53
|
+
const fps = opts.fps ?? WORD_FPS;
|
|
54
|
+
const sec = opts.edge === "end" ? last.endSec : first.startSec;
|
|
55
|
+
let frame = Math.max(0, Math.round(sec * fps));
|
|
56
|
+
if (scene.durationFrames !== undefined) frame = Math.min(frame, Math.max(0, scene.durationFrames - 1));
|
|
57
|
+
return frame + (opts.absolute ? scene.startFrame : 0);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Several words at once, in the order given.
|
|
61
|
+
export const wordFrames = (scene: WordScene, words: string[], opts: WordFrameOptions = {}): number[] => words.map((w) => wordFrame(scene, w, opts));
|
|
62
|
+
|
|
63
|
+
// The scene with this id. Throws an Error that lists the ids when there is none.
|
|
64
|
+
export function sceneById<S extends { id: string }>(manifest: { scenes: S[] }, id: string): S {
|
|
65
|
+
const found = manifest.scenes.find((s) => s.id === id);
|
|
66
|
+
if (!found) throw new Error(`sceneById: there is no scene "${id}". The scenes are: ${manifest.scenes.map((s) => s.id).join(", ")}`);
|
|
67
|
+
return found;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type OnWordOptions = WordFrameOptions & {
|
|
71
|
+
// How many frames before the word the entrance starts (default 3).
|
|
72
|
+
lead?: number;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// The frame at which to START an entrance (a `delay`, a `from`, an `at`) so that it is seen landing on its word: the word's frame minus `lead`
|
|
76
|
+
// (default 3), never below 0.
|
|
77
|
+
export function onWord(scene: WordScene, word: string, opts: OnWordOptions = {}): number {
|
|
78
|
+
const lead = opts.lead ?? DEFAULT_LEAD;
|
|
79
|
+
const at = wordFrame(scene, word, { ...opts, absolute: false });
|
|
80
|
+
return Math.max(0, at - lead) + (opts.absolute ? scene.startFrame : 0);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type OnWordBeatOptions = OnWordOptions & {
|
|
84
|
+
// A beat counts only when it is at most this many frames from the word's start (default 3).
|
|
85
|
+
window?: number;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// Beat and word together. The voice is the clock for what illustrates a word, so the word wins; the beat is used only when one lies within `window`
|
|
89
|
+
// frames of the word (default 3), and then only to let the entrance land on it. The result is never earlier than onWord's, so what a word names
|
|
90
|
+
// is never seen more than 3 frames before the word starts. `music` is manifest.music (its beatFrames are frames of the whole film); without a
|
|
91
|
+
// track it is onWord.
|
|
92
|
+
export function onWordBeat(scene: WordScene, word: string, music: { beatFrames: number[] } | undefined, opts: OnWordBeatOptions = {}): number {
|
|
93
|
+
const lead = opts.lead ?? DEFAULT_LEAD;
|
|
94
|
+
const window = opts.window ?? DEFAULT_BEAT_WINDOW;
|
|
95
|
+
const at = wordFrame(scene, word, { ...opts, absolute: false });
|
|
96
|
+
const wordAbs = scene.startFrame + at;
|
|
97
|
+
const base = Math.max(0, at - lead);
|
|
98
|
+
let start = base;
|
|
99
|
+
let best: number | undefined;
|
|
100
|
+
for (const b of music?.beatFrames ?? []) {
|
|
101
|
+
// A beat before the word is never used: it would show the thing early.
|
|
102
|
+
if (b < wordAbs || b - wordAbs > window) continue;
|
|
103
|
+
if (best === undefined || b - wordAbs < best - wordAbs) best = b;
|
|
104
|
+
}
|
|
105
|
+
if (best !== undefined) start = Math.max(base, best - scene.startFrame - lead);
|
|
106
|
+
return start + (opts.absolute ? scene.startFrame : 0);
|
|
107
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const exec = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
// The grid of a sheet of `count` tiles for a frame of `aspect` (width over height): 6 across for a tall frame, 4 for a wide one, so that the whole sheet
|
|
7
|
+
// is at most about `maxWidth` pixels across and a phone-shaped frame is still readable.
|
|
8
|
+
export function sheetGrid(count: number, aspect: number, maxWidth = 2400): { cols: number; rows: number; tileW: number; tileH: number } {
|
|
9
|
+
const cols = Math.max(1, Math.min(count, aspect < 1 ? 6 : aspect > 1.2 ? 4 : 5));
|
|
10
|
+
const tileW = Math.min(540, Math.floor(maxWidth / cols / 2) * 2);
|
|
11
|
+
const tileH = Math.max(2, Math.round(tileW / aspect / 2) * 2);
|
|
12
|
+
return { cols, rows: Math.ceil(count / cols), tileW, tileH };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// The drawn text is plain ASCII (the default font has no arrows): the arrow of a word label is drawn as >.
|
|
16
|
+
const clean = (s: string) => s.replace(/\u25B8/g, ">").replace(/[^A-Za-z0-9 _.>#-]/g, "");
|
|
17
|
+
|
|
18
|
+
// One picture of many: the files in order, each with its label in a box on its top left corner. Falls back to the same sheet without labels if this ffmpeg
|
|
19
|
+
// has no text drawing; the caller still lists the order. `newRowAt` starts a new row at that tile (the tiles before it are padded with empty ones), so a
|
|
20
|
+
// second kind of frame sits in rows of its own. Returns the grid, and whether the labels were drawn.
|
|
21
|
+
export async function contactSheet(files: string[], labels: string[], out: string, aspect: number, opts: { newRowAt?: number } = {}): Promise<{ cols: number; rows: number; width: number; height: number; labelled: boolean }> {
|
|
22
|
+
const base = sheetGrid(files.length, aspect);
|
|
23
|
+
const at = opts.newRowAt !== undefined && opts.newRowAt > 0 && opts.newRowAt < files.length ? opts.newRowAt : undefined;
|
|
24
|
+
const blanks = at === undefined ? 0 : (base.cols - (at % base.cols)) % base.cols;
|
|
25
|
+
const g = { ...base, rows: Math.ceil((files.length + blanks) / base.cols) };
|
|
26
|
+
const build = (labelled: boolean) => {
|
|
27
|
+
const tile = (i: number) => `[${i}:v]scale=${g.tileW}:${g.tileH},setsar=1${labelled ? `,drawtext=text='${clean(labels[i] ?? "")}':x=6:y=6:fontsize=${Math.max(12, Math.round(g.tileW / 22))}:fontcolor=white:box=1:boxcolor=black@0.65:boxborderw=4` : ""}[t${i}]`;
|
|
28
|
+
const parts = files.map((_, i) => tile(i));
|
|
29
|
+
// The empty tiles are extra inputs of plain colour, one frame each.
|
|
30
|
+
const blankInputs = Array.from({ length: blanks }, () => ["-f", "lavfi", "-i", `color=c=0x202020:s=${g.tileW}x${g.tileH}:r=25`]).flat();
|
|
31
|
+
const blankParts = Array.from({ length: blanks }, (_, k) => `[${files.length + k}:v]trim=end_frame=1,setpts=PTS-STARTPTS,format=yuvj420p,setsar=1[b${k}]`);
|
|
32
|
+
const order = [...files.map((_, i) => (i === at ? [...Array.from({ length: blanks }, (_, k) => `[b${k}]`), `[t${i}]`] : [`[t${i}]`])).flat()];
|
|
33
|
+
const filter = `${[...parts, ...blankParts].join(";")};${order.join("")}concat=n=${files.length + blanks}:v=1:a=0,tile=${g.cols}x${g.rows}:padding=2:color=0x202020[out]`;
|
|
34
|
+
return ["-y", "-loglevel", "error", ...files.flatMap((f) => ["-i", f]), ...blankInputs, "-filter_complex", filter, "-map", "[out]", "-frames:v", "1", "-q:v", "4", out];
|
|
35
|
+
};
|
|
36
|
+
let labelled = true;
|
|
37
|
+
try { await exec("ffmpeg", build(true)); } catch { labelled = false; await exec("ffmpeg", build(false)); }
|
|
38
|
+
return { cols: g.cols, rows: g.rows, width: g.cols * g.tileW + (g.cols - 1) * 2, height: g.rows * g.tileH + (g.rows - 1) * 2, labelled };
|
|
39
|
+
}
|
package/src/render/continuity.ts
CHANGED
|
@@ -10,7 +10,8 @@ export const CARRIED_AT = 0.25;
|
|
|
10
10
|
const MIN_EDGE_SHARE = 0.002;
|
|
11
11
|
|
|
12
12
|
export type Gray = ArrayLike<number>;
|
|
13
|
-
|
|
13
|
+
// "cut (intended)" is a change the plan marked with cutIn: it is reported but never counts against the score.
|
|
14
|
+
export type BoundaryKind = "carried" | "cut" | "empty" | "cut (intended)";
|
|
14
15
|
|
|
15
16
|
// A pixel is an edge where the brightness steps by more than the threshold to the pixel on its right or below it.
|
|
16
17
|
export function edgeMap(gray: Gray, w: number, h: number, threshold = EDGE_THRESHOLD): Uint8Array {
|
|
@@ -57,6 +58,13 @@ export function dilate(map: Uint8Array, w: number, h: number, radius: number): U
|
|
|
57
58
|
return out;
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
// The later scene is compared at two moments, its first fully visible frame and one a few frames on while a move is still settling (a plate
|
|
62
|
+
// that becomes a card has not yet reached its edges at the first). The better of the two says whether something was carried.
|
|
63
|
+
export function compareAcross(earlier: Gray, laters: Gray[], w: number, h: number): ReturnType<typeof compareFrames> {
|
|
64
|
+
const all = laters.map((l) => compareFrames(earlier, l, w, h));
|
|
65
|
+
return all.reduce((best, r) => (r.survived > best.survived ? r : best));
|
|
66
|
+
}
|
|
67
|
+
|
|
60
68
|
export function compareFrames(earlier: Gray, later: Gray, w: number, h: number): { survived: number; edges: number; kind: BoundaryKind } {
|
|
61
69
|
const band = captionBand(w, h);
|
|
62
70
|
const before = clearBottom(edgeMap(earlier, w, h), w, h, band);
|
|
@@ -72,15 +80,17 @@ export type Boundary = { from: string; to: string; survived: number; kind: Bound
|
|
|
72
80
|
|
|
73
81
|
// The score is carried over carried plus cut: a change with nothing on screen to compare says nothing either way.
|
|
74
82
|
export function summariseContinuity(boundaries: Boundary[]): { score: number | null; lines: string[] } {
|
|
83
|
+
const intended = boundaries.filter((b) => b.kind === "cut (intended)").length;
|
|
75
84
|
const carried = boundaries.filter((b) => b.kind === "carried").length;
|
|
76
85
|
const cut = boundaries.filter((b) => b.kind === "cut");
|
|
77
86
|
const counted = carried + cut.length;
|
|
78
|
-
const empty = boundaries.length - counted;
|
|
79
|
-
|
|
87
|
+
const empty = boundaries.length - counted - intended;
|
|
88
|
+
const hard = intended ? ` ${intended} ${intended === 1 ? "is" : "are"} intended hard cut${intended === 1 ? "" : "s"}, not counted.` : "";
|
|
89
|
+
if (!counted) return { score: null, lines: [intended && !empty ? `Every scene change that could be measured is an intended hard cut.` : "No scene change could be measured: the picture is nearly empty at the end of every scene."] };
|
|
80
90
|
return {
|
|
81
91
|
score: carried / counted,
|
|
82
92
|
lines: [
|
|
83
|
-
`${carried} of ${counted} scene changes carry something across${empty ? ` (${empty} more had nothing on screen to compare)` : ""}
|
|
93
|
+
`${carried} of ${counted} scene changes carry something across${empty ? ` (${empty} more had nothing on screen to compare)` : ""}.${hard}`,
|
|
84
94
|
...cut.map((b) => `${b.from} → ${b.to}: nothing carries over. Keep one element on screen through the change and move it into the next scene (see reference/continuity.md).`),
|
|
85
95
|
],
|
|
86
96
|
};
|
package/src/render/deps.ts
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
1
2
|
import { createRequire } from "node:module";
|
|
2
|
-
import { dirname } from "node:path";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
3
4
|
|
|
4
5
|
const require = createRequire(import.meta.url);
|
|
5
6
|
|
|
6
7
|
// The directory of an installed dependency of this package, found through its package.json so it does not depend on
|
|
7
|
-
// where the package keeps its main file or on how the package manager lays out node_modules.
|
|
8
|
-
|
|
8
|
+
// where the package keeps its main file or on how the package manager lays out node_modules. A package that does not export its
|
|
9
|
+
// package.json (three does not) is found from its main file instead, by walking up to the folder that holds its own package.json.
|
|
10
|
+
export const packageDir = (name: string): string => {
|
|
11
|
+
try {
|
|
12
|
+
return dirname(require.resolve(`${name}/package.json`));
|
|
13
|
+
} catch {
|
|
14
|
+
for (let dir = dirname(require.resolve(name)); dir !== dirname(dir); dir = dirname(dir)) {
|
|
15
|
+
const file = join(dir, "package.json");
|
|
16
|
+
if (existsSync(file) && (JSON.parse(readFileSync(file, "utf8")) as { name?: string }).name === name) return dir;
|
|
17
|
+
}
|
|
18
|
+
throw new Error(`Could not find the installed package ${name}.`);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
@@ -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
|
+
};
|
package/src/render/render.ts
CHANGED
|
@@ -14,9 +14,10 @@ registerRoot(makeRoot(Video));
|
|
|
14
14
|
`;
|
|
15
15
|
|
|
16
16
|
// The project folder has no node_modules. Each dependency is pinned to the one copy installed with this package, so the
|
|
17
|
-
// kit, Root and the user's composition all share a single react and remotion (a second react breaks hooks)
|
|
17
|
+
// kit, Root and the user's composition all share a single react and remotion (a second react breaks hooks), and the 3D layer a single three
|
|
18
|
+
// (two copies of three break instanceof checks). User code still may not import them: only the kit does.
|
|
18
19
|
export function dependencyAliases(): Record<string, string> {
|
|
19
|
-
return Object.fromEntries(["react", "react-dom", "remotion"].map((n) => [n, packageDir(n)]));
|
|
20
|
+
return Object.fromEntries(["react", "react-dom", "remotion", "three", "@react-three/fiber", "@remotion/three"].map((n) => [n, packageDir(n)]));
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
// Writes the bundler's entry file into the project. The composition itself stays where the user's Claude wrote it.
|
|
@@ -57,18 +58,24 @@ export const ensureRenderBrowser: EnsureBrowser = async (onDownload) => {
|
|
|
57
58
|
await ensureBrowser({ onBrowserDownload: () => { onDownload(); return { version: null, onProgress: () => {} }; } });
|
|
58
59
|
};
|
|
59
60
|
|
|
61
|
+
// Remotion's headless browser has no GPU, so three.js needs a software GL. "angle" is the setting that draws WebGL here; it is used only for a film that
|
|
62
|
+
// has a 3D scene, so every 2D render keeps the browser's own defaults and looks exactly as before. This is the one place that says it.
|
|
63
|
+
export const GL_BACKEND = "angle" as const;
|
|
64
|
+
export type RenderOptions = { uses3D?: boolean };
|
|
65
|
+
const chromium = (o?: RenderOptions) => (o?.uses3D ? { chromiumOptions: { gl: GL_BACKEND } } : {});
|
|
66
|
+
|
|
60
67
|
const asInput = (props: VideoProps) => props as unknown as Record<string, unknown>;
|
|
61
68
|
|
|
62
|
-
export async function renderStills(serveUrl: string, props: VideoProps, frames: number[], outDir: string) {
|
|
69
|
+
export async function renderStills(serveUrl: string, props: VideoProps, frames: number[], outDir: string, options?: RenderOptions) {
|
|
63
70
|
await mkdir(outDir, { recursive: true });
|
|
64
|
-
const composition = await selectComposition({ serveUrl, id: "Video", inputProps: asInput(props) });
|
|
71
|
+
const composition = await selectComposition({ serveUrl, id: "Video", inputProps: asInput(props), ...chromium(options) });
|
|
65
72
|
for (const frame of frames) {
|
|
66
|
-
await renderStill({ composition, serveUrl, frame, output: join(outDir, `still-${frame}.png`), inputProps: asInput(props) });
|
|
73
|
+
await renderStill({ composition, serveUrl, frame, output: join(outDir, `still-${frame}.png`), inputProps: asInput(props), ...chromium(options) });
|
|
67
74
|
}
|
|
68
75
|
}
|
|
69
76
|
|
|
70
|
-
export async function renderVideo(serveUrl: string, props: VideoProps, outPath: string) {
|
|
77
|
+
export async function renderVideo(serveUrl: string, props: VideoProps, outPath: string, options?: RenderOptions) {
|
|
71
78
|
await mkdir(dirname(outPath), { recursive: true });
|
|
72
|
-
const composition = await selectComposition({ serveUrl, id: "Video", inputProps: asInput(props) });
|
|
73
|
-
await renderMedia({ composition, serveUrl, codec: "h264", outputLocation: outPath, inputProps: asInput(props) });
|
|
79
|
+
const composition = await selectComposition({ serveUrl, id: "Video", inputProps: asInput(props), ...chromium(options) });
|
|
80
|
+
await renderMedia({ composition, serveUrl, codec: "h264", outputLocation: outPath, inputProps: asInput(props), ...chromium(options) });
|
|
74
81
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { sourceFile } from "./static-check";
|
|
3
|
+
|
|
4
|
+
// Advice about the sound of a film with no voice, read from the composition's source. It is a count of what the code places, never a
|
|
5
|
+
// listening test: a sound that is computed, looped over or hidden behind a variable cannot be counted, so then nothing is said.
|
|
6
|
+
export type SoundCount = {
|
|
7
|
+
// Sfx elements and literal SoundCues entries.
|
|
8
|
+
placed: number;
|
|
9
|
+
// True when some cue is computed (a map, a helper, a variable that is not a plain list), so that the count is only a lower bound.
|
|
10
|
+
uncertain: boolean;
|
|
11
|
+
music: boolean;
|
|
12
|
+
// True when a cue or an effect is placed before this frame; `unknownStart` when one has a start that is not a plain number.
|
|
13
|
+
early: boolean;
|
|
14
|
+
unknownStart: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const numberOf = (e: ts.Expression | undefined): number | undefined => {
|
|
18
|
+
if (!e) return undefined;
|
|
19
|
+
if (ts.isNumericLiteral(e)) return Number(e.text);
|
|
20
|
+
if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(e.operand)) return -Number(e.operand.text);
|
|
21
|
+
if (ts.isParenthesizedExpression(e)) return numberOf(e.expression);
|
|
22
|
+
return undefined;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const insideLoop = (node: ts.Node): boolean => {
|
|
26
|
+
for (let p = node.parent; p; p = p.parent) {
|
|
27
|
+
if (ts.isCallExpression(p) && ts.isPropertyAccessExpression(p.expression) && ["map", "forEach", "flatMap"].includes(p.expression.name.text)) return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// The value of a prop, looking through a name that the file declares as a constant.
|
|
33
|
+
function propValue(attr: ts.JsxAttribute, sf: ts.SourceFile): ts.Expression | undefined {
|
|
34
|
+
const init = attr.initializer;
|
|
35
|
+
if (!init || !ts.isJsxExpression(init) || !init.expression) return undefined;
|
|
36
|
+
const e = init.expression;
|
|
37
|
+
if (!ts.isIdentifier(e)) return e;
|
|
38
|
+
let found: ts.Expression | undefined;
|
|
39
|
+
const visit = (n: ts.Node) => {
|
|
40
|
+
if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.name.text === e.text && n.initializer && !found) found = n.initializer;
|
|
41
|
+
ts.forEachChild(n, visit);
|
|
42
|
+
};
|
|
43
|
+
visit(sf);
|
|
44
|
+
return found ?? e;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function countSounds(source: string, file: string, earlyFrames: number): SoundCount {
|
|
48
|
+
const sf = sourceFile(source, file);
|
|
49
|
+
const out: SoundCount = { placed: 0, uncertain: false, music: false, early: false, unknownStart: false };
|
|
50
|
+
const attr = (el: ts.JsxOpeningLikeElement, name: string) => el.attributes.properties.find((a): a is ts.JsxAttribute => ts.isJsxAttribute(a) && a.name.getText(sf) === name);
|
|
51
|
+
const atOf = (e: ts.Expression | undefined, absent: number | undefined) => {
|
|
52
|
+
const n = e === undefined ? absent : numberOf(e);
|
|
53
|
+
if (n === undefined) out.unknownStart = true;
|
|
54
|
+
else if (n < earlyFrames) out.early = true;
|
|
55
|
+
};
|
|
56
|
+
const visit = (node: ts.Node) => {
|
|
57
|
+
const el = ts.isJsxSelfClosingElement(node) ? node : ts.isJsxElement(node) ? node.openingElement : undefined;
|
|
58
|
+
if (el) {
|
|
59
|
+
const tag = el.tagName.getText(sf);
|
|
60
|
+
if (tag === "Music") out.music = true;
|
|
61
|
+
if (tag === "Sfx") {
|
|
62
|
+
out.placed += 1;
|
|
63
|
+
if (insideLoop(el)) out.uncertain = true;
|
|
64
|
+
const at = attr(el, "at");
|
|
65
|
+
if (!at) out.early = true;
|
|
66
|
+
else {
|
|
67
|
+
const v = at.initializer && ts.isJsxExpression(at.initializer) ? at.initializer.expression : undefined;
|
|
68
|
+
atOf(v, undefined);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (tag === "SoundCues") {
|
|
72
|
+
const cues = attr(el, "cues");
|
|
73
|
+
const value = cues && propValue(cues, sf);
|
|
74
|
+
if (!value || !ts.isArrayLiteralExpression(value) || value.elements.some((c) => !ts.isObjectLiteralExpression(c)) || insideLoop(el)) out.uncertain = true;
|
|
75
|
+
else {
|
|
76
|
+
out.placed += value.elements.length;
|
|
77
|
+
for (const c of value.elements as ts.NodeArray<ts.ObjectLiteralExpression>) {
|
|
78
|
+
const at = c.properties.find((p): p is ts.PropertyAssignment => ts.isPropertyAssignment(p) && p.name.getText(sf) === "at");
|
|
79
|
+
atOf(at?.initializer, undefined);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
ts.forEachChild(node, visit);
|
|
85
|
+
};
|
|
86
|
+
visit(sf);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The notes for a film with no voice. `seconds` is the film's length, `musicSet` whether a track was pulled with --music.
|
|
91
|
+
export function soundNotes(sources: { name: string; source: string }[], opts: { seconds: number; fps: number; musicSet: boolean }): string[] {
|
|
92
|
+
const earlyFrames = Math.round(opts.fps / 2);
|
|
93
|
+
const counts = sources.map((s) => countSounds(s.source, s.name, earlyFrames));
|
|
94
|
+
const placed = counts.reduce((n, c) => n + c.placed, 0);
|
|
95
|
+
const uncertain = counts.some((c) => c.uncertain);
|
|
96
|
+
const notes: string[] = [];
|
|
97
|
+
if (!opts.musicSet) notes.push("No music track is set. A film with no voice is carried by its music: choose one and pull it with `reelkit assets pull <id> --music` (see reference/launch-film.md).");
|
|
98
|
+
if (!uncertain && placed < opts.seconds / 2) {
|
|
99
|
+
notes.push(`The code places ${placed} sound cue${placed === 1 ? "" : "s"} (Sfx elements and SoundCues entries) in ${opts.seconds.toFixed(1)}s of film, fewer than one per two seconds. This is a count of what the composition places, not a listening test; a film with no voice usually has about one placed effect per second on top of the music (see reference/launch-film.md).`);
|
|
100
|
+
}
|
|
101
|
+
const startsAtOnce = counts.some((c) => c.music || c.early);
|
|
102
|
+
if (!startsAtOnce && !uncertain && !counts.some((c) => c.unknownStart)) {
|
|
103
|
+
notes.push("Nothing is placed in the first half second: the code has no Music, and no Sfx or SoundCues cue that starts there. This is a count of what the composition places, not a listening test; start a sound on the first frame (see reference/launch-film.md).");
|
|
104
|
+
}
|
|
105
|
+
return notes;
|
|
106
|
+
}
|
|
@@ -0,0 +1,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.
|