reelkit-cli 0.11.0 → 0.12.2
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/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/skill/SKILL.md +2 -1
- package/src/cli.ts +9 -0
- package/src/commands/build.ts +17 -1
- package/src/commands/template.ts +229 -0
- package/src/contract/index.ts +67 -0
- package/src/project/scene-components.ts +90 -0
- package/src/render/render.ts +3 -2
- package/src/testing/fake-api.ts +49 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
What changed in each published version of `reelkit-cli`, newest first. A push to `main` publishes the version in
|
|
4
4
|
`package.json` when it is not on npm yet, and it must have an entry here.
|
|
5
5
|
|
|
6
|
+
## 0.12.2 - 2026-10-09
|
|
7
|
+
|
|
8
|
+
- You can keep 50 templates (it was 20).
|
|
9
|
+
|
|
10
|
+
## 0.12.1 - 2026-10-09
|
|
11
|
+
|
|
12
|
+
- A pushed template now carries, for each scene, the plan's visual direction and the components the composition times to it, and for the film every component it uses (written for the project, pulled from the library or from the kit).
|
|
13
|
+
- Sound effects and the music track carry their library ids, so a template's timeline can copy the command that pulls each one.
|
|
14
|
+
- Scene pictures are sharper (640 pixels wide).
|
|
15
|
+
|
|
16
|
+
## 0.12.0 - 2026-10-09
|
|
17
|
+
|
|
18
|
+
- Templates: `reelkit template push` saves a whole project in your private files (the plan, the composition, everything under `assets/` and the rendered video), and `reelkit template clone <id>` unpacks one into a new project to continue or remix. `reelkit template list` and `reelkit template delete <id>` manage them.
|
|
19
|
+
- Your templates page on reelkit.cc shows each one: its video, a timeline of its scenes, text, voice, music, beats and sound effects, and its files.
|
|
20
|
+
- `reelkit render` writes `out/sounds.json`: which sound plays when. A template pushed after that names each sound effect on its timeline.
|
|
21
|
+
|
|
6
22
|
## 0.11.0 - 2026-10-09
|
|
7
23
|
|
|
8
24
|
Ported from the reelkit-cli 0.9 toolset (written against the Remotion 0.8.0 CLI) onto the HyperFrames renderer.
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -15,7 +15,7 @@ For image or video cutouts, editable Blender product shots, custom 3D materials,
|
|
|
15
15
|
|
|
16
16
|
To implement a chosen style, read its row in `reference/style-recipes.md` for action, timing, sound and proof. `reference/style-components.md` maps the shared components and built-in kit to shot roles; inspect the selected source before using its props.
|
|
17
17
|
|
|
18
|
-
This skill targets `reelkit-cli` 0.
|
|
18
|
+
This skill targets `reelkit-cli` 0.12.2 and its HyperFrames kit. Check the installed version with `reelkit --version`; in a source checkout, invoke its `bin/reelkit.mjs` with Node. Use the matching CLI before authoring with `reelkit/frame`.
|
|
19
19
|
|
|
20
20
|
## What decides whether the film is accepted
|
|
21
21
|
|
|
@@ -185,6 +185,7 @@ The pane never changes the project by itself. Its buttons (Edit this scene, Chan
|
|
|
185
185
|
- A revision changes only what was asked. `reelkit render --allow <frames>` is how that is proved. Do not tidy, retime or recolour anything outside the list.
|
|
186
186
|
- Never regenerate a voice the user did not ask you to touch, and never pick a take when they asked to hear the options.
|
|
187
187
|
- On-screen text never has vowel points. Pointing belongs in the voice request only.
|
|
188
|
+
- `reelkit template push` saves the whole project in the user's private files: the plan, the composition, everything under `assets/` (their own pictures, footage and recorded voice too) and the rendered video. Run it only when the user asks to save, back up or reuse the project, and say in one sentence what it sends. Only they can see it, on their templates page and with `reelkit template list`. `reelkit template clone <id> [folder]` unpacks one into a new project to continue or remix; `reelkit template delete <id>` removes it.
|
|
188
189
|
- A private project means `reelkit render --no-share` on every render, and neither `reelkit components share` nor `reelkit assets upload --share`.
|
|
189
190
|
- Never print an API key, a token, or the contents of a credentials file.
|
|
190
191
|
- If a command reports that a quota is used up, tell the user what ran out and when it resets. Do not work around it. A message that says to wait a minute or an hour ("Too many searches", "Too many uploads started") is a throttle, not a used-up quota: wait and retry once instead of stopping.
|
package/src/cli.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { templateClone, templateDelete, templateList, templatePush } from "./commands/template";
|
|
1
2
|
import { Command } from "commander";
|
|
2
3
|
import { ApiFailure } from "./api/client";
|
|
3
4
|
import { assetsGenClip, assetsGenImage, assetsGenSvg, assetsLayers, assetsPull, assetsSearch, assetsUpload, assetsVoiceover, assetsVoices } from "./commands/assets";
|
|
@@ -150,6 +151,14 @@ program.command("tools").description("Print where the voice scripts that ship wi
|
|
|
150
151
|
const names = existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".py") && !f.startsWith("_")).sort() : [];
|
|
151
152
|
return { ok: names.length > 0, data: { dir, scripts: names }, summary: names.length ? `${dir}\n${names.map((n) => ` python3 ${dir}/${n} --help`).join("\n")}\nRead ${dir}/README.md for the order to use them in.` : "The voice scripts are missing from this installation. Reinstall reelkit." };
|
|
152
153
|
}));
|
|
154
|
+
const template = program.command("template").description("Whole projects kept in your private files, to continue later or to start a new video from");
|
|
155
|
+
template.command("push").description("Save this project as a template in your private files: the plan, the composition, everything under assets/ (your own files too), the rendered video when there is one, and a picture of each scene. Only you can see it")
|
|
156
|
+
.option("--name <name>", "what to call the template (default: the project's name)")
|
|
157
|
+
.action(run((ctx, opts: { name?: string }) => templatePush(ctx, opts)));
|
|
158
|
+
template.command("list").description("List your templates").action(run((ctx) => templateList(ctx)));
|
|
159
|
+
template.command("clone <id> [folder]").description("Unpack one of your templates into a new project folder, as a copy of its own to continue or to remix")
|
|
160
|
+
.action(run((ctx, id: string, folder: string | undefined) => templateClone(ctx, id, folder)));
|
|
161
|
+
template.command("delete <id>").description("Delete one of your templates from your private files").action(run((ctx, id: string) => templateDelete(ctx, id)));
|
|
153
162
|
const components = program.command("components").description("Components written in this project");
|
|
154
163
|
components.command("share [names...]").description("Share components written in this project with the library for review (all of them with no name): their source, a description and an example are sent, nothing else")
|
|
155
164
|
.option("--describe <text>", "what it shows and when to use it (one component)").option("--example <jsx>", "an example use with plain values, such as '<StatRing value={42} />' (one component)").option("--tags <tags>", "comma-separated tags, 3 to 6 (one component)")
|
package/src/commands/build.ts
CHANGED
|
@@ -101,6 +101,18 @@ function compositionNotes(project: Project, plan: ScenePlan, manifest?: AssetMan
|
|
|
101
101
|
|
|
102
102
|
const failed = (errors: string[]): Result => ({ ok: false, data: { errors }, summary: `${errors.length} problem(s):\n- ${errors.join("\n- ")}` });
|
|
103
103
|
|
|
104
|
+
// Which sound plays when, as the last render registered it: the project path of each sound, and its start and end in seconds. Written
|
|
105
|
+
// beside the video so that a timeline (the studio's, a template's) can name each sound effect instead of guessing from the mix.
|
|
106
|
+
export const SOUND_MAP = "out/sounds.json";
|
|
107
|
+
const SOUND_RECORDS = "out/.sound-records.json";
|
|
108
|
+
export type SoundMapEntry = { path: string; start: number; end: number; volume: number };
|
|
109
|
+
function saveSoundMap(project: { writeJson(rel: string, value: unknown): void }, urls: Record<string, string>, records: { src: string; start: number; end: number; volume: number }[]): void {
|
|
110
|
+
const pathOf = new Map(Object.entries(urls).map(([path, url]) => [url, path]));
|
|
111
|
+
const r3 = (n: number) => Math.round(n * 1000) / 1000;
|
|
112
|
+
const map: SoundMapEntry[] = records.flatMap((r) => { const path = pathOf.get(r.src); return path ? [{ path, start: r3(r.start), end: r3(r.end), volume: r3(r.volume) }] : []; }).sort((a, b) => a.start - b.start);
|
|
113
|
+
try { project.writeJson(SOUND_MAP, map); } catch { /* the map is a convenience: a render never fails for it */ }
|
|
114
|
+
}
|
|
115
|
+
|
|
104
116
|
export async function check(ctx: Ctx): Promise<Result> {
|
|
105
117
|
const project = openProject(ctx.cwd);
|
|
106
118
|
const { errors, manifest } = inspect(project);
|
|
@@ -334,6 +346,7 @@ export async function render(ctx: Ctx, deps: RenderDeps = {}): Promise<Result> {
|
|
|
334
346
|
renderSound: async (out) => {
|
|
335
347
|
const lists = readdirSync(chunkDir).filter((f) => f.endsWith(".audio.json")).map((f) => JSON.parse(readFileSync(join(chunkDir, f), "utf8")) as CapturedAudio[]);
|
|
336
348
|
const merged = mergeAudioRecords(lists);
|
|
349
|
+
saveSoundMap(project, urls, merged);
|
|
337
350
|
if (!merged.length) return false;
|
|
338
351
|
await mixCapturedAudio(merged, out, manifest.totalFrames / manifest.fps);
|
|
339
352
|
return existsSync(out) && statSync(out).size > 44;
|
|
@@ -343,7 +356,10 @@ export async function render(ctx: Ctx, deps: RenderDeps = {}): Promise<Result> {
|
|
|
343
356
|
}));
|
|
344
357
|
} else {
|
|
345
358
|
// The browser can fail to start or drop its connection; that is no fault of the composition, so it gets another go.
|
|
346
|
-
await withRetry(() => withBundle(project, (serveUrl, urls) => renderVideo(serveUrl, { manifest, urls }, project.path(path), options
|
|
359
|
+
await withRetry(() => withBundle(project, (serveUrl, urls) => renderVideo(serveUrl, { manifest, urls }, project.path(path), options, project.path(SOUND_RECORDS)).then(() => {
|
|
360
|
+
const file = project.path(SOUND_RECORDS);
|
|
361
|
+
if (existsSync(file)) { saveSoundMap(project, urls, JSON.parse(readFileSync(file, "utf8")) as { src: string; start: number; end: number; volume: number }[]); rmSync(file, { force: true }); }
|
|
362
|
+
})), { onRetry: (n, e) => ctx.log(`The browser failed (${(e instanceof Error ? e.message : String(e)).split("\n")[0]}); rendering again (attempt ${n + 1}).`) });
|
|
347
363
|
}
|
|
348
364
|
} catch (e) {
|
|
349
365
|
return failed([`Render failed: ${e instanceof Error ? e.message : String(e)}${deps.chunk ? " The finished pieces are kept: run the same command again to continue from them." : " For a long film, \`reelkit render --chunk\` keeps finished pieces and continues from them."}`]);
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, join, resolve } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { uploadTo } from "../api/client";
|
|
7
|
+
import { client, type Ctx, type Result } from "../context";
|
|
8
|
+
import { MAX_TEMPLATE_FILES, MAX_TEMPLATE_SOURCES_BYTES, MAX_TEMPLATE_THUMBS, MAX_TEMPLATE_THUMB_BYTES, MAX_UPLOAD_BYTES, TEMPLATE_BUNDLE_TYPE, type Template, type TemplateFile, type TemplateFilm } from "../contract";
|
|
9
|
+
import type { AssetManifest, ScenePlan } from "../pipeline/schema";
|
|
10
|
+
import type { MusicRecord } from "../project/music";
|
|
11
|
+
import { FILES, openProject, Project } from "../project/project";
|
|
12
|
+
import { SOUND_MAP, type SoundMapEntry } from "./build";
|
|
13
|
+
import { decodeMono } from "../project/refmeasure";
|
|
14
|
+
import { componentsOf } from "../project/scene-components";
|
|
15
|
+
import { leadingComment } from "./components";
|
|
16
|
+
import { AUDIO_RATE, hitTimes, swells } from "../project/soundreport";
|
|
17
|
+
|
|
18
|
+
const sh = promisify(execFile);
|
|
19
|
+
|
|
20
|
+
// A template is a whole project kept in its owner's private files on the server, to continue later or to start a new video from.
|
|
21
|
+
// What is sent: the project folder as one archive (the plan, the composition, and every file under assets/, including the user's own
|
|
22
|
+
// pictures, footage and the recorded voice), the rendered video when there is one, and a small picture of each scene. What is left on
|
|
23
|
+
// this machine: out/ (renders and previews), refs/ (reference videos), caches and node_modules.
|
|
24
|
+
export const TEMPLATE_SKIP = ["out", "refs", ".reelkit", "node_modules", ".git", ".DS_Store"];
|
|
25
|
+
const VIDEO = "out/video.mp4";
|
|
26
|
+
// Where a project remembers the template it was cloned from, and the last one it was pushed as. Never part of a bundle.
|
|
27
|
+
const LINK = ".reelkit/template.json";
|
|
28
|
+
const TEMPLATE_ID = /^tpl-[a-z0-9]{12}$/;
|
|
29
|
+
|
|
30
|
+
const mb = (bytes: number) => `${(bytes / 1_048_576).toFixed(bytes < 10_485_760 ? 1 : 0)} MB`;
|
|
31
|
+
const clock = (sec: number) => `${Math.floor(sec / 60)}:${String(Math.round(sec % 60)).padStart(2, "0")}`;
|
|
32
|
+
|
|
33
|
+
// The timeline as a page can draw it, from the manifest (the real timings) and the plan (the words). Times are in frames of the film;
|
|
34
|
+
// a scene's `words` and the music's `beats` are in seconds.
|
|
35
|
+
export function filmOf(project: Project): TemplateFilm | undefined {
|
|
36
|
+
const manifest = project.readJsonOr<AssetManifest | undefined>(FILES.manifest, undefined);
|
|
37
|
+
const plan = project.readJsonOr<ScenePlan | undefined>(FILES.plan, undefined);
|
|
38
|
+
if (!manifest?.scenes?.length || !plan) return undefined;
|
|
39
|
+
const byId = new Map(plan.scenes.map((s) => [s.id, s]));
|
|
40
|
+
const music = project.readJsonOr<MusicRecord | undefined>(FILES.music, undefined);
|
|
41
|
+
const seconds = manifest.totalFrames / manifest.fps;
|
|
42
|
+
const cut = (s: string, max: number) => (s.length > max ? s.slice(0, max) : s);
|
|
43
|
+
// The components, from the composition's source: which ones it uses, and which scenes time each of them.
|
|
44
|
+
const pulled = Object.values(project.readJsonOr<Record<string, { path?: string; kind?: string }>>(FILES.library, {})).filter((p) => p.kind === "component" && p.path).map((p) => basename(p.path!).replace(/\.tsx$/, ""));
|
|
45
|
+
let used: ReturnType<typeof componentsOf> = [];
|
|
46
|
+
try { if (project.exists("src/Video.tsx")) used = componentsOf(readFileSync(project.path("src/Video.tsx"), "utf8"), { sceneIds: plan.scenes.map((s) => s.id), pulled }); } catch { used = []; }
|
|
47
|
+
const about = (name: string): string | undefined => {
|
|
48
|
+
try { return project.exists(`src/${name}.tsx`) ? leadingComment(readFileSync(project.path(`src/${name}.tsx`), "utf8"), name)?.replace(/\s+/g, " ").trim() : undefined; } catch { return undefined; }
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
aspect: plan.aspect, fps: manifest.fps, width: manifest.width, height: manifest.height, totalFrames: Math.round(manifest.totalFrames),
|
|
52
|
+
scenes: manifest.scenes.slice(0, 200).map((m) => {
|
|
53
|
+
const p = byId.get(m.id);
|
|
54
|
+
const narration = p?.narration?.trim();
|
|
55
|
+
const text = (p?.onScreenText ?? []).map((t) => cut(t, 400)).slice(0, 20);
|
|
56
|
+
return {
|
|
57
|
+
id: cut(m.id, 80), start: Math.max(0, Math.round(m.startFrame)), frames: Math.max(0, Math.round(m.durationFrames)),
|
|
58
|
+
...(p?.treatment ? { treatment: cut(p.treatment, 60) } : {}), ...(narration ? { narration: cut(narration, 4000) } : {}),
|
|
59
|
+
...(text.length ? { text } : {}), ...(m.words.length ? { words: m.words.slice(0, 600).map((w) => Math.max(0, Math.round(w.startSec * 1000) / 1000)) } : {}),
|
|
60
|
+
...(p?.notes?.trim() ? { notes: cut(p.notes.trim(), 2000) } : {}),
|
|
61
|
+
...(used.some((c) => c.scenes.includes(m.id)) ? { components: used.filter((c) => c.scenes.includes(m.id)).map((c) => cut(c.name, 80)).slice(0, 60) } : {}),
|
|
62
|
+
};
|
|
63
|
+
}),
|
|
64
|
+
...(music ? { music: { title: cut(music.title, 200), seconds: music.durationSec, ...(music.bpm ? { bpm: music.bpm } : {}), beats: music.beats.filter((b) => b >= 0 && b <= seconds).slice(0, 4000), ...(music.id ? { id: cut(music.id, 128) } : {}) } } : {}),
|
|
65
|
+
...(used.length ? { components: used.slice(0, 300).map((c) => { const text = c.from === "kit" ? undefined : about(c.name); return { name: cut(c.name, 80), from: c.from, uses: c.uses, ...(text ? { about: cut(text, 400) } : {}) }; }) } : {}),
|
|
66
|
+
voice: plan.voice !== "none",
|
|
67
|
+
...(manifest.captions ? { captions: manifest.captions } : {}),
|
|
68
|
+
...cuesOf(project, manifest, music?.key),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The sound effects by name, from what the last render recorded (out/sounds.json): every sound that is not the music or a scene's
|
|
73
|
+
// voice, with the title it has in the library when it was pulled from there, or its file name.
|
|
74
|
+
function cuesOf(project: Project, manifest: AssetManifest, musicKey: string | undefined): Pick<TemplateFilm, "cues"> {
|
|
75
|
+
const map = project.readJsonOr<SoundMapEntry[]>(SOUND_MAP, []);
|
|
76
|
+
if (!Array.isArray(map) || !map.length) return {};
|
|
77
|
+
const pulled = Object.entries(project.readJsonOr<Record<string, { path?: string; kind?: string; title?: string; meta?: { durationSec?: number } }>>(FILES.library, {})).map(([id, p]) => ({ ...p, id }));
|
|
78
|
+
const voice = new Set(manifest.scenes.map((s) => s.voiceoverKey).filter(Boolean));
|
|
79
|
+
const cues = map.filter((e) => e && typeof e.path === "string" && e.path !== musicKey && e.path !== manifest.music?.key && !voice.has(e.path)).flatMap((e) => {
|
|
80
|
+
const lib = pulled.find((p) => p.path === e.path);
|
|
81
|
+
if (lib?.kind === "music") return [];
|
|
82
|
+
const title = (lib?.title ?? basename(e.path).replace(/\.[a-z0-9]+$/i, "")).slice(0, 200);
|
|
83
|
+
// A sound is registered until the end of the stretch it sits in, which is often the rest of the film: its own length is the file's.
|
|
84
|
+
const own = typeof lib?.meta?.durationSec === "number" && lib.meta.durationSec > 0 ? lib.meta.durationSec : 0;
|
|
85
|
+
const seconds = Math.max(0, Math.round(Math.min(own || 0, Math.max(0, e.end - e.start)) * 1000) / 1000);
|
|
86
|
+
return [{ at: Math.max(0, e.start), seconds, title, ...(lib?.kind ? { kind: lib.kind.slice(0, 24) } : {}), ...(lib?.id ? { id: lib.id.slice(0, 128) } : {}) }];
|
|
87
|
+
}).slice(0, 1500);
|
|
88
|
+
return cues.length ? { cues } : {};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// One small JPEG per scene, taken from the middle of it in the rendered video. A scene whose frame cannot be read gets none, and the
|
|
92
|
+
// scenes after it still do: the list is in scene order, cut short at the first failure so that positions keep their meaning.
|
|
93
|
+
async function thumbsOf(video: string, film: TemplateFilm, dir: string): Promise<Uint8Array[]> {
|
|
94
|
+
const out: Uint8Array[] = [];
|
|
95
|
+
for (const [i, scene] of film.scenes.slice(0, MAX_TEMPLATE_THUMBS).entries()) {
|
|
96
|
+
const at = (scene.start + scene.frames / 2) / film.fps;
|
|
97
|
+
const file = join(dir, `thumb-${i}.jpg`);
|
|
98
|
+
try {
|
|
99
|
+
await sh("ffmpeg", ["-nostdin", "-v", "error", "-y", "-ss", at.toFixed(3), "-i", video, "-frames:v", "1", "-vf", "scale=640:-2", "-q:v", "6", file]);
|
|
100
|
+
const bytes = readFileSync(file);
|
|
101
|
+
if (!bytes.length || bytes.length > MAX_TEMPLATE_THUMB_BYTES) break;
|
|
102
|
+
out.push(new Uint8Array(bytes));
|
|
103
|
+
} catch { break; }
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const TEXT_FILE = /\.(tsx?|jsx?|mjs|cjs|json|md|txt|css|html|svg|ya?ml)$/i;
|
|
109
|
+
const MAX_SOURCE_FILE = 200_000;
|
|
110
|
+
|
|
111
|
+
// What is in the bundle: every file's path and size, and the text of the small text files (the composition, the plan, the records), as
|
|
112
|
+
// one JSON object. The website lists the first and opens the second; media files are listed with their size only.
|
|
113
|
+
export function filesOf(dir: string): { files: TemplateFile[]; sources: Uint8Array | undefined } {
|
|
114
|
+
const files: TemplateFile[] = [];
|
|
115
|
+
const texts: Record<string, string> = {};
|
|
116
|
+
let room = MAX_TEMPLATE_SOURCES_BYTES - 1024;
|
|
117
|
+
const walk = (rel: string) => {
|
|
118
|
+
for (const entry of readdirSync(join(dir, rel), { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
119
|
+
if (!rel && TEMPLATE_SKIP.includes(entry.name)) continue;
|
|
120
|
+
if (entry.name === ".DS_Store") continue;
|
|
121
|
+
const path = rel ? `${rel}/${entry.name}` : entry.name;
|
|
122
|
+
if (entry.isDirectory()) { walk(path); continue; }
|
|
123
|
+
if (!entry.isFile() || files.length >= MAX_TEMPLATE_FILES || path.length > 400) continue;
|
|
124
|
+
const bytes = statSync(join(dir, path)).size;
|
|
125
|
+
files.push({ path, bytes });
|
|
126
|
+
if (TEXT_FILE.test(entry.name) && bytes <= MAX_SOURCE_FILE && bytes * 1.2 < room) {
|
|
127
|
+
const text = readFileSync(join(dir, path), "utf8");
|
|
128
|
+
if (!text.includes("\u0000")) { texts[path] = text; room -= Buffer.byteLength(JSON.stringify(text)) + path.length + 8; }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
walk("");
|
|
133
|
+
const sources = Object.keys(texts).length ? new Uint8Array(Buffer.from(JSON.stringify(texts))) : undefined;
|
|
134
|
+
return { files, sources: sources && sources.length <= MAX_TEMPLATE_SOURCES_BYTES ? sources : undefined };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const readLink = (project: Project): { id?: string; from?: string } => { try { return project.readJsonOr<{ id?: string; from?: string }>(LINK, {}); } catch { return {}; } };
|
|
138
|
+
|
|
139
|
+
export async function templatePush(ctx: Ctx, opts: { name?: string }): Promise<Result> {
|
|
140
|
+
const project = openProject(ctx.cwd);
|
|
141
|
+
const film = filmOf(project);
|
|
142
|
+
if (!film) return { ok: false, summary: "This project has no timeline yet. Make its assets and run `reelkit check` (or `reelkit render`) first, then push it." };
|
|
143
|
+
const name = (opts.name ?? project.config().name ?? basename(resolve(ctx.cwd))).trim().slice(0, 80);
|
|
144
|
+
if (!name) return { ok: false, summary: "Give the template a name: `reelkit template push --name \"Launch film\"`." };
|
|
145
|
+
const plan = project.readJsonOr<ScenePlan | undefined>(FILES.plan, undefined);
|
|
146
|
+
const work = mkdtempSync(join(tmpdir(), "rk-template-"));
|
|
147
|
+
try {
|
|
148
|
+
const bundle = join(work, "bundle.tgz");
|
|
149
|
+
ctx.log("Packing the project.");
|
|
150
|
+
await sh("tar", ["-czf", bundle, ...TEMPLATE_SKIP.flatMap((x) => ["--exclude", `./${x}`]), "-C", project.dir, "."], { maxBuffer: 1 << 24 });
|
|
151
|
+
const bundleBytes = statSync(bundle).size;
|
|
152
|
+
if (bundleBytes > MAX_UPLOAD_BYTES) return { ok: false, summary: `The project packs to ${mb(bundleBytes)}, over the ${mb(MAX_UPLOAD_BYTES)} a template may be. Remove large files under assets/ that the video does not use and push again.` };
|
|
153
|
+
const video = project.exists(VIDEO) ? project.path(VIDEO) : undefined;
|
|
154
|
+
const videoBytes = video ? statSync(video).size : 0;
|
|
155
|
+
if (videoBytes > MAX_UPLOAD_BYTES) return { ok: false, summary: `The rendered video is ${mb(videoBytes)}, over the ${mb(MAX_UPLOAD_BYTES)} limit. Export a smaller one with \`reelkit export\` or push without a render.` };
|
|
156
|
+
const thumbs = video ? await thumbsOf(video, film, work) : [];
|
|
157
|
+
// The sound effects are placed by the composition's code, so where they land is measured from the render itself.
|
|
158
|
+
if (video) {
|
|
159
|
+
try {
|
|
160
|
+
const samples = await decodeMono(video, AUDIO_RATE);
|
|
161
|
+
const r3 = (n: number) => Math.round(n * 1000) / 1000;
|
|
162
|
+
film.hits = hitTimes(samples, AUDIO_RATE).slice(0, 3000).map(r3);
|
|
163
|
+
film.swells = swells(samples, AUDIO_RATE).slice(0, 600).map((w) => ({ start: r3(w.start), length: r3(w.length) }));
|
|
164
|
+
} catch { /* a render with no sound, or one that cannot be read: the timeline has no sound-effects row */ }
|
|
165
|
+
}
|
|
166
|
+
const link = readLink(project);
|
|
167
|
+
const { files, sources } = filesOf(project.dir);
|
|
168
|
+
const api = client(ctx);
|
|
169
|
+
const started = await api("templateStart", {
|
|
170
|
+
name, ...(plan?.title ? { title: plan.title.slice(0, 200) } : {}), bundleBytes, ...(videoBytes ? { videoBytes } : {}),
|
|
171
|
+
thumbBytes: thumbs.map((t) => t.length), film, ...(link.from && TEMPLATE_ID.test(link.from) ? { from: link.from } : {}),
|
|
172
|
+
files, ...(sources ? { sourcesBytes: sources.length } : {}),
|
|
173
|
+
});
|
|
174
|
+
ctx.log(`Uploading ${mb(bundleBytes + videoBytes)} to your private files.`);
|
|
175
|
+
await uploadTo(started.bundleUrl, new Uint8Array(readFileSync(bundle)), TEMPLATE_BUNDLE_TYPE);
|
|
176
|
+
if (video && started.videoUrl) await uploadTo(started.videoUrl, new Uint8Array(readFileSync(video)), "video/mp4");
|
|
177
|
+
if (sources && started.sourcesUrl) await uploadTo(started.sourcesUrl, sources, "application/json");
|
|
178
|
+
for (const [i, url] of started.thumbUrls.entries()) if (thumbs[i]) await uploadTo(url, thumbs[i]!, "image/jpeg");
|
|
179
|
+
const { template } = await api("templateCommit", { id: started.id });
|
|
180
|
+
project.writeJson(LINK, { ...link, id: template.id });
|
|
181
|
+
return {
|
|
182
|
+
ok: true, data: { template },
|
|
183
|
+
summary: `Saved the template "${template.name}" as ${template.id} in your private files: the project (${mb(bundleBytes)}, with your own files under assets/)${videoBytes ? `, the rendered video (${mb(videoBytes)})` : ", with no rendered video (render first to keep one)"} and ${thumbs.length} scene picture${thumbs.length === 1 ? "" : "s"}. Only you can see it. Start from it anywhere with \`reelkit template clone ${template.id}\`.`,
|
|
184
|
+
};
|
|
185
|
+
} finally { rmSync(work, { recursive: true, force: true }); }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const line = (t: Template) => `${t.id} ${t.name} ${t.aspect} ${clock(t.durationSec)} ${t.scenes} scene${t.scenes === 1 ? "" : "s"} ${mb(t.bundleBytes + (t.videoBytes ?? 0))} ${t.createdAt.slice(0, 10)}${t.from ? ` from ${t.from}` : ""}`;
|
|
189
|
+
|
|
190
|
+
export async function templateList(ctx: Ctx): Promise<Result> {
|
|
191
|
+
const { templates } = await client(ctx)("templateList", {});
|
|
192
|
+
return { ok: true, data: { templates }, summary: templates.length ? `${templates.map(line).join("\n")}\nClone one with \`reelkit template clone <id>\`.` : "You have no templates yet. In a project, run `reelkit template push`." };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Entries of an archive that would land outside the folder it is unpacked into are refused before anything is written.
|
|
196
|
+
const unsafe = (entry: string) => entry.startsWith("/") || /^[A-Za-z]:/.test(entry) || entry.split(/[\\/]/).includes("..");
|
|
197
|
+
|
|
198
|
+
export async function templateClone(ctx: Ctx, id: string, dirArg?: string): Promise<Result> {
|
|
199
|
+
if (!TEMPLATE_ID.test(id)) return { ok: false, summary: `"${id}" is not a template id. List yours with \`reelkit template list\`.` };
|
|
200
|
+
const got = await client(ctx)("templateGet", { id });
|
|
201
|
+
const slug = got.template.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || id;
|
|
202
|
+
const dir = resolve(ctx.cwd, dirArg ?? slug);
|
|
203
|
+
if (existsSync(dir) && readdirSync(dir).length) return { ok: false, summary: `${dir} already has files in it. Give another folder: \`reelkit template clone ${id} <folder>\`.` };
|
|
204
|
+
const work = mkdtempSync(join(tmpdir(), "rk-template-"));
|
|
205
|
+
try {
|
|
206
|
+
const res = await fetch(got.bundleUrl);
|
|
207
|
+
if (!res.ok) return { ok: false, summary: `The template could not be downloaded (${res.status}). Run the command again.` };
|
|
208
|
+
const bundle = join(work, "bundle.tgz");
|
|
209
|
+
writeFileSync(bundle, new Uint8Array(await res.arrayBuffer()));
|
|
210
|
+
const { stdout } = await sh("tar", ["-tzf", bundle], { maxBuffer: 1 << 26 });
|
|
211
|
+
const bad = stdout.split("\n").filter(Boolean).find(unsafe);
|
|
212
|
+
if (bad) return { ok: false, summary: `The template holds a path that leaves its folder (${bad}), so nothing was unpacked.` };
|
|
213
|
+
mkdirSync(dir, { recursive: true });
|
|
214
|
+
await sh("tar", ["-xzf", bundle, "-C", dir], { maxBuffer: 1 << 24 });
|
|
215
|
+
if (!existsSync(join(dir, FILES.config))) return { ok: false, summary: `The template unpacked into ${dir}, but it holds no ${FILES.config}: it is not a Reelkit project.` };
|
|
216
|
+
// The new folder is a project of its own: what is pushed from it is a new template that remembers where it came from.
|
|
217
|
+
new Project(dir).writeJson(LINK, { from: id });
|
|
218
|
+
return {
|
|
219
|
+
ok: true, data: { dir, template: got.template },
|
|
220
|
+
summary: `Cloned "${got.template.name}" into ${dir}: the plan, the composition and its assets. It is a copy of its own, so changing it does not change the template. Next: \`cd ${dirArg ?? slug}\`, then \`reelkit check\` and \`reelkit preview\`. \`reelkit template push\` there saves it as a new template.`,
|
|
221
|
+
};
|
|
222
|
+
} finally { rmSync(work, { recursive: true, force: true }); }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export async function templateDelete(ctx: Ctx, id: string): Promise<Result> {
|
|
226
|
+
if (!TEMPLATE_ID.test(id)) return { ok: false, summary: `"${id}" is not a template id. List yours with \`reelkit template list\`.` };
|
|
227
|
+
await client(ctx)("templateDelete", { id });
|
|
228
|
+
return { ok: true, data: { id }, summary: `Deleted the template ${id} from your private files. Projects cloned from it are not touched.` };
|
|
229
|
+
}
|
package/src/contract/index.ts
CHANGED
|
@@ -172,6 +172,56 @@ export const MeSchema = z.object({
|
|
|
172
172
|
});
|
|
173
173
|
|
|
174
174
|
const Empty = z.object({});
|
|
175
|
+
// ── Templates ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
176
|
+
// A template is a whole video project kept in its owner's private files: the bundle (the project folder as a .tgz, without out/, refs/
|
|
177
|
+
// and caches), the rendered video when there is one, a small picture per scene, and `film`, a summary of the timeline that a page can
|
|
178
|
+
// draw without opening the bundle. Only its owner can list, read, clone or delete it. Nothing here reaches the shared library.
|
|
179
|
+
export const MAX_TEMPLATES = 50;
|
|
180
|
+
export const MAX_TEMPLATE_THUMBS = 60;
|
|
181
|
+
export const MAX_TEMPLATE_THUMB_BYTES = 200_000;
|
|
182
|
+
export const TEMPLATE_BUNDLE_TYPE = "application/gzip";
|
|
183
|
+
// The bundle's file list, and the text of its small text files (one JSON object, path to text), so a page can show the project's
|
|
184
|
+
// files and open the source ones without unpacking the bundle.
|
|
185
|
+
export const MAX_TEMPLATE_FILES = 3000;
|
|
186
|
+
export const MAX_TEMPLATE_SOURCES_BYTES = 3_145_728;
|
|
187
|
+
export const TemplateFileSchema = z.object({ path: z.string().min(1).max(400), bytes: z.number().int().min(0) });
|
|
188
|
+
export type TemplateFile = z.infer<typeof TemplateFileSchema>;
|
|
189
|
+
const TemplateId = z.string().regex(/^tpl-[a-z0-9]{12}$/);
|
|
190
|
+
const frames = z.number().int().min(0).max(1_000_000);
|
|
191
|
+
export const TemplateFilmSchema = z.object({
|
|
192
|
+
aspect: z.string().max(8), fps: z.number().min(1).max(120), width: z.number().int().min(16).max(8192), height: z.number().int().min(16).max(8192), totalFrames: frames,
|
|
193
|
+
// One entry per scene, in order. `words` are the seconds from the scene's start at which each spoken word begins.
|
|
194
|
+
scenes: z.array(z.object({
|
|
195
|
+
id: text(80), start: frames, frames, treatment: text(60).optional(), narration: text(4000).optional(),
|
|
196
|
+
text: z.array(text(400)).max(20).optional(), words: z.array(z.number().min(0)).max(600).optional(),
|
|
197
|
+
// The plan's visual direction for the scene, and the components the composition times to it (read from its source).
|
|
198
|
+
notes: text(2000).optional(), components: z.array(text(80)).max(60).optional(),
|
|
199
|
+
})).min(1).max(200),
|
|
200
|
+
// The music track, with the seconds at which its beats fall inside the film.
|
|
201
|
+
// `id` is the track's id in the library, when it was pulled from there.
|
|
202
|
+
music: z.object({ title: text(200), seconds: z.number().min(0), bpm: z.number().optional(), beats: z.array(z.number().min(0)).max(4000).optional(), id: text(128).optional() }).optional(),
|
|
203
|
+
voice: z.boolean().optional(),
|
|
204
|
+
captions: z.string().max(16).optional(),
|
|
205
|
+
// What a listener hears besides the voice and the music's pulse, measured from the rendered film's sound: the seconds at which a hit
|
|
206
|
+
// lands (a click, a pop, an impact) and the swells (a whoosh, a riser), each with its start and length in seconds.
|
|
207
|
+
hits: z.array(z.number().min(0)).max(3000).optional(),
|
|
208
|
+
swells: z.array(z.object({ start: z.number().min(0), length: z.number().min(0) })).max(600).optional(),
|
|
209
|
+
// The sound effects by name, when the render recorded them: each one's title, when it starts and how long it plays, in seconds.
|
|
210
|
+
// `id` is the sound's id in the library, when it was pulled from there.
|
|
211
|
+
cues: z.array(z.object({ at: z.number().min(0), seconds: z.number().min(0), title: text(200), kind: text(24).optional(), id: text(128).optional() })).max(1500).optional(),
|
|
212
|
+
// Every component the composition uses: one written in the project, one pulled from the library, or one of the kit's. `uses` is how
|
|
213
|
+
// many times it appears, and `about` is the comment above a project component.
|
|
214
|
+
components: z.array(z.object({ name: text(80), from: z.enum(["project", "library", "kit"]), uses: z.number().int().min(1), about: text(400).optional() })).max(300).optional(),
|
|
215
|
+
});
|
|
216
|
+
export type TemplateFilm = z.infer<typeof TemplateFilmSchema>;
|
|
217
|
+
export const TemplateSchema = z.object({
|
|
218
|
+
id: TemplateId, name: z.string(), title: z.string().optional(), createdAt: z.string(), bundleBytes: z.number(), videoBytes: z.number().optional(),
|
|
219
|
+
aspect: z.string(), durationSec: z.number(), scenes: z.number(), thumbs: z.number(),
|
|
220
|
+
// The template this one was cloned from, when it was pushed from a clone.
|
|
221
|
+
from: z.string().optional(),
|
|
222
|
+
});
|
|
223
|
+
export type Template = z.infer<typeof TemplateSchema>;
|
|
224
|
+
|
|
175
225
|
const route = <Q extends z.ZodType, S extends z.ZodType>(method: "GET" | "POST", path: string, auth: boolean, req: Q, res: S) => ({ method, path, auth, req, res });
|
|
176
226
|
|
|
177
227
|
export const routes = {
|
|
@@ -258,6 +308,23 @@ export const routes = {
|
|
|
258
308
|
transcribeRun: route("POST", "/transcripts/run", true,
|
|
259
309
|
z.object({ id: ItemId }),
|
|
260
310
|
z.object({ id: z.string(), text: z.string(), language: z.string().optional(), segments: z.array(TranscriptSegmentSchema), durationSec: z.number().positive() })),
|
|
311
|
+
// Starts a template: answers its id and one upload URL per file. Each URL takes one PUT of exactly the size and type named here
|
|
312
|
+
// (the bundle as application/gzip, the video as video/mp4, each thumb as image/jpeg, in scene order). The template exists for its
|
|
313
|
+
// owner only after `templateCommit`. A user may keep MAX_TEMPLATES; one more is refused with invalid_request.
|
|
314
|
+
templateStart: route("POST", "/templates", true,
|
|
315
|
+
z.object({
|
|
316
|
+
name: text(80).min(1), title: text(200).optional(), bundleBytes: z.number().int().min(1).max(MAX_UPLOAD_BYTES), videoBytes: z.number().int().min(1).max(MAX_UPLOAD_BYTES).optional(),
|
|
317
|
+
thumbBytes: z.array(z.number().int().min(1).max(MAX_TEMPLATE_THUMB_BYTES)).max(MAX_TEMPLATE_THUMBS), film: TemplateFilmSchema, from: TemplateId.optional(),
|
|
318
|
+
files: z.array(TemplateFileSchema).max(MAX_TEMPLATE_FILES).optional(), sourcesBytes: z.number().int().min(2).max(MAX_TEMPLATE_SOURCES_BYTES).optional(),
|
|
319
|
+
}),
|
|
320
|
+
z.object({ id: TemplateId, bundleUrl: z.string(), videoUrl: z.string().optional(), thumbUrls: z.array(z.string()), sourcesUrl: z.string().optional() })),
|
|
321
|
+
// Makes a started template real once its bundle (and its video, when one was named) has been uploaded; otherwise invalid_request.
|
|
322
|
+
templateCommit: route("POST", "/templates/commit", true, z.object({ id: TemplateId }), z.object({ template: TemplateSchema })),
|
|
323
|
+
// The caller's own templates, newest first.
|
|
324
|
+
templateList: route("GET", "/templates", true, z.object({}), z.object({ templates: z.array(TemplateSchema) })),
|
|
325
|
+
// One of the caller's templates with a link to its bundle that works for ten minutes. Another user's id is not_found.
|
|
326
|
+
templateGet: route("GET", "/templates/get", true, z.object({ id: TemplateId }), z.object({ template: TemplateSchema, bundleUrl: z.string(), film: TemplateFilmSchema })),
|
|
327
|
+
templateDelete: route("POST", "/templates/delete", true, z.object({ id: TemplateId }), z.object({ deleted: z.literal(true) })),
|
|
261
328
|
publicLibrary: route("GET", "/public/library", false,
|
|
262
329
|
// `sort` orders a listing without words by when each item was added: "new" is newest first, "old" is oldest first. Without it the
|
|
263
330
|
// server chooses (newest first within a kind; the kinds take turns when none is named). It does not reorder a search by words.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { sourceFile } from "../render/static-check";
|
|
3
|
+
|
|
4
|
+
// Which components a composition uses, and in which scenes, read from its source without running it.
|
|
5
|
+
//
|
|
6
|
+
// A composition is one tree, not a file per scene, so "this component belongs to that scene" is read from how it is timed: a variable
|
|
7
|
+
// made with `sceneById(manifest, "id")` stands for a scene, a variable computed from one stands for the same scene, and a component
|
|
8
|
+
// whose props (or whose enclosing element's props) mention such a variable is counted for that scene. A component timed by plain
|
|
9
|
+
// numbers, or by nothing, is counted for the film as a whole and for no scene. It is a reading of the code, not a measurement.
|
|
10
|
+
export type UsedComponent = { name: string; from: "project" | "kit" | "library"; uses: number; scenes: string[] };
|
|
11
|
+
|
|
12
|
+
// Tags that are plumbing rather than something the film shows.
|
|
13
|
+
const PLUMBING = new Set(["Sequence", "AbsoluteFill", "Series", "Fragment", "React.Fragment", "Audio", "Img", "Video", "OffthreadVideo", "Loop", "Freeze"]);
|
|
14
|
+
|
|
15
|
+
export function componentsOf(source: string, opts: { sceneIds: string[]; pulled?: string[] } = { sceneIds: [] }): UsedComponent[] {
|
|
16
|
+
const sf = sourceFile(source, "Video.tsx");
|
|
17
|
+
const known = new Set(opts.sceneIds);
|
|
18
|
+
const pulled = new Set(opts.pulled ?? []);
|
|
19
|
+
// Where each imported name comes from.
|
|
20
|
+
const origin = new Map<string, UsedComponent["from"]>();
|
|
21
|
+
for (const stmt of sf.statements) {
|
|
22
|
+
if (!ts.isImportDeclaration(stmt) || !ts.isStringLiteral(stmt.moduleSpecifier) || !stmt.importClause) continue;
|
|
23
|
+
const spec = stmt.moduleSpecifier.text;
|
|
24
|
+
const from: UsedComponent["from"] | undefined = spec === "reelkit/kit" ? "kit" : spec.startsWith("./") ? "project" : undefined;
|
|
25
|
+
if (!from) continue;
|
|
26
|
+
const names: string[] = [];
|
|
27
|
+
if (stmt.importClause.name) names.push(stmt.importClause.name.text);
|
|
28
|
+
const bindings = stmt.importClause.namedBindings;
|
|
29
|
+
if (bindings && ts.isNamedImports(bindings)) for (const el of bindings.elements) if (!el.isTypeOnly) names.push(el.name.text);
|
|
30
|
+
for (const name of names) origin.set(name, from === "project" && pulled.has(name) ? "library" : from);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Variables that stand for a scene: `const grow = sceneById(manifest, "grow")`, then anything computed from one, in source order.
|
|
34
|
+
const sceneOf = new Map<string, Set<string>>();
|
|
35
|
+
const idsIn = (node: ts.Node): Set<string> => {
|
|
36
|
+
const found = new Set<string>();
|
|
37
|
+
const walk = (n: ts.Node) => {
|
|
38
|
+
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === "sceneById") {
|
|
39
|
+
const arg = n.arguments[1];
|
|
40
|
+
if (arg && ts.isStringLiteralLike(arg) && (!known.size || known.has(arg.text))) found.add(arg.text);
|
|
41
|
+
}
|
|
42
|
+
if (ts.isIdentifier(n)) for (const id of sceneOf.get(n.text) ?? []) found.add(id);
|
|
43
|
+
ts.forEachChild(n, walk);
|
|
44
|
+
};
|
|
45
|
+
walk(node);
|
|
46
|
+
return found;
|
|
47
|
+
};
|
|
48
|
+
const declare = (node: ts.Node) => {
|
|
49
|
+
if (ts.isVariableDeclaration(node) && node.initializer) {
|
|
50
|
+
const ids = idsIn(node.initializer);
|
|
51
|
+
if (ids.size) {
|
|
52
|
+
const bind = (name: ts.BindingName) => {
|
|
53
|
+
if (ts.isIdentifier(name)) sceneOf.set(name.text, new Set([...(sceneOf.get(name.text) ?? []), ...ids]));
|
|
54
|
+
else for (const el of name.elements) if (ts.isBindingElement(el)) bind(el.name);
|
|
55
|
+
};
|
|
56
|
+
bind(node.name);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
ts.forEachChild(node, declare);
|
|
60
|
+
};
|
|
61
|
+
// Twice, so a variable computed from one declared further down (a helper above its data) is still followed.
|
|
62
|
+
declare(sf); declare(sf);
|
|
63
|
+
|
|
64
|
+
const used = new Map<string, { uses: number; scenes: Set<string> }>();
|
|
65
|
+
const visit = (node: ts.Node, inherited: Set<string>) => {
|
|
66
|
+
let here = inherited;
|
|
67
|
+
const opening = ts.isJsxSelfClosingElement(node) ? node : ts.isJsxElement(node) ? node.openingElement : undefined;
|
|
68
|
+
if (opening) {
|
|
69
|
+
const own = idsIn(opening.attributes);
|
|
70
|
+
here = own.size ? own : inherited;
|
|
71
|
+
const tag = opening.tagName.getText(sf);
|
|
72
|
+
const from = origin.get(tag.split(".")[0]!);
|
|
73
|
+
if (from && !PLUMBING.has(tag) && /^[A-Z]/.test(tag)) {
|
|
74
|
+
const entry = used.get(tag) ?? { uses: 0, scenes: new Set<string>() };
|
|
75
|
+
entry.uses += 1;
|
|
76
|
+
for (const id of here) entry.scenes.add(id);
|
|
77
|
+
used.set(tag, entry);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
// A block of JSX produced inside an expression that mentions a scene (a `.map` over that scene's items, a condition on its frame)
|
|
81
|
+
// belongs to that scene too.
|
|
82
|
+
if (ts.isJsxExpression(node) && node.expression) { const own = idsIn(node.expression); if (own.size && !ts.isJsxElement(node.expression)) here = new Set([...own]); }
|
|
83
|
+
ts.forEachChild(node, (child) => visit(child, here));
|
|
84
|
+
};
|
|
85
|
+
visit(sf, new Set());
|
|
86
|
+
|
|
87
|
+
const order = opts.sceneIds;
|
|
88
|
+
return [...used].map(([name, u]) => ({ name, from: origin.get(name.split(".")[0]!)!, uses: u.uses, scenes: [...u.scenes].sort((a, b) => order.indexOf(a) - order.indexOf(b)) }))
|
|
89
|
+
.sort((a, b) => (a.from === b.from ? b.uses - a.uses || a.name.localeCompare(b.name) : ["project", "library", "kit"].indexOf(a.from) - ["project", "library", "kit"].indexOf(b.from)));
|
|
90
|
+
}
|
package/src/render/render.ts
CHANGED
|
@@ -75,8 +75,9 @@ async function render(dir: string, props: VideoProps, mode: "stills" | "video",
|
|
|
75
75
|
export async function renderStills(dir: string, props: VideoProps, frames: number[], outDir: string, options?: RenderOptions) {
|
|
76
76
|
await mkdir(outDir, { recursive: true }); await render(dir, props, "stills", outDir, frames, options);
|
|
77
77
|
}
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
// `audioFile`, when given, receives the audio records the film registered (which sound plays from when to when), as JSON.
|
|
79
|
+
export async function renderVideo(dir: string, props: VideoProps, outPath: string, options?: RenderOptions, audioFile?: string) {
|
|
80
|
+
await mkdir(dirname(outPath), { recursive: true }); await render(dir, props, "video", outPath, [], options, audioFile ? { audioSidecar: audioFile } : undefined);
|
|
80
81
|
}
|
|
81
82
|
// One inclusive range of frames as a silent file, plus the audio records those frames registered (`${outPath}.audio.json`).
|
|
82
83
|
export async function renderVideoRange(dir: string, props: VideoProps, outPath: string, from: number, to: number, options?: RenderOptions) {
|
package/src/testing/fake-api.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { createServer } from "node:http";
|
|
|
5
5
|
import type { AddressInfo } from "node:net";
|
|
6
6
|
import { tmpdir } from "node:os";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
-
import { MAX_UPLOAD_BYTES, routes, type ErrorCode, type LibraryItem, type RouteName, type Voice } from "../contract";
|
|
8
|
+
import { MAX_TEMPLATES, MAX_UPLOAD_BYTES, TEMPLATE_BUNDLE_TYPE, routes, type ErrorCode, type LibraryItem, type RouteName, type Template, type TemplateFilm, type Voice } from "../contract";
|
|
9
9
|
import { staticCheck } from "../render/validate";
|
|
10
10
|
import { FAKE_SVG, PIXEL_PNG, silentWav } from "./fixtures";
|
|
11
11
|
|
|
@@ -124,6 +124,9 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
|
|
|
124
124
|
const secret = () => randomBytes(16).toString("hex");
|
|
125
125
|
let origin = "";
|
|
126
126
|
// Every call makes a new URL for its own blob. The URL says nothing about the item behind it.
|
|
127
|
+
// A user's templates: the files as they were uploaded, and whether the template was committed.
|
|
128
|
+
type TemplateJob = { owner: string; record: Template; film: TemplateFilm; committed: boolean; bundle?: Uint8Array; video?: Uint8Array; sources?: Uint8Array; thumbs: (Uint8Array | undefined)[] };
|
|
129
|
+
const templates = new Map<string, TemplateJob>();
|
|
127
130
|
const fileUrl = (blob: Blob) => { const t = secret(); blobs.set(t, blob); return `${origin}/files/${t}`; };
|
|
128
131
|
const words = (s: string) => new Set(s.toLowerCase().match(/[a-z0-9]+/g) ?? []);
|
|
129
132
|
|
|
@@ -323,6 +326,51 @@ export async function startFakeApi(opts: { voiceoverCharLimit?: number; imageLim
|
|
|
323
326
|
};
|
|
324
327
|
return job.result;
|
|
325
328
|
},
|
|
329
|
+
templateStart: (input, { userId }) => {
|
|
330
|
+
const owner = userId ?? "u-test";
|
|
331
|
+
if ([...templates.values()].filter((t) => t.owner === owner && t.committed).length >= MAX_TEMPLATES) throw new Fail(400, "invalid_request", `You already keep ${MAX_TEMPLATES} templates. Delete one with \`reelkit template delete <id>\` and push again.`);
|
|
332
|
+
const id = `tpl-${randomBytes(6).toString("hex")}`;
|
|
333
|
+
const film = input.film;
|
|
334
|
+
const record: Template = {
|
|
335
|
+
id, name: input.name, ...(input.title ? { title: input.title } : {}), createdAt: new Date().toISOString(), bundleBytes: input.bundleBytes,
|
|
336
|
+
...(input.videoBytes ? { videoBytes: input.videoBytes } : {}), aspect: film.aspect, durationSec: Math.round((film.totalFrames / film.fps) * 100) / 100,
|
|
337
|
+
scenes: film.scenes.length, thumbs: input.thumbBytes.length, ...(input.from ? { from: input.from } : {}),
|
|
338
|
+
};
|
|
339
|
+
const job: TemplateJob = { owner, record, film, committed: false, thumbs: (input.thumbBytes as number[]).map(() => undefined) };
|
|
340
|
+
templates.set(id, job);
|
|
341
|
+
const put = (contentType: string, bytes: number, has: () => boolean, set: (b: Uint8Array) => void) => {
|
|
342
|
+
const token = secret();
|
|
343
|
+
uploads.set(token, { contentType, bytes, used: false, sink: { has, put: set } });
|
|
344
|
+
return `${origin}/upload/${token}`;
|
|
345
|
+
};
|
|
346
|
+
return {
|
|
347
|
+
id, bundleUrl: put(TEMPLATE_BUNDLE_TYPE, input.bundleBytes, () => Boolean(job.bundle), (b) => { job.bundle = b; }),
|
|
348
|
+
...(input.videoBytes ? { videoUrl: put("video/mp4", input.videoBytes, () => Boolean(job.video), (b) => { job.video = b; }) } : {}),
|
|
349
|
+
...(input.sourcesBytes ? { sourcesUrl: put("application/json", input.sourcesBytes, () => Boolean(job.sources), (b) => { job.sources = b; }) } : {}),
|
|
350
|
+
thumbUrls: (input.thumbBytes as number[]).map((n, i) => put("image/jpeg", n, () => Boolean(job.thumbs[i]), (b) => { job.thumbs[i] = b; })),
|
|
351
|
+
};
|
|
352
|
+
},
|
|
353
|
+
templateCommit: ({ id }, { userId }) => {
|
|
354
|
+
const job = templates.get(id);
|
|
355
|
+
if (!job || job.owner !== (userId ?? "u-test")) throw new Fail(404, "not_found", "No such template.");
|
|
356
|
+
if (!job.bundle || (job.record.videoBytes && !job.video)) throw new Fail(400, "invalid_request", "The template's files have not been uploaded. Run `reelkit template push` again.");
|
|
357
|
+
job.committed = true;
|
|
358
|
+
return { template: job.record };
|
|
359
|
+
},
|
|
360
|
+
templateList: (_input, { userId }) => ({
|
|
361
|
+
templates: [...templates.values()].filter((t) => t.owner === (userId ?? "u-test") && t.committed).map((t) => t.record).sort((a, b) => b.createdAt.localeCompare(a.createdAt)),
|
|
362
|
+
}),
|
|
363
|
+
templateGet: ({ id }, { userId }) => {
|
|
364
|
+
const job = templates.get(id);
|
|
365
|
+
if (!job || !job.committed || job.owner !== (userId ?? "u-test")) throw new Fail(404, "not_found", "No such template.");
|
|
366
|
+
return { template: job.record, film: job.film, bundleUrl: fileUrl({ filename: "bundle.tgz", contentType: TEMPLATE_BUNDLE_TYPE, bytes: job.bundle! }) };
|
|
367
|
+
},
|
|
368
|
+
templateDelete: ({ id }, { userId }) => {
|
|
369
|
+
const job = templates.get(id);
|
|
370
|
+
if (!job || !job.committed || job.owner !== (userId ?? "u-test")) throw new Fail(404, "not_found", "No such template.");
|
|
371
|
+
templates.delete(id);
|
|
372
|
+
return { deleted: true as const };
|
|
373
|
+
},
|
|
326
374
|
publicLibrary: ({ q, kind, page, sort }) => {
|
|
327
375
|
// Published items only. Newest first, or oldest first when asked; with a query, best match first and equal matches newest first.
|
|
328
376
|
const want = q ? words(q) : undefined;
|