@anvpx/motion-cli 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +121 -2
- package/dist/analyze/command.d.ts +2 -0
- package/dist/analyze/command.js +190 -0
- package/dist/approval/approval.d.ts +32 -0
- package/dist/approval/approval.js +141 -0
- package/dist/approval/command.d.ts +2 -0
- package/dist/approval/command.js +255 -0
- package/dist/approval/gate.d.ts +7 -0
- package/dist/approval/gate.js +23 -0
- package/dist/approval/hash.d.ts +71 -0
- package/dist/approval/hash.js +464 -0
- package/dist/args.d.ts +30 -0
- package/dist/args.js +53 -0
- package/dist/assets/command.d.ts +2 -0
- package/dist/assets/command.js +165 -0
- package/dist/captions/command.d.ts +2 -0
- package/dist/captions/command.js +341 -0
- package/dist/captions/cues.d.ts +10 -0
- package/dist/captions/cues.js +61 -0
- package/dist/catalog/command.d.ts +2 -0
- package/dist/catalog/command.js +60 -0
- package/dist/catalog/format.d.ts +13 -0
- package/dist/catalog/format.js +54 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +35 -0
- package/dist/commands.d.ts +3 -0
- package/dist/commands.js +59 -0
- package/dist/compare/command.d.ts +4 -0
- package/dist/compare/command.js +144 -0
- package/dist/contact/argv.d.ts +24 -0
- package/dist/contact/argv.js +42 -0
- package/dist/contact/command.d.ts +2 -0
- package/dist/contact/command.js +79 -0
- package/dist/doctor/checks.d.ts +25 -0
- package/dist/doctor/checks.js +571 -0
- package/dist/doctor/command.d.ts +4 -0
- package/dist/doctor/command.js +88 -0
- package/dist/doctor/format.d.ts +7 -0
- package/dist/doctor/format.js +41 -0
- package/dist/doctor/heal.d.ts +14 -0
- package/dist/doctor/heal.js +71 -0
- package/dist/doctor/pins.d.ts +8 -0
- package/dist/doctor/pins.js +28 -0
- package/dist/doctor/skills.d.ts +15 -0
- package/dist/doctor/skills.js +58 -0
- package/dist/doctor/types.d.ts +36 -0
- package/dist/doctor/types.js +1 -0
- package/dist/doctor/vendored.d.ts +15 -0
- package/dist/doctor/vendored.js +90 -0
- package/dist/doctor/version.d.ts +8 -0
- package/dist/doctor/version.js +11 -0
- package/dist/exit-codes.d.ts +16 -0
- package/dist/exit-codes.js +15 -0
- package/dist/fit/command.d.ts +5 -0
- package/dist/fit/command.js +154 -0
- package/dist/fit/solve.d.ts +21 -0
- package/dist/fit/solve.js +80 -0
- package/dist/fit/target.d.ts +18 -0
- package/dist/fit/target.js +47 -0
- package/dist/generate3d/command.d.ts +3 -0
- package/dist/generate3d/command.js +38 -0
- package/dist/generate3d/run.d.ts +39 -0
- package/dist/generate3d/run.js +77 -0
- package/dist/icon/command.d.ts +2 -0
- package/dist/icon/command.js +113 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +16 -0
- package/dist/install-mk.d.ts +31 -0
- package/dist/install-mk.js +168 -0
- package/dist/integration/preview.integration.d.ts +1 -0
- package/dist/integration/preview.integration.js +53 -0
- package/dist/integration/smoke.integration.d.ts +1 -0
- package/dist/integration/smoke.integration.js +91 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +4 -0
- package/dist/node-io.d.ts +14 -0
- package/dist/node-io.js +152 -0
- package/dist/optimize/command.d.ts +12 -0
- package/dist/optimize/command.js +110 -0
- package/dist/packs/command.d.ts +2 -0
- package/dist/packs/command.js +109 -0
- package/dist/patch/apply.d.ts +36 -0
- package/dist/patch/apply.js +186 -0
- package/dist/patch/command.d.ts +2 -0
- package/dist/patch/command.js +92 -0
- package/dist/plugin/install.d.ts +40 -0
- package/dist/plugin/install.js +170 -0
- package/dist/preview/command.d.ts +10 -0
- package/dist/preview/command.js +764 -0
- package/dist/preview/manifest.d.ts +94 -0
- package/dist/preview/manifest.js +19 -0
- package/dist/preview/pack.d.ts +24 -0
- package/dist/preview/pack.js +53 -0
- package/dist/preview/plan.d.ts +58 -0
- package/dist/preview/plan.js +160 -0
- package/dist/preview/provenance.d.ts +23 -0
- package/dist/preview/provenance.js +38 -0
- package/dist/publish/command.d.ts +2 -0
- package/dist/publish/command.js +70 -0
- package/dist/publish/player.d.ts +25 -0
- package/dist/publish/player.js +108 -0
- package/dist/publish/static.d.ts +16 -0
- package/dist/publish/static.js +78 -0
- package/dist/qc.d.ts +2 -0
- package/dist/qc.js +192 -0
- package/dist/scene/author.d.ts +16 -0
- package/dist/scene/author.js +279 -0
- package/dist/scene/command.d.ts +2 -0
- package/dist/scene/command.js +110 -0
- package/dist/scene/ops.d.ts +23 -0
- package/dist/scene/ops.js +151 -0
- package/dist/state/command.d.ts +2 -0
- package/dist/state/command.js +128 -0
- package/dist/state/evidence.d.ts +71 -0
- package/dist/state/evidence.js +243 -0
- package/dist/storyboard/check.d.ts +21 -0
- package/dist/storyboard/check.js +39 -0
- package/dist/storyboard/containment.d.ts +24 -0
- package/dist/storyboard/containment.js +43 -0
- package/dist/storyboard/custom.d.ts +15 -0
- package/dist/storyboard/custom.js +133 -0
- package/dist/storyboard/edit.d.ts +19 -0
- package/dist/storyboard/edit.js +39 -0
- package/dist/storyboard/load.d.ts +11 -0
- package/dist/storyboard/load.js +52 -0
- package/dist/storyboard/report.d.ts +5 -0
- package/dist/storyboard/report.js +8 -0
- package/dist/template/coerce.d.ts +6 -0
- package/dist/template/coerce.js +44 -0
- package/dist/template/command.d.ts +5 -0
- package/dist/template/command.js +163 -0
- package/dist/template/env.d.ts +34 -0
- package/dist/template/env.js +38 -0
- package/dist/template/fork.d.ts +8 -0
- package/dist/template/fork.js +156 -0
- package/dist/template/format.d.ts +21 -0
- package/dist/template/format.js +161 -0
- package/dist/template/list.d.ts +15 -0
- package/dist/template/list.js +116 -0
- package/dist/template/new.d.ts +6 -0
- package/dist/template/new.js +302 -0
- package/dist/testing/env.d.ts +8 -0
- package/dist/testing/env.js +19 -0
- package/dist/testing/fakes.d.ts +33 -0
- package/dist/testing/fakes.js +95 -0
- package/dist/testing/fixtures.d.ts +8 -0
- package/dist/testing/fixtures.js +6 -0
- package/dist/testing/probe.d.ts +15 -0
- package/dist/testing/probe.js +20 -0
- package/dist/testing/project.d.ts +22 -0
- package/dist/testing/project.js +26 -0
- package/dist/tooling/session.d.ts +40 -0
- package/dist/tooling/session.js +106 -0
- package/dist/types.d.ts +59 -0
- package/dist/types.js +1 -0
- package/dist/verify/audio-loudness.d.ts +46 -0
- package/dist/verify/audio-loudness.js +186 -0
- package/dist/verify/command.d.ts +2 -0
- package/dist/verify/command.js +161 -0
- package/dist/verify/determinism.d.ts +30 -0
- package/dist/verify/determinism.js +59 -0
- package/dist/verify/expectation.d.ts +15 -0
- package/dist/verify/expectation.js +19 -0
- package/dist/verify/media-events.d.ts +61 -0
- package/dist/verify/media-events.js +174 -0
- package/dist/verify/platform.d.ts +33 -0
- package/dist/verify/platform.js +59 -0
- package/dist/verify/probe.d.ts +14 -0
- package/dist/verify/probe.js +23 -0
- package/dist/verify/report.d.ts +15 -0
- package/dist/verify/report.js +45 -0
- package/dist/verify/run.d.ts +27 -0
- package/dist/verify/run.js +53 -0
- package/dist/voice/command.d.ts +3 -0
- package/dist/voice/command.js +49 -0
- package/dist/voice/manifest.d.ts +33 -0
- package/dist/voice/manifest.js +69 -0
- package/dist/voice/run.d.ts +27 -0
- package/dist/voice/run.js +190 -0
- package/dist/workflow/build.d.ts +2 -0
- package/dist/workflow/build.js +25 -0
- package/dist/workflow/draft.d.ts +9 -0
- package/dist/workflow/draft.js +215 -0
- package/dist/workflow/finish.d.ts +23 -0
- package/dist/workflow/finish.js +28 -0
- package/dist/workflow/project.d.ts +73 -0
- package/dist/workflow/project.js +198 -0
- package/dist/workflow/provenance.d.ts +18 -0
- package/dist/workflow/provenance.js +36 -0
- package/dist/workflow/render.d.ts +31 -0
- package/dist/workflow/render.js +721 -0
- package/dist/workflow/still.d.ts +5 -0
- package/dist/workflow/still.js +62 -0
- package/dist/workflow/validate.d.ts +2 -0
- package/dist/workflow/validate.js +49 -0
- package/package.json +38 -3
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { basename, join, resolve } from "node:path";
|
|
2
|
+
import { AssetLedgerSchema } from "@anvpx/motion-schema";
|
|
3
|
+
import { parseFlags, textFlag, usageError } from "../args.js";
|
|
4
|
+
import { EXIT } from "../exit-codes.js";
|
|
5
|
+
const USAGE = `mk assets import <file...> --dir <assets dir> --licence <spdx> --source-url <url> [--attribution <text>] [--ledger <file>] [--json]
|
|
6
|
+
|
|
7
|
+
Copy local files into <assets dir>, hash the copied bytes and append one ledger row per file
|
|
8
|
+
(path, sourceUrl, licence, attribution, sha256). Re-running the same call is a no-op when the
|
|
9
|
+
bytes match; a different hash at an existing path exits 2 naming the file. Remote URLs are refused.
|
|
10
|
+
--dir <d> destination directory (created when missing)
|
|
11
|
+
--licence <id> SPDX identifier for every file in this call (required)
|
|
12
|
+
--source-url <u> provenance URL for every file in this call (required; the ledger schema needs it)
|
|
13
|
+
--attribution <t> optional attribution text for every file in this call
|
|
14
|
+
--ledger <f> ledger path (default <dir>/assets.json)
|
|
15
|
+
--json print one JSON summary line
|
|
16
|
+
`;
|
|
17
|
+
const FLAGS = {
|
|
18
|
+
dir: { type: "string" },
|
|
19
|
+
licence: { type: "string" },
|
|
20
|
+
"source-url": { type: "string" },
|
|
21
|
+
attribution: { type: "string" },
|
|
22
|
+
ledger: { type: "string" },
|
|
23
|
+
json: { type: "boolean" },
|
|
24
|
+
};
|
|
25
|
+
const REMOTE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
26
|
+
/** One `import` file: refused when remote, unreadable or nameless. */
|
|
27
|
+
async function inspectFile(file, io) {
|
|
28
|
+
if (REMOTE.test(file)) {
|
|
29
|
+
io.stderr(`mk assets import: refusing remote path "${file}"; download it first\n`);
|
|
30
|
+
return EXIT.USAGE;
|
|
31
|
+
}
|
|
32
|
+
const name = basename(file);
|
|
33
|
+
if (name === "" || name === "." || name === "..") {
|
|
34
|
+
io.stderr(`mk assets import: cannot import "${file}": it has no file name\n`);
|
|
35
|
+
return EXIT.USAGE;
|
|
36
|
+
}
|
|
37
|
+
if (!(await io.host.exists(file))) {
|
|
38
|
+
io.stderr(`mk assets import: no such file "${file}"\n`);
|
|
39
|
+
return EXIT.USAGE;
|
|
40
|
+
}
|
|
41
|
+
const sha256 = await io.host.sha256File(file);
|
|
42
|
+
if (sha256 === undefined) {
|
|
43
|
+
io.stderr(`mk assets import: cannot hash "${file}"\n`);
|
|
44
|
+
return EXIT.USAGE;
|
|
45
|
+
}
|
|
46
|
+
return { name, sha256 };
|
|
47
|
+
}
|
|
48
|
+
/** Reads and validates the ledger; a missing file starts an empty ledger. */
|
|
49
|
+
async function readLedger(path, io) {
|
|
50
|
+
if (!(await io.host.exists(path)))
|
|
51
|
+
return [];
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(await io.host.readText(path));
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
io.stderr(`mk assets import: cannot read ledger ${path}: ${error.message}\n`);
|
|
58
|
+
return EXIT.USAGE;
|
|
59
|
+
}
|
|
60
|
+
const valid = AssetLedgerSchema.safeParse(parsed);
|
|
61
|
+
if (!valid.success) {
|
|
62
|
+
const problems = valid.error.issues
|
|
63
|
+
.map((issue) => `${issue.path.join("/") || "/"}: ${issue.message}`)
|
|
64
|
+
.join("; ");
|
|
65
|
+
io.stderr(`mk assets import: ledger ${path} is invalid: ${problems}\n`);
|
|
66
|
+
return EXIT.USAGE;
|
|
67
|
+
}
|
|
68
|
+
return [...valid.data];
|
|
69
|
+
}
|
|
70
|
+
/** Copies one file and returns the ledger row; exits 2 when the existing path has other bytes. */
|
|
71
|
+
async function importOne(file, inspected, dir, rows, shared, io) {
|
|
72
|
+
const existing = rows.find((row) => row.path === inspected.name);
|
|
73
|
+
if (existing !== undefined && existing.sha256 !== inspected.sha256) {
|
|
74
|
+
io.stderr(`mk assets import: "${inspected.name}" already has different bytes in the ledger (${existing.sha256.slice(0, 12)} != ${inspected.sha256.slice(0, 12)}); remove the row or rename the file\n`);
|
|
75
|
+
return EXIT.USAGE;
|
|
76
|
+
}
|
|
77
|
+
if (existing !== undefined) {
|
|
78
|
+
return {
|
|
79
|
+
row: { file, path: inspected.name, sha256: inspected.sha256 },
|
|
80
|
+
skipped: true,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const destination = join(dir, inspected.name);
|
|
84
|
+
try {
|
|
85
|
+
await io.host.copyFile(file, destination);
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
io.stderr(`mk assets import: cannot copy "${file}" to ${destination}: ${error.message}\n`);
|
|
89
|
+
return EXIT.USAGE;
|
|
90
|
+
}
|
|
91
|
+
const copied = await io.host.sha256File(destination);
|
|
92
|
+
if (copied !== inspected.sha256) {
|
|
93
|
+
io.stderr(`mk assets import: "${file}" changed while copying; nothing written\n`);
|
|
94
|
+
return EXIT.ERROR;
|
|
95
|
+
}
|
|
96
|
+
rows.push({
|
|
97
|
+
path: inspected.name,
|
|
98
|
+
sourceUrl: shared.sourceUrl,
|
|
99
|
+
licence: shared.licence,
|
|
100
|
+
...(shared.attribution === undefined ? {} : { attribution: shared.attribution }),
|
|
101
|
+
sha256: inspected.sha256,
|
|
102
|
+
});
|
|
103
|
+
return { row: { file, path: inspected.name, sha256: inspected.sha256 }, skipped: false };
|
|
104
|
+
}
|
|
105
|
+
const byPath = (a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
106
|
+
export const assetsCommand = {
|
|
107
|
+
name: "assets",
|
|
108
|
+
summary: "import local asset files into a directory with validated ledger rows",
|
|
109
|
+
async run(args, io) {
|
|
110
|
+
const [subcommand, ...files] = args;
|
|
111
|
+
if (subcommand !== "import") {
|
|
112
|
+
return usageError("assets", USAGE, 'expected "import"', io).code;
|
|
113
|
+
}
|
|
114
|
+
const parsed = parseFlags("assets", USAGE, files, FLAGS, io);
|
|
115
|
+
if (parsed.kind === "exit")
|
|
116
|
+
return parsed.code;
|
|
117
|
+
const sources = parsed.positionals;
|
|
118
|
+
if (sources.length === 0) {
|
|
119
|
+
return usageError("assets", USAGE, "missing <file...>", io).code;
|
|
120
|
+
}
|
|
121
|
+
const dirFlag = textFlag(parsed.flags, "dir");
|
|
122
|
+
if (dirFlag === undefined) {
|
|
123
|
+
return usageError("assets", USAGE, "--dir <assets dir> is required", io).code;
|
|
124
|
+
}
|
|
125
|
+
const licence = textFlag(parsed.flags, "licence");
|
|
126
|
+
if (licence === undefined || licence.trim() === "") {
|
|
127
|
+
return usageError("assets", USAGE, "--licence <spdx> is required", io).code;
|
|
128
|
+
}
|
|
129
|
+
const sourceUrl = textFlag(parsed.flags, "source-url");
|
|
130
|
+
if (sourceUrl === undefined || sourceUrl.trim() === "") {
|
|
131
|
+
return usageError("assets", USAGE, "--source-url <url> is required (the ledger row needs provenance)", io).code;
|
|
132
|
+
}
|
|
133
|
+
const attribution = textFlag(parsed.flags, "attribution");
|
|
134
|
+
const dir = resolve(dirFlag);
|
|
135
|
+
const ledgerPath = resolve(textFlag(parsed.flags, "ledger") ?? join(dir, "assets.json"));
|
|
136
|
+
const rows = await readLedger(ledgerPath, io);
|
|
137
|
+
if (typeof rows === "number")
|
|
138
|
+
return rows;
|
|
139
|
+
const summary = { imported: [], skipped: [] };
|
|
140
|
+
for (const file of sources) {
|
|
141
|
+
const inspected = await inspectFile(file, io);
|
|
142
|
+
if (typeof inspected === "number")
|
|
143
|
+
return inspected;
|
|
144
|
+
const result = await importOne(file, inspected, dir, rows, {
|
|
145
|
+
sourceUrl,
|
|
146
|
+
licence,
|
|
147
|
+
...(attribution === undefined ? {} : { attribution }),
|
|
148
|
+
}, io);
|
|
149
|
+
if (typeof result === "number")
|
|
150
|
+
return result;
|
|
151
|
+
(result.skipped ? summary.skipped : summary.imported).push(result.row);
|
|
152
|
+
}
|
|
153
|
+
if (summary.imported.length > 0) {
|
|
154
|
+
const text = `${JSON.stringify([...rows].sort(byPath), null, 2)}\n`;
|
|
155
|
+
await io.host.writeTextAtomic(ledgerPath, text);
|
|
156
|
+
}
|
|
157
|
+
if (parsed.flags.json === true) {
|
|
158
|
+
io.stdout(`${JSON.stringify({ ok: true, ledger: ledgerPath, ...summary })}\n`);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
io.stdout(`assets import ok | ${summary.imported.length} imported, ${summary.skipped.length} unchanged -> ${ledgerPath}\n`);
|
|
162
|
+
}
|
|
163
|
+
return EXIT.OK;
|
|
164
|
+
},
|
|
165
|
+
};
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { resolveTimeline, validateStoryboardFull } from "@anvpx/motion-build";
|
|
2
|
+
import { TimingManifestSchema } from "@anvpx/motion-schema";
|
|
3
|
+
import { parseFlags, textFlag, usageError } from "../args.js";
|
|
4
|
+
import { EXIT } from "../exit-codes.js";
|
|
5
|
+
import { readJsonFile } from "../storyboard/load.js";
|
|
6
|
+
import { formatErrors } from "../template/env.js";
|
|
7
|
+
import { formatSrt, formatVtt, parseCues } from "./cues.js";
|
|
8
|
+
const USAGE = `mk captions [<voice-manifest.json>] [--storyboard <board.json>] [--out ops.json] [--srt <file>] [--vtt <file>] [--from-srt <file> | --from-vtt <file>] [--json]
|
|
9
|
+
|
|
10
|
+
Turn voice word timings into one karaoke caption overlay per narrated scene, written as RFC 6902
|
|
11
|
+
\`add\` operations for \`mk patch --ops\`. Words are shifted to scene-relative time; words past the
|
|
12
|
+
scene's authored/auto duration are omitted and reported per scene, never silently kept.
|
|
13
|
+
--storyboard b the storyboard the manifest belongs to (required to emit ops)
|
|
14
|
+
--out f write the ops JSON array here (default: print it on stdout)
|
|
15
|
+
--srt f also export the manifest as SRT
|
|
16
|
+
--vtt f also export the manifest as WebVTT
|
|
17
|
+
--from-srt f instead of a manifest: convert SRT cues into caption overlays (needs --storyboard)
|
|
18
|
+
--from-vtt f instead of a manifest: convert WebVTT cues into caption overlays (needs --storyboard)
|
|
19
|
+
`;
|
|
20
|
+
const FLAGS = {
|
|
21
|
+
storyboard: { type: "string" },
|
|
22
|
+
out: { type: "string" },
|
|
23
|
+
srt: { type: "string" },
|
|
24
|
+
vtt: { type: "string" },
|
|
25
|
+
"from-srt": { type: "string" },
|
|
26
|
+
"from-vtt": { type: "string" },
|
|
27
|
+
json: { type: "boolean" },
|
|
28
|
+
};
|
|
29
|
+
const round = (value) => Math.round(value * 1000) / 1000;
|
|
30
|
+
const CAPTION_MAX_WORDS = 40;
|
|
31
|
+
const CAPTION_MAX_TEXT = 140;
|
|
32
|
+
const baseTimeline = (board) => {
|
|
33
|
+
const timeline = resolveTimeline(board);
|
|
34
|
+
if (!timeline.ok)
|
|
35
|
+
return EXIT.USAGE;
|
|
36
|
+
return timeline.value.scenes.map((scene) => ({
|
|
37
|
+
id: scene.id,
|
|
38
|
+
start: scene.start,
|
|
39
|
+
duration: scene.duration,
|
|
40
|
+
}));
|
|
41
|
+
};
|
|
42
|
+
/** A4/A1: words on the scene clock, clamped to the authored/auto bound; never overlaps. */
|
|
43
|
+
const sceneWords = (row, bound, fallback) => {
|
|
44
|
+
const source = row.words && row.words.length > 0 ? row.words : fallback;
|
|
45
|
+
const words = [];
|
|
46
|
+
let omitted = 0;
|
|
47
|
+
let cursor = 0;
|
|
48
|
+
for (const word of source) {
|
|
49
|
+
const start = round(Math.max(0, word.start - row.start));
|
|
50
|
+
const end = round(Math.min(bound, word.end - row.start));
|
|
51
|
+
if (start >= bound - 1e-6 || end <= start || start < cursor) {
|
|
52
|
+
omitted += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (words.length >= CAPTION_MAX_WORDS) {
|
|
56
|
+
omitted += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
words.push({ w: word.w, start, end });
|
|
60
|
+
cursor = end;
|
|
61
|
+
}
|
|
62
|
+
return { words, omitted };
|
|
63
|
+
};
|
|
64
|
+
/** One `add /scenes/-` op per narrated scene, with a per-scene captioned/omitted report. */
|
|
65
|
+
const planFromManifest = (board, manifest) => {
|
|
66
|
+
const timed = baseTimeline(board);
|
|
67
|
+
if (typeof timed === "number")
|
|
68
|
+
return timed;
|
|
69
|
+
const byId = new Map(timed.map((scene) => [scene.id, scene]));
|
|
70
|
+
const ops = [];
|
|
71
|
+
const report = [];
|
|
72
|
+
let words = 0;
|
|
73
|
+
let omitted = 0;
|
|
74
|
+
let scenes = 0;
|
|
75
|
+
for (const row of manifest.scenes) {
|
|
76
|
+
const scene = byId.get(row.id);
|
|
77
|
+
if (scene === undefined) {
|
|
78
|
+
report.push(`scene "${row.id}": not in the storyboard; skipped`);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const bound = round(scene.duration);
|
|
82
|
+
const fallback = manifest.audio?.words.filter((word) => word.start >= row.start - 1e-6 && word.start < row.start + bound) ?? [];
|
|
83
|
+
const built = sceneWords(row, bound, fallback);
|
|
84
|
+
words += built.words.length;
|
|
85
|
+
omitted += built.omitted;
|
|
86
|
+
if (built.words.length === 0) {
|
|
87
|
+
report.push(`scene "${row.id}": no words inside ${bound}s (${built.omitted} omitted); no caption`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
scenes += 1;
|
|
91
|
+
ops.push({
|
|
92
|
+
op: "add",
|
|
93
|
+
path: "/scenes/-",
|
|
94
|
+
value: {
|
|
95
|
+
id: `cap-${row.id}`,
|
|
96
|
+
type: "caption",
|
|
97
|
+
layer: "overlay",
|
|
98
|
+
over: row.id,
|
|
99
|
+
duration: bound,
|
|
100
|
+
props: { words: built.words },
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
report.push(`scene "${row.id}": ${built.words.length} words captioned` +
|
|
104
|
+
(built.omitted > 0 ? `, ${built.omitted} omitted past ${bound}s` : ""));
|
|
105
|
+
}
|
|
106
|
+
if (scenes === 0 || words === 0) {
|
|
107
|
+
return EXIT.USAGE;
|
|
108
|
+
}
|
|
109
|
+
return { ops, report, words, omitted };
|
|
110
|
+
};
|
|
111
|
+
/** SRT/VTT cues -> overlays placed by scene + offset; text-only captions (no word timings). */
|
|
112
|
+
const planFromCues = (board, cues, io) => {
|
|
113
|
+
const valid = validateStoryboardFull(board);
|
|
114
|
+
if (!valid.ok) {
|
|
115
|
+
io.stderr(`mk captions: storyboard is invalid: ${formatErrors(valid.errors)}\n`);
|
|
116
|
+
return EXIT.USAGE;
|
|
117
|
+
}
|
|
118
|
+
const timed = baseTimeline(valid.value);
|
|
119
|
+
if (typeof timed === "number") {
|
|
120
|
+
io.stderr("mk captions: cannot resolve the storyboard timeline\n");
|
|
121
|
+
return EXIT.USAGE;
|
|
122
|
+
}
|
|
123
|
+
const ops = [];
|
|
124
|
+
const report = [];
|
|
125
|
+
let words = 0;
|
|
126
|
+
let omitted = 0;
|
|
127
|
+
let index = 0;
|
|
128
|
+
for (const cue of cues) {
|
|
129
|
+
const scene = timed.find((candidate) => cue.start >= candidate.start - 1e-6 &&
|
|
130
|
+
cue.start < candidate.start + candidate.duration - 1e-6);
|
|
131
|
+
if (scene === undefined) {
|
|
132
|
+
omitted += 1;
|
|
133
|
+
report.push(`cue "${cue.text.slice(0, 24)}": outside every base scene; omitted`);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const start = round(Math.max(0, cue.start - scene.start));
|
|
137
|
+
const end = round(Math.min(cue.end, scene.start + scene.duration) - cue.start);
|
|
138
|
+
if (end <= 0) {
|
|
139
|
+
omitted += 1;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (cue.text.length > CAPTION_MAX_TEXT) {
|
|
143
|
+
omitted += 1;
|
|
144
|
+
report.push(`cue "${cue.text.slice(0, 24)}": ${cue.text.length} chars (max ${CAPTION_MAX_TEXT}); omitted`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
index += 1;
|
|
148
|
+
words += cue.text.split(/\s+/).filter(Boolean).length;
|
|
149
|
+
ops.push({
|
|
150
|
+
op: "add",
|
|
151
|
+
path: "/scenes/-",
|
|
152
|
+
value: {
|
|
153
|
+
id: `cap-cue-${index}`,
|
|
154
|
+
type: "caption",
|
|
155
|
+
layer: "overlay",
|
|
156
|
+
over: scene.id,
|
|
157
|
+
offset: start,
|
|
158
|
+
duration: end,
|
|
159
|
+
props: { text: cue.text },
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
report.push(`cue ${index} over "${scene.id}" at ${start}s for ${round(end)}s`);
|
|
163
|
+
}
|
|
164
|
+
if (ops.length === 0)
|
|
165
|
+
return EXIT.USAGE;
|
|
166
|
+
return { ops, report, words, omitted };
|
|
167
|
+
};
|
|
168
|
+
/** Applies the ops in memory and validates, so `mk patch --ops` cannot fail on our output. */
|
|
169
|
+
const validateOps = (board, plan, io) => {
|
|
170
|
+
const validBoard = validateStoryboardFull(board);
|
|
171
|
+
if (!validBoard.ok) {
|
|
172
|
+
io.stderr(`mk captions: storyboard is invalid: ${formatErrors(validBoard.errors)}\n`);
|
|
173
|
+
return EXIT.USAGE;
|
|
174
|
+
}
|
|
175
|
+
const next = {
|
|
176
|
+
...validBoard.value,
|
|
177
|
+
scenes: [...validBoard.value.scenes, ...plan.ops.map((op) => op.value)],
|
|
178
|
+
};
|
|
179
|
+
const valid = validateStoryboardFull(next);
|
|
180
|
+
if (valid.ok)
|
|
181
|
+
return undefined;
|
|
182
|
+
io.stderr(`mk captions: the caption overlays would not validate: ${formatErrors(valid.errors)}\n`);
|
|
183
|
+
return EXIT.USAGE;
|
|
184
|
+
};
|
|
185
|
+
const writeFile = async (path, text, io) => {
|
|
186
|
+
try {
|
|
187
|
+
await io.host.writeText(path, text);
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
io.stderr(`mk captions: cannot write ${path}: ${error.message}\n`);
|
|
192
|
+
return EXIT.ERROR;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
export const captionsCommand = {
|
|
196
|
+
name: "captions",
|
|
197
|
+
summary: "turn voice word timings (or SRT/VTT) into caption overlay ops; export SRT/VTT",
|
|
198
|
+
async run(args, io) {
|
|
199
|
+
const parsed = parseFlags("captions", USAGE, args, FLAGS, io);
|
|
200
|
+
if (parsed.kind === "exit")
|
|
201
|
+
return parsed.code;
|
|
202
|
+
const [manifestPath, ...extra] = parsed.positionals;
|
|
203
|
+
if (extra.length > 0) {
|
|
204
|
+
return usageError("captions", USAGE, `unexpected argument "${extra[0]}"`, io).code;
|
|
205
|
+
}
|
|
206
|
+
const storyboard = textFlag(parsed.flags, "storyboard");
|
|
207
|
+
const fromSrt = textFlag(parsed.flags, "from-srt");
|
|
208
|
+
const fromVtt = textFlag(parsed.flags, "from-vtt");
|
|
209
|
+
if (fromSrt !== undefined && fromVtt !== undefined) {
|
|
210
|
+
return usageError("captions", USAGE, "--from-srt and --from-vtt conflict", io).code;
|
|
211
|
+
}
|
|
212
|
+
if ((fromSrt !== undefined || fromVtt !== undefined) && manifestPath !== undefined) {
|
|
213
|
+
return usageError("captions", USAGE, "give a manifest or --from-*, not both", io).code;
|
|
214
|
+
}
|
|
215
|
+
const importPath = fromSrt ?? fromVtt;
|
|
216
|
+
if (manifestPath === undefined && importPath === undefined) {
|
|
217
|
+
return usageError("captions", USAGE, "missing <voice-manifest.json> or --from-srt/--from-vtt", io).code;
|
|
218
|
+
}
|
|
219
|
+
const wantsOps = textFlag(parsed.flags, "out") !== undefined || storyboard !== undefined;
|
|
220
|
+
const wantsExport = textFlag(parsed.flags, "srt") !== undefined || textFlag(parsed.flags, "vtt") !== undefined;
|
|
221
|
+
if (wantsOps && storyboard === undefined) {
|
|
222
|
+
return usageError("captions", USAGE, "--storyboard is required to emit caption ops", io).code;
|
|
223
|
+
}
|
|
224
|
+
if (!wantsOps && !wantsExport) {
|
|
225
|
+
return usageError("captions", USAGE, "nothing to do: give --out, --storyboard, --srt or --vtt", io).code;
|
|
226
|
+
}
|
|
227
|
+
let board;
|
|
228
|
+
if (wantsOps) {
|
|
229
|
+
const loaded = await readJsonFile(storyboard, io.host);
|
|
230
|
+
if (!loaded.ok) {
|
|
231
|
+
io.stderr(`mk captions: ${loaded.message}\n`);
|
|
232
|
+
return EXIT.USAGE;
|
|
233
|
+
}
|
|
234
|
+
board = loaded.value;
|
|
235
|
+
}
|
|
236
|
+
let plan;
|
|
237
|
+
let manifest;
|
|
238
|
+
if (importPath !== undefined) {
|
|
239
|
+
let text;
|
|
240
|
+
try {
|
|
241
|
+
text = await io.host.readText(importPath);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
io.stderr(`mk captions: cannot read ${importPath}\n`);
|
|
245
|
+
return EXIT.USAGE;
|
|
246
|
+
}
|
|
247
|
+
const cues = parseCues(text, fromSrt !== undefined ? "srt" : "vtt");
|
|
248
|
+
if (cues.length === 0) {
|
|
249
|
+
io.stderr(`mk captions: ${importPath} has no readable cues\n`);
|
|
250
|
+
return EXIT.USAGE;
|
|
251
|
+
}
|
|
252
|
+
const planned = planFromCues(board, cues, io);
|
|
253
|
+
if (typeof planned === "number")
|
|
254
|
+
return planned;
|
|
255
|
+
plan = planned;
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
const loaded = await readJsonFile(manifestPath, io.host);
|
|
259
|
+
if (!loaded.ok) {
|
|
260
|
+
io.stderr(`mk captions: ${loaded.message}\n`);
|
|
261
|
+
return EXIT.USAGE;
|
|
262
|
+
}
|
|
263
|
+
const parsedManifest = TimingManifestSchema.safeParse(loaded.value);
|
|
264
|
+
if (!parsedManifest.success) {
|
|
265
|
+
const problems = parsedManifest.error.issues
|
|
266
|
+
.map((issue) => `${issue.path.join("/") || "/"}: ${issue.message}`)
|
|
267
|
+
.join("; ");
|
|
268
|
+
io.stderr(`mk captions: invalid voice manifest: ${problems}\n`);
|
|
269
|
+
return EXIT.USAGE;
|
|
270
|
+
}
|
|
271
|
+
manifest = parsedManifest.data;
|
|
272
|
+
const hasWords = manifest.scenes.some((scene) => (scene.words?.length ?? 0) > 0) ||
|
|
273
|
+
(manifest.audio?.words.length ?? 0) > 0;
|
|
274
|
+
if (!hasWords) {
|
|
275
|
+
io.stderr("mk captions: the manifest has no words\n");
|
|
276
|
+
return EXIT.USAGE;
|
|
277
|
+
}
|
|
278
|
+
if (wantsOps) {
|
|
279
|
+
const valid = validateStoryboardFull(board);
|
|
280
|
+
if (!valid.ok) {
|
|
281
|
+
io.stderr(`mk captions: storyboard is invalid: ${formatErrors(valid.errors)}\n`);
|
|
282
|
+
return EXIT.USAGE;
|
|
283
|
+
}
|
|
284
|
+
const planned = planFromManifest(valid.value, manifest);
|
|
285
|
+
if (typeof planned === "number") {
|
|
286
|
+
io.stderr("mk captions: no scene of the storyboard has words inside its bound\n");
|
|
287
|
+
return EXIT.USAGE;
|
|
288
|
+
}
|
|
289
|
+
plan = planned;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (plan !== undefined) {
|
|
293
|
+
const invalid = validateOps(board, plan, io);
|
|
294
|
+
if (invalid !== undefined)
|
|
295
|
+
return invalid;
|
|
296
|
+
for (const line of plan.report)
|
|
297
|
+
io.stderr(`${line}\n`);
|
|
298
|
+
const text = `${JSON.stringify(plan.ops, null, 2)}\n`;
|
|
299
|
+
const out = textFlag(parsed.flags, "out");
|
|
300
|
+
if (out === undefined) {
|
|
301
|
+
io.stdout(text);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
const written = await writeFile(out, text, io);
|
|
305
|
+
if (written !== undefined)
|
|
306
|
+
return written;
|
|
307
|
+
const summary = `captions ok | ${plan.ops.length} overlays, ${plan.words} words, ${plan.omitted} omitted -> ${out}`;
|
|
308
|
+
if (parsed.flags.json === true) {
|
|
309
|
+
io.stdout(`${JSON.stringify({ ok: true, out, overlays: plan.ops.length, words: plan.words, omitted: plan.omitted })}\n`);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
io.stdout(`${summary}\n`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (manifest !== undefined) {
|
|
317
|
+
const exportCues = manifest.scenes
|
|
318
|
+
.filter((scene) => (scene.words?.length ?? 0) > 0)
|
|
319
|
+
.map((scene) => ({
|
|
320
|
+
start: scene.start,
|
|
321
|
+
end: round(scene.start + scene.duration),
|
|
322
|
+
text: (scene.words ?? []).map((word) => word.w).join(" "),
|
|
323
|
+
}));
|
|
324
|
+
const srt = textFlag(parsed.flags, "srt");
|
|
325
|
+
if (srt !== undefined) {
|
|
326
|
+
const written = await writeFile(srt, formatSrt(exportCues), io);
|
|
327
|
+
if (written !== undefined)
|
|
328
|
+
return written;
|
|
329
|
+
io.stdout(`captions srt | ${exportCues.length} cues -> ${srt}\n`);
|
|
330
|
+
}
|
|
331
|
+
const vtt = textFlag(parsed.flags, "vtt");
|
|
332
|
+
if (vtt !== undefined) {
|
|
333
|
+
const written = await writeFile(vtt, formatVtt(exportCues), io);
|
|
334
|
+
if (written !== undefined)
|
|
335
|
+
return written;
|
|
336
|
+
io.stdout(`captions vtt | ${exportCues.length} cues -> ${vtt}\n`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return EXIT.OK;
|
|
340
|
+
},
|
|
341
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Cue parsing/formatting for SRT and WebVTT (the caption interchange formats). */
|
|
2
|
+
export interface Cue {
|
|
3
|
+
readonly start: number;
|
|
4
|
+
readonly end: number;
|
|
5
|
+
readonly text: string;
|
|
6
|
+
}
|
|
7
|
+
/** Parses cue blocks; an index line and the WEBVTT header are ignored, malformed cues are skipped. */
|
|
8
|
+
export declare function parseCues(text: string, kind: "srt" | "vtt"): Cue[];
|
|
9
|
+
export declare function formatSrt(cues: readonly Cue[]): string;
|
|
10
|
+
export declare function formatVtt(cues: readonly Cue[]): string;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Cue parsing/formatting for SRT and WebVTT (the caption interchange formats). */
|
|
2
|
+
const TIMING = /^(\d{1,2}:)?(\d{2}):(\d{2})[.,](\d{1,3})$/;
|
|
3
|
+
const seconds = (hh, mm, ss, ms) => Number(hh?.slice(0, -1) ?? 0) * 3600 +
|
|
4
|
+
Number(mm) * 60 +
|
|
5
|
+
Number(ss) +
|
|
6
|
+
Number(ms.padEnd(3, "0")) / 1000;
|
|
7
|
+
const parseStamp = (text) => {
|
|
8
|
+
const match = TIMING.exec(text.trim());
|
|
9
|
+
if (match === null)
|
|
10
|
+
return undefined;
|
|
11
|
+
return seconds(match[1], match[2] ?? "0", match[3] ?? "0", match[4] ?? "0");
|
|
12
|
+
};
|
|
13
|
+
const clean = (text) => text
|
|
14
|
+
.replace(/<[^>]+>/g, "")
|
|
15
|
+
.replace(/\s+/g, " ")
|
|
16
|
+
.trim();
|
|
17
|
+
/** Parses cue blocks; an index line and the WEBVTT header are ignored, malformed cues are skipped. */
|
|
18
|
+
export function parseCues(text, kind) {
|
|
19
|
+
const blocks = text.replaceAll("\r\n", "\n").split(/\n{2,}/);
|
|
20
|
+
const cues = [];
|
|
21
|
+
for (const block of blocks) {
|
|
22
|
+
const lines = block.split("\n").map((line) => line.trim());
|
|
23
|
+
if (lines.length === 0)
|
|
24
|
+
continue;
|
|
25
|
+
const timingIndex = lines.findIndex((line) => line.includes("-->"));
|
|
26
|
+
if (timingIndex === -1)
|
|
27
|
+
continue;
|
|
28
|
+
const [from = "", to = ""] = (lines[timingIndex] ?? "").split("-->");
|
|
29
|
+
const start = parseStamp(from);
|
|
30
|
+
const end = parseStamp(to.trim().split(/\s+/)[0] ?? "");
|
|
31
|
+
if (start === undefined || end === undefined || end <= start)
|
|
32
|
+
continue;
|
|
33
|
+
const body = clean(lines.slice(timingIndex + 1).join(" "));
|
|
34
|
+
if (body === "")
|
|
35
|
+
continue;
|
|
36
|
+
cues.push({ start, end, text: body });
|
|
37
|
+
}
|
|
38
|
+
if (kind === "vtt")
|
|
39
|
+
cues.sort((a, b) => a.start - b.start);
|
|
40
|
+
return cues;
|
|
41
|
+
}
|
|
42
|
+
const stamp = (value, separator) => {
|
|
43
|
+
const total = Math.max(0, Math.round(value * 1000));
|
|
44
|
+
const ms = total % 1000;
|
|
45
|
+
const sec = Math.floor(total / 1000) % 60;
|
|
46
|
+
const min = Math.floor(total / 60_000) % 60;
|
|
47
|
+
const hour = Math.floor(total / 3_600_000);
|
|
48
|
+
const two = (n) => String(n).padStart(2, "0");
|
|
49
|
+
return `${two(hour)}:${two(min)}:${two(sec)}${separator}${String(ms).padStart(3, "0")}`;
|
|
50
|
+
};
|
|
51
|
+
export function formatSrt(cues) {
|
|
52
|
+
return `${cues
|
|
53
|
+
.map((cue, index) => `${index + 1}\n${stamp(cue.start, ",")} --> ${stamp(cue.end, ",")}\n${cue.text}`)
|
|
54
|
+
.join("\n\n")}\n`;
|
|
55
|
+
}
|
|
56
|
+
export function formatVtt(cues) {
|
|
57
|
+
const body = cues
|
|
58
|
+
.map((cue) => `${stamp(cue.start, ".")} --> ${stamp(cue.end, ".")}\n${cue.text}`)
|
|
59
|
+
.join("\n\n");
|
|
60
|
+
return `WEBVTT\n\n${body}\n`;
|
|
61
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { sceneDocs } from "@anvpx/motion-build";
|
|
3
|
+
import { parseFlags, requirePositional, textFlag, usageError } from "../args.js";
|
|
4
|
+
import { EXIT } from "../exit-codes.js";
|
|
5
|
+
import { detailJson, formatCatalogMarkdown, formatDetail, formatIndex, indexJson, } from "./format.js";
|
|
6
|
+
const USAGE = `mk catalog [<scene>] [--json] [--write [--out <file>]]
|
|
7
|
+
|
|
8
|
+
No argument: one line per scene (type, layer, purpose, required fields).
|
|
9
|
+
With <scene>: its fields, types, defaults and a minimal example.
|
|
10
|
+
--json one compact JSON line instead of text
|
|
11
|
+
--write write CATALOG.md in the current directory (or --out <file>) from the registry
|
|
12
|
+
`;
|
|
13
|
+
const FLAGS = {
|
|
14
|
+
json: { type: "boolean" },
|
|
15
|
+
write: { type: "boolean" },
|
|
16
|
+
out: { type: "string" },
|
|
17
|
+
};
|
|
18
|
+
/** Default `--write` destination: the invoking directory, never the repo root. */
|
|
19
|
+
const defaultCatalogFile = () => join(process.cwd(), "CATALOG.md");
|
|
20
|
+
const pick = (docs, type) => docs.find((doc) => doc.type === type);
|
|
21
|
+
async function writeCatalog(docs, flags, positionals, io) {
|
|
22
|
+
if (positionals.length > 0)
|
|
23
|
+
return usageError("catalog", USAGE, "--write takes no scene", io).code;
|
|
24
|
+
const file = textFlag(flags, "out") ?? defaultCatalogFile();
|
|
25
|
+
const text = formatCatalogMarkdown(docs);
|
|
26
|
+
await io.host.writeText(file, text);
|
|
27
|
+
io.stdout(`wrote ${file} (${text.length} chars, ${docs.length} scenes)
|
|
28
|
+
`);
|
|
29
|
+
return EXIT.OK;
|
|
30
|
+
}
|
|
31
|
+
function showScene(docs, positionals, json, io) {
|
|
32
|
+
const type = requirePositional("catalog", USAGE, positionals, "<scene>", io);
|
|
33
|
+
if (typeof type !== "string")
|
|
34
|
+
return type.code;
|
|
35
|
+
const doc = pick(docs, type);
|
|
36
|
+
if (!doc) {
|
|
37
|
+
const known = docs.map((d) => d.type).join("|");
|
|
38
|
+
return usageError("catalog", USAGE, `unknown scene "${type}"; use one of: ${known}`, io).code;
|
|
39
|
+
}
|
|
40
|
+
io.stdout(json ? detailJson(doc) : formatDetail(doc));
|
|
41
|
+
return EXIT.OK;
|
|
42
|
+
}
|
|
43
|
+
export const catalogCommand = {
|
|
44
|
+
name: "catalog",
|
|
45
|
+
summary: "list scene types, or show one scene's fields and example; --write refreshes CATALOG.md",
|
|
46
|
+
async run(args, io) {
|
|
47
|
+
const parsed = parseFlags("catalog", USAGE, args, FLAGS, io);
|
|
48
|
+
if (parsed.kind === "exit")
|
|
49
|
+
return parsed.code;
|
|
50
|
+
const { flags, positionals } = parsed;
|
|
51
|
+
const docs = sceneDocs();
|
|
52
|
+
if (flags.write === true)
|
|
53
|
+
return writeCatalog(docs, flags, positionals, io);
|
|
54
|
+
const json = flags.json === true;
|
|
55
|
+
if (positionals.length > 0)
|
|
56
|
+
return showScene(docs, positionals, json, io);
|
|
57
|
+
io.stdout(json ? indexJson(docs) : formatIndex(docs));
|
|
58
|
+
return EXIT.OK;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { SceneDoc } from "@anvpx/motion-build";
|
|
2
|
+
/** Constitution 8.4: the index stays within 3k tokens (estimated as chars / 4). */
|
|
3
|
+
export declare const INDEX_TOKEN_BUDGET = 3000;
|
|
4
|
+
export declare const CHARS_PER_TOKEN = 4;
|
|
5
|
+
/** One line per scene: `type | layer | purpose | required fields`. */
|
|
6
|
+
export declare const formatIndex: (docs: readonly SceneDoc[]) => string;
|
|
7
|
+
/** Fields (`*` = required), types, defaults and a minimal example of one scene. */
|
|
8
|
+
export declare const formatDetail: (doc: SceneDoc) => string;
|
|
9
|
+
export declare const indexJson: (docs: readonly SceneDoc[]) => string;
|
|
10
|
+
export declare const detailJson: (doc: SceneDoc) => string;
|
|
11
|
+
/** The committed CATALOG.md: deterministic, generated, index first. */
|
|
12
|
+
export declare const formatCatalogMarkdown: (docs: readonly SceneDoc[]) => string;
|
|
13
|
+
export declare const estimateTokens: (text: string) => number;
|