reelkit-cli 0.6.0 → 0.10.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 +5 -3
- package/package.json +52 -9
- package/skill/SKILL.md +40 -19
- package/skill/THIRD_PARTY.md +104 -2
- package/skill/commands/launch-film.md +7 -0
- package/skill/reference/art-styles.md +70 -0
- package/skill/reference/asset-reuse.md +13 -2
- package/skill/reference/backgrounds.md +63 -0
- package/skill/reference/beat-sync.md +25 -19
- package/skill/reference/brand-motion.md +62 -0
- package/skill/reference/captions.md +11 -5
- package/skill/reference/clips.md +3 -3
- package/skill/reference/component-authoring.md +1 -1
- package/skill/reference/continuity.md +26 -7
- package/skill/reference/delivery-review.md +40 -0
- package/skill/reference/hebrew-rtl.md +3 -4
- package/skill/reference/{remotion-composition.md → hyperframes-composition.md} +18 -11
- package/skill/reference/kit.md +144 -12
- package/skill/reference/launch-film.md +194 -0
- package/skill/reference/motion-design.md +18 -16
- package/skill/reference/scene-treatments.md +20 -0
- package/skill/reference/scriptwriting.md +4 -1
- package/skill/reference/sound-design.md +34 -12
- package/skill/reference/studio-editing.md +55 -0
- package/skill/reference/styles.md +9 -6
- package/skill/reference/three-d.md +135 -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 +18 -7
- package/src/commands/assets.ts +314 -30
- package/src/commands/build.ts +148 -35
- package/src/commands/init.ts +1 -1
- 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 +5 -3
- package/src/hyperframes/Root.tsx +1 -0
- package/src/hyperframes/fonts.ts +54 -0
- package/src/hyperframes/frame.tsx +46 -0
- package/src/hyperframes/host.tsx +38 -0
- package/src/hyperframes/kit/Assemble3D.tsx +92 -0
- package/src/hyperframes/kit/BrandTransform3D.tsx +12 -0
- package/src/hyperframes/kit/BrowserFrame.tsx +83 -0
- package/src/{remotion → hyperframes}/kit/Camera.tsx +7 -5
- package/src/{remotion → hyperframes}/kit/Captions.tsx +34 -18
- package/src/hyperframes/kit/Card3D.tsx +211 -0
- package/src/{remotion → hyperframes}/kit/Carry.tsx +1 -1
- package/src/hyperframes/kit/ChapterFrame.tsx +68 -0
- package/src/{remotion → hyperframes}/kit/ClipLayer.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/Counter.tsx +1 -1
- package/src/hyperframes/kit/CounterRoll.tsx +75 -0
- package/src/{remotion → hyperframes}/kit/Entrance.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/FootageLayer.tsx +1 -1
- package/src/hyperframes/kit/GlassPanel.tsx +43 -0
- package/src/hyperframes/kit/Grounds.tsx +177 -0
- package/src/hyperframes/kit/Headline.tsx +97 -0
- package/src/hyperframes/kit/Hero3D.tsx +197 -0
- package/src/hyperframes/kit/HudOverlay.tsx +52 -0
- package/src/hyperframes/kit/ImageLayers.tsx +48 -0
- package/src/{remotion → hyperframes}/kit/KenBurnsImage.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/KeyedClip.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/Layers.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/LowerThird.tsx +1 -1
- package/src/hyperframes/kit/Music.tsx +19 -0
- package/src/hyperframes/kit/NamedCursor.tsx +54 -0
- package/src/hyperframes/kit/Orbit3D.tsx +49 -0
- package/src/hyperframes/kit/Particles3D.tsx +74 -0
- package/src/hyperframes/kit/Place.tsx +12 -0
- package/src/hyperframes/kit/PromptBox.tsx +84 -0
- package/src/hyperframes/kit/Scene3D.tsx +70 -0
- package/src/hyperframes/kit/SceneFrame.tsx +96 -0
- package/src/{remotion → hyperframes}/kit/ScreenOverlay.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/Sfx.tsx +1 -1
- package/src/hyperframes/kit/SoundCues.tsx +22 -0
- package/src/hyperframes/kit/TerminalLog.tsx +98 -0
- package/src/hyperframes/kit/Text3D.tsx +78 -0
- package/src/hyperframes/kit/TextOnImage.tsx +41 -0
- package/src/{remotion → hyperframes}/kit/TitleCard.tsx +1 -1
- package/src/{remotion → hyperframes}/kit/Voiceover.tsx +1 -1
- package/src/hyperframes/kit/Warp3D.tsx +59 -0
- package/src/hyperframes/kit/bg-math.ts +179 -0
- package/src/hyperframes/kit/brand-transform.ts +25 -0
- package/src/{remotion → hyperframes}/kit/caption-groups.ts +7 -3
- package/src/hyperframes/kit/caption-style.ts +45 -0
- package/src/hyperframes/kit/docs.ts +249 -0
- package/src/hyperframes/kit/image-layers-math.ts +115 -0
- package/src/hyperframes/kit/index.ts +74 -0
- package/src/hyperframes/kit/inter-bold-typeface.ts +3 -0
- package/src/{remotion → hyperframes}/kit/motion-math.ts +36 -2
- package/src/hyperframes/kit/music-math.ts +59 -0
- package/src/hyperframes/kit/quiet-three.ts +11 -0
- package/src/hyperframes/kit/sample-text.ts +55 -0
- package/src/hyperframes/kit/scene3d-context.ts +5 -0
- package/src/hyperframes/kit/seeded.ts +13 -0
- package/src/hyperframes/kit/sound-cues.ts +89 -0
- package/src/hyperframes/kit/sound-kinds.ts +135 -0
- package/src/{remotion → hyperframes}/kit/theme.ts +43 -39
- package/src/hyperframes/kit/three-fx-math.ts +192 -0
- package/src/hyperframes/kit/three-math.ts +145 -0
- package/src/hyperframes/kit/transition-math.ts +116 -0
- package/src/hyperframes/kit/ui-math.ts +145 -0
- package/src/hyperframes/kit/ui-theme.ts +25 -0
- package/src/hyperframes/kit/word-anchor.ts +107 -0
- package/src/hyperframes/math.ts +62 -0
- package/src/hyperframes/three.tsx +10 -0
- package/src/pipeline/beatsnap.ts +72 -0
- package/src/pipeline/review.ts +44 -10
- package/src/pipeline/schema.ts +51 -4
- package/src/pipeline/timing.ts +27 -1
- package/src/project/background.ts +33 -0
- package/src/project/chromakey.ts +1 -1
- package/src/project/layers.ts +60 -0
- package/src/project/manifest.ts +59 -14
- package/src/project/music.ts +19 -5
- package/src/project/project.ts +4 -1
- package/src/project/serve.ts +2 -2
- package/src/project/soundreport.ts +347 -0
- package/src/project/svgcheck.ts +21 -0
- package/src/render/component-preview.ts +11 -55
- 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/render.ts +62 -57
- package/src/render/serve.ts +31 -0
- package/src/render/sound-notes.ts +106 -0
- package/src/render/static-check.ts +15 -4
- package/src/render/validate.ts +4 -4
- package/src/render/word-check.ts +181 -0
- package/src/render/worker.ts +71 -0
- package/src/testing/conformance.ts +12 -0
- package/src/testing/fake-api.ts +4 -4
- package/src/testing/fixtures.ts +4 -1
- package/src/remotion/Root.tsx +0 -31
- package/src/remotion/kit/Music.tsx +0 -19
- package/src/remotion/kit/SceneFrame.tsx +0 -19
- package/src/remotion/kit/docs.ts +0 -124
- package/src/remotion/kit/index.ts +0 -29
- package/src/remotion/kit/music-math.ts +0 -42
- /package/src/{remotion → hyperframes}/kit/Icon.tsx +0 -0
- /package/src/{remotion → hyperframes}/kit/beat.ts +0 -0
- /package/src/{remotion → hyperframes}/kit/brand-icons.ts +0 -0
- /package/src/{remotion → hyperframes}/kit/media.ts +0 -0
- /package/src/{remotion → hyperframes}/types.ts +0 -0
package/src/render/render.ts
CHANGED
|
@@ -1,74 +1,79 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { cp, mkdir, mkdtemp, rm, writeFile, readdir, readFile } from "node:fs/promises";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join, basename } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { build } from "esbuild";
|
|
8
|
+
import type { VideoProps } from "../hyperframes/types";
|
|
9
|
+
import { fontFamilies } from "../hyperframes/fonts";
|
|
6
10
|
import { packageDir } from "./deps";
|
|
7
|
-
import { KIT_INDEX,
|
|
11
|
+
import { KIT_INDEX, PKG_ROOT } from "./validate";
|
|
8
12
|
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
registerRoot(makeRoot(Video));
|
|
14
|
-
`;
|
|
15
|
-
|
|
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).
|
|
13
|
+
const run = promisify(execFile);
|
|
14
|
+
const HOST = join(PKG_ROOT, "src/hyperframes/host.tsx");
|
|
15
|
+
const FRAME = join(PKG_ROOT, "src/hyperframes/frame.tsx");
|
|
18
16
|
export function dependencyAliases(): Record<string, string> {
|
|
19
|
-
return Object.fromEntries(["react", "react-dom", "
|
|
17
|
+
return Object.fromEntries(["react", "react-dom", "three", "@react-three/fiber"].map((name) => [name, packageDir(name)]));
|
|
20
18
|
}
|
|
21
|
-
|
|
22
|
-
// Writes the bundler's entry file into the project. The composition itself stays where the user's Claude wrote it.
|
|
23
19
|
export async function writeEntry(projectDir: string): Promise<string> {
|
|
24
20
|
const dir = join(projectDir, ".reelkit", "build");
|
|
25
|
-
await rm(dir, { recursive: true, force: true });
|
|
26
|
-
await mkdir(dir, { recursive: true });
|
|
21
|
+
await rm(dir, { recursive: true, force: true }); await mkdir(dir, { recursive: true });
|
|
27
22
|
const entry = join(dir, "index.ts");
|
|
28
|
-
await writeFile(entry,
|
|
23
|
+
await writeFile(entry, 'import { mountVideo } from "reelkit/host";\nimport { Video } from "../../src/Video";\nmountVideo(Video);\n');
|
|
29
24
|
return entry;
|
|
30
25
|
}
|
|
31
|
-
|
|
32
|
-
// Webpack's on-disk cache would only grow, by tens of megabytes per render. It is switched off, and each bundle
|
|
33
|
-
// is deleted by disposeBundle once it has been used.
|
|
26
|
+
// A self-contained HyperFrames page using the package's copies of React and Three.
|
|
34
27
|
export async function bundleProject(entry: string): Promise<string> {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
},
|
|
44
|
-
}
|
|
45
|
-
|
|
28
|
+
const dir = await mkdtemp(join(tmpdir(), "reelkit-hyperframes-"));
|
|
29
|
+
try {
|
|
30
|
+
await build({ entryPoints: [entry], bundle: true, outfile: join(dir, "composition.js"), platform: "browser", format: "iife", jsx: "automatic", logLevel: "silent", loader: { ".woff2": "file", ".woff": "file", ".ttf": "file" }, define: { "process.env.NODE_ENV": '"production"' }, alias: { ...dependencyAliases(), "reelkit/kit": KIT_INDEX, "reelkit/frame": FRAME, "reelkit/host": HOST } });
|
|
31
|
+
await mkdir(join(dir, "fonts"));
|
|
32
|
+
for (const slug of Object.values(fontFamilies)) {
|
|
33
|
+
const source = packageDir(`@fontsource/${slug}`);
|
|
34
|
+
await cp(join(source, "files"), join(dir, "fonts", "files"), { recursive: true });
|
|
35
|
+
const sheets = (await readdir(source)).filter((name) => /^\d+(?:-italic)?\.css$/.test(name));
|
|
36
|
+
await writeFile(join(dir, "fonts", `${slug}.css`), (await Promise.all(sheets.map((name) => readFile(join(source, name), "utf8")))).join("\n"));
|
|
37
|
+
}
|
|
38
|
+
await writeFile(join(dir, "index.html"), '<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;overflow:hidden}#stage{position:relative;overflow:hidden}</style></head><body><div id="stage"></div><script src="props.js"></script><script src="composition.js"></script></body></html>');
|
|
39
|
+
return dir;
|
|
40
|
+
} catch (error) { await rm(dir, { recursive: true, force: true }); throw error; }
|
|
46
41
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
// Only ever remove a folder that is one of Remotion's own temporary bundles.
|
|
50
|
-
if (/remotion-webpack-bundle-/.test(serveUrl)) await rm(serveUrl, { recursive: true, force: true });
|
|
42
|
+
export async function disposeBundle(dir: string): Promise<void> {
|
|
43
|
+
if (dirname(dir) === tmpdir() && basename(dir).startsWith("reelkit-hyperframes-")) await rm(dir, { recursive: true, force: true });
|
|
51
44
|
}
|
|
52
|
-
|
|
53
|
-
// Makes sure Remotion's headless browser is installed. onDownload is called once, only when a download is about to begin.
|
|
54
|
-
// Remotion's own progress bar is replaced because it writes to stdout, which --json keeps for the result alone.
|
|
55
45
|
export type EnsureBrowser = (onDownload: () => void) => Promise<void>;
|
|
46
|
+
export const GL_BACKEND = "software" as const;
|
|
47
|
+
export type RenderOptions = { uses3D?: boolean; silent?: boolean; crf?: number };
|
|
48
|
+
export function browserPath(): string | undefined {
|
|
49
|
+
if (process.env.REELKIT_CHROME_PATH) return process.env.REELKIT_CHROME_PATH;
|
|
50
|
+
const paths = process.platform === "darwin" ? ["/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium"] : process.platform === "linux" ? ["/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome"] : [];
|
|
51
|
+
return paths.find(existsSync);
|
|
52
|
+
}
|
|
56
53
|
export const ensureRenderBrowser: EnsureBrowser = async (onDownload) => {
|
|
57
|
-
|
|
54
|
+
if (browserPath()) return;
|
|
55
|
+
const { resolveHeadlessShellPath } = await import("@hyperframes/engine");
|
|
56
|
+
const cached = resolveHeadlessShellPath({}) ?? resolveHeadlessShellPath({ chromePath: undefined });
|
|
57
|
+
if (cached) { process.env.REELKIT_CHROME_PATH = cached; return; }
|
|
58
|
+
const { install, Browser, resolveBuildId, detectBrowserPlatform, getInstalledBrowsers } = await import("@puppeteer/browsers");
|
|
59
|
+
const installed = (await getInstalledBrowsers({ cacheDir: join(PKG_ROOT, ".browser") })).find((b) => b.browser === Browser.CHROMEHEADLESSSHELL && existsSync(b.executablePath));
|
|
60
|
+
if (installed) { process.env.REELKIT_CHROME_PATH = installed.executablePath; return; }
|
|
61
|
+
const platform = detectBrowserPlatform(); if (!platform) throw new Error("No supported Chrome platform. Set REELKIT_CHROME_PATH.");
|
|
62
|
+
onDownload();
|
|
63
|
+
const browser = await install({ browser: Browser.CHROMEHEADLESSSHELL, buildId: await resolveBuildId(Browser.CHROMEHEADLESSSHELL, platform, "stable"), cacheDir: join(PKG_ROOT, ".browser"), platform });
|
|
64
|
+
process.env.REELKIT_CHROME_PATH = browser.executablePath;
|
|
58
65
|
};
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
await renderStill({ composition, serveUrl, frame, output: join(outDir, `still-${frame}.png`), inputProps: asInput(props) });
|
|
67
|
-
}
|
|
66
|
+
async function render(dir: string, props: VideoProps, mode: "stills" | "video", output: string, frames: number[], options?: RenderOptions) {
|
|
67
|
+
await ensureRenderBrowser(() => {});
|
|
68
|
+
await writeFile(join(dir, "props.js"), `window.__reelkitProps=${JSON.stringify(props).replace(/</g, "\\u003c")};`);
|
|
69
|
+
// Isolate engine diagnostics from the CLI's JSON stdout.
|
|
70
|
+
const request = join(dir, "render-request.json");
|
|
71
|
+
await writeFile(request, JSON.stringify({ dir, props, mode, output, frames, options, chromePath: browserPath() }));
|
|
72
|
+
await run(process.execPath, ["--import", join(packageDir("tsx"), "dist/loader.mjs"), join(PKG_ROOT, "src/render/worker.ts"), request], { maxBuffer: 16 * 1024 * 1024 });
|
|
68
73
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
await
|
|
74
|
+
export async function renderStills(dir: string, props: VideoProps, frames: number[], outDir: string, options?: RenderOptions) {
|
|
75
|
+
await mkdir(outDir, { recursive: true }); await render(dir, props, "stills", outDir, frames, options);
|
|
76
|
+
}
|
|
77
|
+
export async function renderVideo(dir: string, props: VideoProps, outPath: string, options?: RenderOptions) {
|
|
78
|
+
await mkdir(dirname(outPath), { recursive: true }); await render(dir, props, "video", outPath, [], options);
|
|
74
79
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createReadStream, statSync } from "node:fs";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import type { AddressInfo } from "node:net";
|
|
5
|
+
import { extname, resolve, sep } from "node:path";
|
|
6
|
+
|
|
7
|
+
const TYPES: Record<string, string> = {
|
|
8
|
+
".html": "text/html; charset=utf-8", ".js": "application/javascript", ".css": "text/css",
|
|
9
|
+
".json": "application/json", ".woff2": "font/woff2", ".woff": "font/woff", ".ttf": "font/ttf",
|
|
10
|
+
".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Serve the generated composition only to localhost, under an unguessable per-render prefix.
|
|
14
|
+
// The generic HyperFrames file server listens on all interfaces; project copy and media URLs stay private here.
|
|
15
|
+
export async function serveComposition(dir: string): Promise<{ url: string; close(): Promise<void> }> {
|
|
16
|
+
const root = resolve(dir), prefix = `/${randomBytes(16).toString("hex")}/`;
|
|
17
|
+
const server = createServer((req, res) => {
|
|
18
|
+
let path: string;
|
|
19
|
+
try { path = decodeURIComponent((req.url ?? "").split("?")[0]!); } catch { res.writeHead(400).end(); return; }
|
|
20
|
+
if (!path.startsWith(prefix)) { res.writeHead(404).end(); return; }
|
|
21
|
+
const file = resolve(root, path.slice(prefix.length) || "index.html");
|
|
22
|
+
try {
|
|
23
|
+
if (!file.startsWith(root + sep) || !statSync(file).isFile()) { res.writeHead(404).end(); return; }
|
|
24
|
+
res.writeHead(200, { "content-type": TYPES[extname(file)] ?? "application/octet-stream", "cache-control": "no-store" });
|
|
25
|
+
const stream = createReadStream(file); stream.on("error", () => res.destroy()); stream.pipe(res);
|
|
26
|
+
} catch { res.writeHead(404).end(); }
|
|
27
|
+
});
|
|
28
|
+
await new Promise<void>((ready, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", ready); });
|
|
29
|
+
const port = (server.address() as AddressInfo).port;
|
|
30
|
+
return { url: `http://127.0.0.1:${port}${prefix}`, close: () => new Promise<void>((ready) => { server.closeAllConnections(); server.close(() => ready()); }) };
|
|
31
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -7,8 +7,19 @@ export const MAIN_FILE = "Video.tsx";
|
|
|
7
7
|
export const FILE_NAME = /^[A-Z][A-Za-z0-9]*\.tsx$/;
|
|
8
8
|
export const componentName = (file: string) => file.replace(/\.tsx$/, "");
|
|
9
9
|
|
|
10
|
+
// Upgrade previously published library components without touching on-screen copy or comments.
|
|
11
|
+
export function migrateFrameImports(source: string): string {
|
|
12
|
+
const parsed = sourceFile(source);
|
|
13
|
+
const edits: { start: number; end: number }[] = [];
|
|
14
|
+
for (const node of parsed.statements) {
|
|
15
|
+
if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "remotion") edits.push({ start: node.moduleSpecifier.getStart(parsed), end: node.moduleSpecifier.getEnd() });
|
|
16
|
+
}
|
|
17
|
+
for (const edit of edits.reverse()) source = source.slice(0, edit.start) + '"reelkit/frame"' + source.slice(edit.end);
|
|
18
|
+
return source;
|
|
19
|
+
}
|
|
20
|
+
|
|
10
21
|
export const KIT = "reelkit/kit";
|
|
11
|
-
const BASE_IMPORTS = new Set(["react", "
|
|
22
|
+
const BASE_IMPORTS = new Set(["react", "reelkit/frame", KIT]);
|
|
12
23
|
|
|
13
24
|
// This check keeps honest compositions deterministic and self-contained. It is NOT a security boundary:
|
|
14
25
|
// 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.
|
|
@@ -77,7 +88,7 @@ function inspectSource(source: string, file?: string): Finding {
|
|
|
77
88
|
return Boolean(x && ts.isIdentifier(x) && x.text === name && !declared(x));
|
|
78
89
|
};
|
|
79
90
|
const flagMember = (object: ts.Expression | undefined, member: string | undefined) => {
|
|
80
|
-
if (isGlobal(object, "Math") && member === "random") forbidden.add("Math.random (use random(seed) from
|
|
91
|
+
if (isGlobal(object, "Math") && member === "random") forbidden.add("Math.random (use random(seed) from reelkit/frame)");
|
|
81
92
|
if (isGlobal(object, "Date") && member === "now") forbidden.add("the clock (Date)");
|
|
82
93
|
};
|
|
83
94
|
// The key a destructuring element reads: `random` in `{ random }` and in `{ random: r }`, "random" in `{ ["random"]: r }`.
|
|
@@ -140,8 +151,8 @@ export function staticCheck(source: string, file: string, siblings: string[]): s
|
|
|
140
151
|
if (BASE_IMPORTS.has(s) || allowedSiblings.has(s)) continue;
|
|
141
152
|
errors.push(
|
|
142
153
|
isMain
|
|
143
|
-
? `${file}: import not allowed: "${s}". Use react,
|
|
144
|
-
: `${file}: import not allowed: "${s}". A component may only import react,
|
|
154
|
+
? `${file}: import not allowed: "${s}". Use react, reelkit/frame, ${KIT}, or a component file that exists ("./Name").`
|
|
155
|
+
: `${file}: import not allowed: "${s}". A component may only import react, reelkit/frame and ${KIT}.`,
|
|
145
156
|
);
|
|
146
157
|
}
|
|
147
158
|
for (const name of found.forbidden) errors.push(`${file}: not allowed: ${name}.`);
|
package/src/render/validate.ts
CHANGED
|
@@ -4,15 +4,15 @@ import ts from "typescript";
|
|
|
4
4
|
import { packageDir } from "./deps";
|
|
5
5
|
|
|
6
6
|
export const PKG_ROOT = fileURLToPath(new URL("../..", import.meta.url));
|
|
7
|
-
export const KIT_INDEX = join(PKG_ROOT, "src", "
|
|
8
|
-
export const ROOT_FILE = join(PKG_ROOT, "src", "
|
|
7
|
+
export const KIT_INDEX = join(PKG_ROOT, "src", "hyperframes", "kit", "index.ts");
|
|
8
|
+
export const ROOT_FILE = join(PKG_ROOT, "src", "hyperframes", "Root.tsx");
|
|
9
9
|
|
|
10
10
|
// The static check lives in its own module, which a server can bundle; everything it exports is available from here as before.
|
|
11
11
|
export { FILE_NAME, MAIN_FILE, componentName, importSpecifiers, staticCheck } from "./static-check";
|
|
12
12
|
import { KIT, sourceFile, unwrap } from "./static-check";
|
|
13
13
|
|
|
14
14
|
const typesReact = packageDir("@types/react");
|
|
15
|
-
export const
|
|
15
|
+
export const frameFile = join(PKG_ROOT, "src", "hyperframes", "frame.tsx");
|
|
16
16
|
|
|
17
17
|
export function typecheck(files: string[]): string[] {
|
|
18
18
|
const program = ts.createProgram(files, {
|
|
@@ -30,7 +30,7 @@ export function typecheck(files: string[]): string[] {
|
|
|
30
30
|
[KIT]: [KIT_INDEX],
|
|
31
31
|
react: [typesReact],
|
|
32
32
|
"react/*": [join(typesReact, "*")],
|
|
33
|
-
|
|
33
|
+
"reelkit/frame": [frameFile],
|
|
34
34
|
},
|
|
35
35
|
});
|
|
36
36
|
const wanted = new Set(files);
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// What a composition's source says statically about the spoken words it is timed to: which helper calls name a word, in which scene, and whether
|
|
2
|
+
// the word is in that scene's narration. Pure text in, findings out (the TypeScript syntax tree, never regular expressions on code), so `check`
|
|
3
|
+
// and `preview` can both use it.
|
|
4
|
+
import ts from "typescript";
|
|
5
|
+
import { normalizeWord, wordFrame, type WordScene } from "../hyperframes/kit/word-anchor";
|
|
6
|
+
import { sourceFile, unwrap } from "./static-check";
|
|
7
|
+
|
|
8
|
+
export const WORD_HELPERS = ["wordFrame", "wordFrames", "onWord", "onWordBeat"] as const;
|
|
9
|
+
const isHelper = (n: string): n is (typeof WORD_HELPERS)[number] => (WORD_HELPERS as readonly string[]).includes(n);
|
|
10
|
+
|
|
11
|
+
export type WordCall = {
|
|
12
|
+
fn: (typeof WORD_HELPERS)[number];
|
|
13
|
+
// The scene, when the call's first argument is a scene found by a literal id (sceneById(manifest, "id"), or manifest.scenes[2], or S.id and S["id"]).
|
|
14
|
+
sceneId?: string;
|
|
15
|
+
// One entry per literal word the call names; a word that is not a literal is left out.
|
|
16
|
+
words: { word: string; nth?: number }[];
|
|
17
|
+
line: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type PlanScene = { id: string; narration: string };
|
|
21
|
+
|
|
22
|
+
const literalText = (e: ts.Expression | undefined): string | undefined => {
|
|
23
|
+
const x = e && unwrap(e);
|
|
24
|
+
return x && ts.isStringLiteralLike(x) ? x.text : undefined;
|
|
25
|
+
};
|
|
26
|
+
const literalNumber = (e: ts.Expression | undefined): number | undefined => {
|
|
27
|
+
const x = e && unwrap(e);
|
|
28
|
+
return x && ts.isNumericLiteral(x) ? Number(x.text) : undefined;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Where a source says `<something>.scenes`: the manifest's list of scenes, however the manifest is called.
|
|
32
|
+
const isScenesList = (e: ts.Expression): boolean => { const x = unwrap(e); return ts.isPropertyAccessExpression(x) && x.name.text === "scenes"; };
|
|
33
|
+
|
|
34
|
+
// Calls of the word helpers in `source`. `ids` are the plan's scene ids in order, so that a scene reached by position or by `S.hook` can be named.
|
|
35
|
+
export function wordCalls(source: string, ids: string[], file = "Video.tsx"): WordCall[] {
|
|
36
|
+
const sf = sourceFile(source, file);
|
|
37
|
+
// Every `const name = expression` and `const [a, b] = list` in the file, flat: the names a composition gives its scenes are not scoped carefully.
|
|
38
|
+
const consts = new Map<string, ts.Expression>();
|
|
39
|
+
const positions = new Map<string, number>();
|
|
40
|
+
const visitDecl = (node: ts.Node) => {
|
|
41
|
+
if (ts.isVariableDeclaration(node) && node.initializer) {
|
|
42
|
+
if (ts.isIdentifier(node.name)) consts.set(node.name.text, node.initializer);
|
|
43
|
+
else if (ts.isArrayBindingPattern(node.name) && isScenesList(node.initializer)) {
|
|
44
|
+
node.name.elements.forEach((el, i) => { if (ts.isBindingElement(el) && ts.isIdentifier(el.name)) positions.set(el.name.text, i); });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
ts.forEachChild(node, visitDecl);
|
|
48
|
+
};
|
|
49
|
+
visitDecl(sf);
|
|
50
|
+
|
|
51
|
+
const resolve = (e: ts.Expression, depth = 0): string | undefined => {
|
|
52
|
+
if (depth > 6) return undefined;
|
|
53
|
+
const x = unwrap(e);
|
|
54
|
+
if (ts.isCallExpression(x) && ts.isIdentifier(x.expression) && x.expression.text === "sceneById") {
|
|
55
|
+
const id = literalText(x.arguments[1]);
|
|
56
|
+
return id !== undefined && ids.includes(id) ? id : undefined;
|
|
57
|
+
}
|
|
58
|
+
if (ts.isIdentifier(x)) {
|
|
59
|
+
const pos = positions.get(x.text);
|
|
60
|
+
if (pos !== undefined) return ids[pos];
|
|
61
|
+
const init = consts.get(x.text);
|
|
62
|
+
return init ? resolve(init, depth + 1) : undefined;
|
|
63
|
+
}
|
|
64
|
+
if (ts.isElementAccessExpression(x) && isScenesList(x.expression)) {
|
|
65
|
+
const i = literalNumber(x.argumentExpression);
|
|
66
|
+
return i !== undefined ? ids[i] : undefined;
|
|
67
|
+
}
|
|
68
|
+
// A record of scenes by id: `const S = Object.fromEntries(manifest.scenes.map(...))`, then S.hook or S["hook"].
|
|
69
|
+
if (ts.isPropertyAccessExpression(x) || ts.isElementAccessExpression(x)) {
|
|
70
|
+
const key = ts.isPropertyAccessExpression(x) ? x.name.text : literalText(x.argumentExpression);
|
|
71
|
+
const base = unwrap(x.expression);
|
|
72
|
+
if (key !== undefined && ids.includes(key) && ts.isIdentifier(base)) {
|
|
73
|
+
const init = consts.get(base.text);
|
|
74
|
+
if (init && init.getText(sf).includes("scenes")) return key;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const out: WordCall[] = [];
|
|
81
|
+
const visit = (node: ts.Node) => {
|
|
82
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && isHelper(node.expression.text)) {
|
|
83
|
+
const fn = node.expression.text;
|
|
84
|
+
const [scene, arg] = node.arguments;
|
|
85
|
+
const options = node.arguments.slice(2).map((a) => unwrap(a)).find((a): a is ts.ObjectLiteralExpression => ts.isObjectLiteralExpression(a));
|
|
86
|
+
const nthProp = options?.properties.find((p): p is ts.PropertyAssignment => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === "nth");
|
|
87
|
+
const nth = literalNumber(nthProp?.initializer);
|
|
88
|
+
const named: string[] = [];
|
|
89
|
+
if (arg) {
|
|
90
|
+
const single = literalText(arg);
|
|
91
|
+
if (fn === "wordFrames") { const list = unwrap(arg); if (ts.isArrayLiteralExpression(list)) for (const el of list.elements) { const t = literalText(el); if (t !== undefined) named.push(t); } }
|
|
92
|
+
else if (single !== undefined) named.push(single);
|
|
93
|
+
}
|
|
94
|
+
out.push({
|
|
95
|
+
fn, ...(scene ? (() => { const id = resolve(scene); return id !== undefined ? { sceneId: id } : {}; })() : {}),
|
|
96
|
+
words: named.map((word) => ({ word, ...(nth !== undefined ? { nth } : {}) })),
|
|
97
|
+
line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
ts.forEachChild(node, visit);
|
|
101
|
+
};
|
|
102
|
+
visit(sf);
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Whether this narration says `word` (a word or a phrase of consecutive words, ignoring case and punctuation) at least `nth` times.
|
|
107
|
+
export function narrationHas(narration: string, word: string, nth = 1): boolean {
|
|
108
|
+
const parts = word.split(/\s+/).map(normalizeWord).filter(Boolean);
|
|
109
|
+
if (!parts.length) return false;
|
|
110
|
+
const list = narration.split(/\s+/).map(normalizeWord).filter(Boolean);
|
|
111
|
+
let seen = 0;
|
|
112
|
+
for (let i = 0; i + parts.length <= list.length; i++) if (parts.every((p, k) => list[i + k] === p) && ++seen >= nth) return true;
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// The errors: a literal word that the narration of a literally named scene does not contain.
|
|
117
|
+
export function wordErrors(file: string, calls: WordCall[], scenes: PlanScene[]): string[] {
|
|
118
|
+
const errors: string[] = [];
|
|
119
|
+
for (const c of calls) {
|
|
120
|
+
const scene = c.sceneId ? scenes.find((s) => s.id === c.sceneId) : undefined;
|
|
121
|
+
if (!scene) continue;
|
|
122
|
+
for (const w of c.words) {
|
|
123
|
+
if (narrationHas(scene.narration, w.word, w.nth ?? 1)) continue;
|
|
124
|
+
errors.push(`${file} line ${c.line}: ${c.fn}(... "${w.word}"${w.nth && w.nth > 1 ? `, { nth: ${w.nth} }` : ""}) names a word that scene ${scene.id} never says. Its narration is: "${scene.narration}". The render would stop on it; use a word from the narration.`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return errors;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// True when the file times anything to a spoken word: it calls a word helper, or reads a scene's `.words` for something other than the captions.
|
|
131
|
+
export function usesWordTiming(source: string, file = "Video.tsx"): boolean {
|
|
132
|
+
const sf = sourceFile(source, file);
|
|
133
|
+
let used = false;
|
|
134
|
+
const visit = (node: ts.Node) => {
|
|
135
|
+
if (used) return;
|
|
136
|
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && isHelper(node.expression.text)) { used = true; return; }
|
|
137
|
+
if ((ts.isPropertyAccessExpression(node) && node.name.text === "words") || (ts.isElementAccessExpression(node) && literalText(node.argumentExpression) === "words")) {
|
|
138
|
+
// `words={s.words}` on <Captions> is not timing: it is the captions' own input.
|
|
139
|
+
let p: ts.Node | undefined = node.parent;
|
|
140
|
+
while (p && (ts.isParenthesizedExpression(p) || ts.isAsExpression(p) || ts.isNonNullExpression(p))) p = p.parent;
|
|
141
|
+
const inCaptions = p && ts.isJsxExpression(p) && ts.isJsxAttribute(p.parent) && p.parent.name.getText(sf) === "words"
|
|
142
|
+
&& (() => { let q: ts.Node | undefined = p.parent.parent.parent; return Boolean(q && (ts.isJsxSelfClosingElement(q) || ts.isJsxOpeningElement(q)) && q.tagName.getText(sf) === "Captions"); })();
|
|
143
|
+
if (!inCaptions) used = true;
|
|
144
|
+
}
|
|
145
|
+
ts.forEachChild(node, visit);
|
|
146
|
+
};
|
|
147
|
+
visit(sf);
|
|
148
|
+
return used;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// How many frames after a word starts the preview looks: the thing the word names should already be there.
|
|
152
|
+
export const WORD_FRAME_AFTER = 4;
|
|
153
|
+
// At most this many word frames go on the contact sheet.
|
|
154
|
+
export const MAX_WORD_FRAMES = 16;
|
|
155
|
+
|
|
156
|
+
export type WordTarget = { sceneId: string; word: string; nth?: number; wordStart: number; frame: number; label: string };
|
|
157
|
+
|
|
158
|
+
// The words the preview can look at: every literal word of a helper call whose scene is known, as the frame 4 frames after the word starts (kept
|
|
159
|
+
// inside its scene), in film order, without repeats, at most `cap`. `skipped` counts what could not be shown: a word or a scene that is not a
|
|
160
|
+
// literal (the code does not say which), and what was left out past the cap.
|
|
161
|
+
export function wordTargets(calls: WordCall[], manifest: { scenes: (WordScene & { id: string; durationFrames: number })[] }, cap = MAX_WORD_FRAMES): { targets: WordTarget[]; skipped: number; undecidable: number; capped: number } {
|
|
162
|
+
const seen = new Set<string>();
|
|
163
|
+
const found: WordTarget[] = [];
|
|
164
|
+
let undecidable = 0;
|
|
165
|
+
for (const c of calls) {
|
|
166
|
+
const scene = c.sceneId ? manifest.scenes.find((s) => s.id === c.sceneId) : undefined;
|
|
167
|
+
if (!scene || !c.words.length) { undecidable += Math.max(1, c.words.length); continue; }
|
|
168
|
+
for (const w of c.words) {
|
|
169
|
+
const key = `${scene.id}|${normalizeWord(w.word) || w.word}|${w.nth ?? 1}`;
|
|
170
|
+
if (seen.has(key)) continue;
|
|
171
|
+
seen.add(key);
|
|
172
|
+
let start: number;
|
|
173
|
+
try { start = wordFrame(scene, w.word, { nth: w.nth, absolute: true }); } catch { undecidable++; continue; }
|
|
174
|
+
const last = scene.startFrame + scene.durationFrames - 1;
|
|
175
|
+
found.push({ sceneId: scene.id, word: w.word, ...(w.nth && w.nth > 1 ? { nth: w.nth } : {}), wordStart: start, frame: Math.min(start + WORD_FRAME_AFTER, last), label: `${w.word}${w.nth && w.nth > 1 ? ` #${w.nth}` : ""} \u25B8 ${scene.id}` });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
found.sort((a, b) => a.wordStart - b.wordStart);
|
|
179
|
+
const targets = found.slice(0, cap);
|
|
180
|
+
return { targets, skipped: undecidable + (found.length - targets.length), undecidable, capped: found.length - targets.length };
|
|
181
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Internal subprocess: HyperFrames owns capture, encoding and audio mixing.
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { cp, mkdir, mkdtemp, rm, writeFile, readFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, extname } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { createCaptureSession, initializeSession, captureFrame, closeCaptureSession, encodeFramesFromDir, processCompositionAudio, muxVideoWithAudio, applyFaststart, type AudioElement } from "@hyperframes/engine";
|
|
8
|
+
import { serveComposition } from "./serve";
|
|
9
|
+
import type { VideoProps } from "../hyperframes/types";
|
|
10
|
+
import type { AudioRecord } from "../hyperframes/frame";
|
|
11
|
+
import type { RenderOptions } from "./render";
|
|
12
|
+
|
|
13
|
+
const run = promisify(execFile);
|
|
14
|
+
const input = JSON.parse(await readFile(process.argv[2]!, "utf8")) as { dir: string; props: VideoProps; mode: "stills" | "video"; output: string; frames: number[]; options?: RenderOptions; chromePath?: string };
|
|
15
|
+
const m = input.props.manifest, fps = { num: m.fps, den: 1 };
|
|
16
|
+
const work = await mkdtemp(join(tmpdir(), "reelkit-capture-"));
|
|
17
|
+
const server = await serveComposition(input.dir);
|
|
18
|
+
let session: Awaited<ReturnType<typeof createCaptureSession>> | undefined;
|
|
19
|
+
try {
|
|
20
|
+
session = await createCaptureSession(server.url.replace(/\/$/, ""), work, { width: m.width, height: m.height, fps, format: "png", compositionDurationSeconds: m.totalFrames / m.fps }, async (page) => {
|
|
21
|
+
await page.evaluate(async () => { await (window as unknown as { __reelkitReady: Promise<void> }).__reelkitReady; });
|
|
22
|
+
}, { chromePath: input.chromePath, forceScreenshot: true, useDrawElement: false, staticFrameDedup: false, enableBrowserPool: false, browserGpuMode: input.options?.uses3D ? "software" : "auto" });
|
|
23
|
+
await initializeSession(session);
|
|
24
|
+
await session.page.evaluate(async () => { await (window as unknown as { __reelkitReady: Promise<void> }).__reelkitReady; });
|
|
25
|
+
const frames = input.mode === "stills" ? input.frames : Array.from({ length: m.totalFrames }, (_, i) => i);
|
|
26
|
+
for (const frame of frames) {
|
|
27
|
+
if (!Number.isInteger(frame) || frame < 0 || frame >= m.totalFrames) throw new Error(`Frame ${frame} is outside the composition.`);
|
|
28
|
+
const captured = await captureFrame(session, frame, frame / m.fps);
|
|
29
|
+
if (input.mode === "stills") await cp(captured.path, join(input.output, `still-${frame}.png`));
|
|
30
|
+
}
|
|
31
|
+
if (input.mode === "video") {
|
|
32
|
+
const silent = join(work, "silent.mp4");
|
|
33
|
+
const encoded = await encodeFramesFromDir(work, "frame_%06d.png", silent, { fps, width: m.width, height: m.height, codec: "h264", pixelFormat: "yuv420p", quality: input.options?.crf ?? 18, preset: "fast" });
|
|
34
|
+
if (!encoded.success) throw new Error(encoded.error ?? "HyperFrames encoding failed.");
|
|
35
|
+
const records = input.options?.silent ? [] : await session.page.evaluate(() => [...(window as unknown as { __reelkitAudio: Map<string, AudioRecord> }).__reelkitAudio.values()]);
|
|
36
|
+
const audio: AudioElement[] = [];
|
|
37
|
+
const localSources = new Map<string, string>();
|
|
38
|
+
for (const record of records) {
|
|
39
|
+
let src = localSources.get(record.src);
|
|
40
|
+
if (!src) {
|
|
41
|
+
const response = await fetch(record.src); if (!response.ok) throw new Error(`Audio source returned ${response.status}.`);
|
|
42
|
+
src = join(work, `source-${localSources.size}${extname(new URL(record.src).pathname) || ".media"}`);
|
|
43
|
+
await writeFile(src, new Uint8Array(await response.arrayBuffer())); localSources.set(record.src, src);
|
|
44
|
+
}
|
|
45
|
+
const info = JSON.parse((await run("ffprobe", ["-v", "error", "-show_format", "-of", "json", src])).stdout) as { format: { duration: string } };
|
|
46
|
+
let end = Math.min(record.end, record.start + Number(info.format.duration) - record.mediaStart);
|
|
47
|
+
if (record.loop) {
|
|
48
|
+
const looped = join(work, `loop-${audio.length}.wav`);
|
|
49
|
+
await run("ffmpeg", ["-y", "-v", "error", "-stream_loop", "-1", "-i", src, "-t", String(record.end - record.start + record.mediaStart), "-vn", looped]);
|
|
50
|
+
src = looped; end = record.end;
|
|
51
|
+
}
|
|
52
|
+
if (end <= record.start) continue;
|
|
53
|
+
const keys = [...new Map(record.volumeKeyframes.map((k) => [k.time, k])).values()].sort((a, b) => a.time - b.time);
|
|
54
|
+
audio.push({ ...record, src, end, volumeKeyframes: keys.length ? keys : undefined });
|
|
55
|
+
}
|
|
56
|
+
if (audio.length) {
|
|
57
|
+
const mixed = join(work, "audio.m4a");
|
|
58
|
+
const result = await processCompositionAudio(audio, work, join(work, "audio-work"), mixed, m.totalFrames / m.fps);
|
|
59
|
+
if (!result.success) throw new Error(result.error ?? "HyperFrames audio mixing failed.");
|
|
60
|
+
const muxed = await muxVideoWithAudio(silent, mixed, input.output, undefined, { audioCodec: "aac" });
|
|
61
|
+
if (!muxed.success) throw new Error(muxed.error ?? "HyperFrames audio mux failed.");
|
|
62
|
+
} else {
|
|
63
|
+
await mkdir(join(work, "faststart"));
|
|
64
|
+
const result = await applyFaststart(silent, input.output);
|
|
65
|
+
if (!result.success) throw new Error(result.error ?? "Faststart failed.");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
if (session) await closeCaptureSession(session);
|
|
70
|
+
await server.close(); await rm(work, { recursive: true, force: true });
|
|
71
|
+
}
|
|
@@ -356,6 +356,18 @@ export function runConformance(name: string, start: (opts: StartOptions) => Prom
|
|
|
356
356
|
expect((await api("me", {})).quota.images.used).toBe(2);
|
|
357
357
|
});
|
|
358
358
|
|
|
359
|
+
it("images: a request for format svg returns an svg, and a request with no format still returns a raster image", async () => {
|
|
360
|
+
const api = await as("user-gen00008");
|
|
361
|
+
const svg = await api("images", { prompt: "a red circle, transparent background", aspect: "1:1", shareable: false, tags: [], format: "svg" });
|
|
362
|
+
expect(svg.ext).toBe("svg");
|
|
363
|
+
expect(svg.contentType).toBe("image/svg+xml");
|
|
364
|
+
await download(svg.url, join(dest, "mark.svg"));
|
|
365
|
+
expect(readFileSync(join(dest, "mark.svg"), "utf8")).toMatch(/^\s*<svg/);
|
|
366
|
+
const raster = await api("images", { prompt: "a quiet lake", aspect: "16:9", shareable: false, tags: [] });
|
|
367
|
+
expect(raster.ext).not.toBe("svg");
|
|
368
|
+
expect((await api("me", {})).quota.images.used).toBe(2);
|
|
369
|
+
});
|
|
370
|
+
|
|
359
371
|
it("a shared image's title is the first 60 characters of the prompt, cut on a code point, never inside an emoji", async () => {
|
|
360
372
|
const api = await as("user-gen00007");
|
|
361
373
|
const prompt = "\u{1F30B}".repeat(70);
|