@glissade/cli 0.13.0-pre.3 → 0.14.0-pre.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/dist/captions.js +12 -2
- package/dist/cli.js +3 -0
- package/dist/index.d.ts +9 -2
- package/dist/locale.js +30 -0
- package/dist/render.js +78 -6
- package/package.json +10 -10
package/dist/captions.js
CHANGED
|
@@ -21,8 +21,18 @@ function parseCaptionsMode(raw) {
|
|
|
21
21
|
if (raw === "sidecar" || raw === "off") return raw;
|
|
22
22
|
throw new Error(`--captions must be burn, sidecar, or off (got '${raw}')`);
|
|
23
23
|
}
|
|
24
|
-
/**
|
|
25
|
-
|
|
24
|
+
/**
|
|
25
|
+
* `<module>.narration.timing.json`, when the scene has been narrated. With a
|
|
26
|
+
* `locale`, PREFER the locale-tagged sibling `<module>.<locale>.narration.timing.json`
|
|
27
|
+
* (0.14 localization core) and fall back to the base sibling when it is absent —
|
|
28
|
+
* so a locale that reuses the base narration still renders. No `locale` (the
|
|
29
|
+
* base path) resolves the base sibling, byte-identical to today.
|
|
30
|
+
*/
|
|
31
|
+
function timingPathFor(modulePath, locale) {
|
|
32
|
+
if (locale !== void 0 && locale !== "") {
|
|
33
|
+
const localeCandidate = modulePath.replace(/\.[jt]sx?$/, "") + `.${locale}.narration.timing.json`;
|
|
34
|
+
if (existsSync(localeCandidate)) return localeCandidate;
|
|
35
|
+
}
|
|
26
36
|
const candidate = modulePath.replace(/\.[jt]sx?$/, "") + ".narration.timing.json";
|
|
27
37
|
return existsSync(candidate) ? candidate : null;
|
|
28
38
|
}
|
package/dist/cli.js
CHANGED
|
@@ -110,6 +110,8 @@ render options:
|
|
|
110
110
|
(YouTube needs the 1st chapter at 0:00 — auto-anchored — and each chapter >= 10s; author cue ts accordingly)
|
|
111
111
|
--chapters-kind <k[,k]> cue kinds that become VTT chapters (default: chapter); cues.json keeps all kinds
|
|
112
112
|
--strict fail on an unregistered font family or an uncovered glyph (§3.6; default: warn)
|
|
113
|
+
--locale <code> resolve the scene against messages.<code>.json (node-id text + free-standing t() keys) and prefer
|
|
114
|
+
the <base>.<code>.narration.timing.json sibling (0.14). No --locale resolves the BASE files
|
|
113
115
|
|
|
114
116
|
diff options (DisplayList diff vs a committed baseline — exits non-zero on any divergence):
|
|
115
117
|
--at <t> time in SECONDS to evaluate the scene at (required)
|
|
@@ -458,6 +460,7 @@ async function main() {
|
|
|
458
460
|
...flags.has("lossless-intermediate") ? { losslessIntermediate: true } : {},
|
|
459
461
|
...flags.has("allow-gpu-shards") ? { allowGpuShards: true } : {},
|
|
460
462
|
...cache !== void 0 ? { cache } : {},
|
|
463
|
+
...flags.has("locale") && flags.get("locale") ? { locale: flags.get("locale") } : {},
|
|
461
464
|
captions: parseCaptionsModeOrFail(flags.get("captions")),
|
|
462
465
|
narration: flags.get("narration") === "off" ? "off" : "auto",
|
|
463
466
|
music: flags.get("music") === "off" ? "off" : "auto",
|
package/dist/index.d.ts
CHANGED
|
@@ -200,6 +200,13 @@ interface RenderOptions {
|
|
|
200
200
|
mode: CacheMode;
|
|
201
201
|
maxSize?: number;
|
|
202
202
|
};
|
|
203
|
+
/**
|
|
204
|
+
* --locale <code> (0.14 localization core): resolve the scene against a
|
|
205
|
+
* per-locale message table (`messages.<code>.json`) and prefer the
|
|
206
|
+
* locale-tagged narration sibling (`<base>.<code>.narration.timing.json`).
|
|
207
|
+
* Omitted (the base path) resolves the BASE files → byte-identical to today.
|
|
208
|
+
*/
|
|
209
|
+
locale?: string;
|
|
203
210
|
onProgress?: (frame: number, total: number) => void;
|
|
204
211
|
}
|
|
205
212
|
declare class SceneModuleError extends Error {
|
|
@@ -211,7 +218,7 @@ declare class SceneModuleError extends Error {
|
|
|
211
218
|
* ranges are rejected, since a frame index is an integer.
|
|
212
219
|
*/
|
|
213
220
|
|
|
214
|
-
declare function loadSceneModule(modulePath: string): Promise<SceneModule>;
|
|
221
|
+
declare function loadSceneModule(modulePath: string, locale?: string): Promise<SceneModule>;
|
|
215
222
|
declare function ffmpegAvailable(): boolean;
|
|
216
223
|
declare function render(opts: RenderOptions): Promise<{
|
|
217
224
|
frames: number;
|
|
@@ -223,7 +230,7 @@ declare function render(opts: RenderOptions): Promise<{
|
|
|
223
230
|
* render/shard path) and `buildMixWav` (the measure-loudness path), so the mix
|
|
224
231
|
* CONTENT measured at commit-time is byte-for-byte the mix rendered later.
|
|
225
232
|
*/
|
|
226
|
-
declare function collectAudioClips(opts: Pick<RenderOptions, 'modulePath' | 'narration' | 'music' | 'sfx'>, timelineClips: AudioClip[]): Promise<AudioClip[]>;
|
|
233
|
+
declare function collectAudioClips(opts: Pick<RenderOptions, 'modulePath' | 'narration' | 'music' | 'sfx' | 'locale'>, timelineClips: AudioClip[]): Promise<AudioClip[]>;
|
|
227
234
|
/**
|
|
228
235
|
* Resolve the ACTUAL audio files that feed the final mix (timeline clips +
|
|
229
236
|
* auto-mixed narration/music/sfx), as absolute paths. This is what the mixHash
|
package/dist/locale.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
//#region src/locale.ts
|
|
4
|
+
/**
|
|
5
|
+
* gs render --locale <code> (0.14 localization core): resolve the per-locale
|
|
6
|
+
* message table + the locale-tagged narration timing sibling for a scene module.
|
|
7
|
+
*
|
|
8
|
+
* The render-time half of @glissade/core/i18n. The base (no --locale) path
|
|
9
|
+
* resolves the BASE files (`messages.json` is NOT consulted, the base narration
|
|
10
|
+
* sibling is used) → byte-identical to today. Every locale is opt-in.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* The per-locale message table filename, resolved RELATIVE to the scene module
|
|
14
|
+
* directory: `messages.<locale>.json`. DEFAULT convention; one-line change.
|
|
15
|
+
*/
|
|
16
|
+
function messagesFileFor(modulePath, locale) {
|
|
17
|
+
return join(dirname(modulePath), `messages.${locale}.json`);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Load the message table for a locale, or `undefined` when no `messages.<locale>.json`
|
|
21
|
+
* exists (a locale with only node-id / narration text and no free-standing t()
|
|
22
|
+
* keys is valid). The base path never calls this.
|
|
23
|
+
*/
|
|
24
|
+
function loadMessageTable(modulePath, locale) {
|
|
25
|
+
const file = messagesFileFor(modulePath, locale);
|
|
26
|
+
if (!existsSync(file)) return void 0;
|
|
27
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
export { loadMessageTable };
|
package/dist/render.js
CHANGED
|
@@ -9,6 +9,59 @@ import { createJiti } from "jiti";
|
|
|
9
9
|
import { buildFontRegistry } from "@glissade/core";
|
|
10
10
|
import { evaluate, validateSceneFonts, withDeterminismGuards } from "@glissade/scene";
|
|
11
11
|
import { SkiaBackend } from "@glissade/backend-skia";
|
|
12
|
+
//#region src/assetValidation.ts
|
|
13
|
+
/**
|
|
14
|
+
* Walk a scene's node tree and collect every Image/Video asset reference. Pure
|
|
15
|
+
* (no playhead) — the asset id a node references is structural, not a function
|
|
16
|
+
* of time. `assetId` may be `undefined` at runtime (a JS caller that passed a
|
|
17
|
+
* `src` URL instead of an `assetId` — the common codegen mistake), even though
|
|
18
|
+
* scene's type says `string`; surfaced as-is.
|
|
19
|
+
*/
|
|
20
|
+
function collectAssetReferences(root) {
|
|
21
|
+
const out = [];
|
|
22
|
+
const visit = (node) => {
|
|
23
|
+
const kind = node.constructor.assetKind;
|
|
24
|
+
if (kind !== void 0) out.push({
|
|
25
|
+
assetId: node.assetId,
|
|
26
|
+
kind,
|
|
27
|
+
nodeId: node.id
|
|
28
|
+
});
|
|
29
|
+
if (node.children) for (const child of node.children) visit(child);
|
|
30
|
+
};
|
|
31
|
+
visit(root);
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* An Image/Video node references an asset id that isn't declared in
|
|
36
|
+
* `timeline.assets`. Distinct from ColdAssetError (a declared-but-not-warmed
|
|
37
|
+
* asset) — this is an AUTHORING mistake caught before evaluation, and the
|
|
38
|
+
* message names the most common cause: passing a `src` URL instead of an
|
|
39
|
+
* `assetId` + a `timeline.assets` entry.
|
|
40
|
+
*/
|
|
41
|
+
var UnknownAssetError = class extends Error {
|
|
42
|
+
assetId;
|
|
43
|
+
declared;
|
|
44
|
+
constructor(ref, declared) {
|
|
45
|
+
const id = ref.assetId;
|
|
46
|
+
const at = ref.nodeId !== void 0 ? ` on ${ref.kind} node '${ref.nodeId}'` : ` on an ${ref.kind} node`;
|
|
47
|
+
const declaredList = declared.length > 0 ? declared.join(", ") : "(none)";
|
|
48
|
+
super(`assetId ${id === void 0 ? "<undefined>" : `'${id}'`}${at} is not declared in timeline.assets (declared: ${declaredList}) — an Image/Video needs an \`assetId\` plus a \`timeline.assets\` entry { kind, url }, not a \`src\` URL; remote URLs are not fetched at render (§2.5 offline determinism)`);
|
|
49
|
+
this.name = "UnknownAssetError";
|
|
50
|
+
this.assetId = id;
|
|
51
|
+
this.declared = declared;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Pre-validate Image/Video asset references against the declared timeline asset
|
|
56
|
+
* ids. Throws an UnknownAssetError naming the real mistake when a referenced id
|
|
57
|
+
* is undefined or absent from `declaredIds`. A no-op when every reference is
|
|
58
|
+
* declared.
|
|
59
|
+
*/
|
|
60
|
+
function validateAssetReferences(refs, declaredIds) {
|
|
61
|
+
const declared = new Set(declaredIds);
|
|
62
|
+
for (const ref of refs) if (ref.assetId === void 0 || !declared.has(ref.assetId)) throw new UnknownAssetError(ref, [...declared]);
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
12
65
|
//#region src/render.ts
|
|
13
66
|
/**
|
|
14
67
|
* gs render (DESIGN.md §5.1d, §5.7): load a scene module, evaluate each frame,
|
|
@@ -46,7 +99,12 @@ function parseFrameRange(flag) {
|
|
|
46
99
|
if (b < a) throw new Error(`--range end (${b}) is before start (${a})`);
|
|
47
100
|
return [a, b];
|
|
48
101
|
}
|
|
49
|
-
async function loadSceneModule(modulePath) {
|
|
102
|
+
async function loadSceneModule(modulePath, locale) {
|
|
103
|
+
const { setMessageTable } = await import("@glissade/core/i18n");
|
|
104
|
+
if (locale !== void 0 && locale !== "") {
|
|
105
|
+
const { loadMessageTable } = await import("./locale.js");
|
|
106
|
+
setMessageTable(loadMessageTable(modulePath, locale));
|
|
107
|
+
} else setMessageTable(void 0);
|
|
50
108
|
const abs = isAbsolute(modulePath) ? modulePath : resolve(process.cwd(), modulePath);
|
|
51
109
|
const loaded = await createJiti(pathToFileURL(process.cwd() + "/").href).import(pathToFileURL(abs).href, { default: true });
|
|
52
110
|
if (typeof loaded?.createScene !== "function" || loaded?.timeline === void 0) throw new SceneModuleError(modulePath, "default export is not a SceneModule");
|
|
@@ -56,7 +114,7 @@ function ffmpegAvailable() {
|
|
|
56
114
|
return spawnSync("ffmpeg", ["-version"], { stdio: "ignore" }).status === 0;
|
|
57
115
|
}
|
|
58
116
|
async function render(opts) {
|
|
59
|
-
const mod = await loadSceneModule(opts.modulePath);
|
|
117
|
+
const mod = await loadSceneModule(opts.modulePath, opts.locale);
|
|
60
118
|
const scene = mod.createScene();
|
|
61
119
|
const { resolveRenderDoc } = await import("./machines.js").then((n) => n.n);
|
|
62
120
|
let doc = resolveRenderDoc(mod, scene, {
|
|
@@ -64,6 +122,14 @@ async function render(opts) {
|
|
|
64
122
|
...opts.state !== void 0 ? { state: opts.state } : {},
|
|
65
123
|
...opts.force !== void 0 ? { force: opts.force } : {}
|
|
66
124
|
});
|
|
125
|
+
if (opts.locale !== void 0 && opts.locale !== "") {
|
|
126
|
+
const { loadMessageTable } = await import("./locale.js");
|
|
127
|
+
const table = loadMessageTable(opts.modulePath, opts.locale);
|
|
128
|
+
if (table) {
|
|
129
|
+
const { localize } = await import("@glissade/core/i18n");
|
|
130
|
+
doc = localize(doc, table, { locale: opts.locale });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
67
133
|
const fps = opts.fps ?? doc.fps ?? 60;
|
|
68
134
|
const captionsMode = opts.captions ?? "burn";
|
|
69
135
|
const { hideCaptionsDoc, timingPathFor, writeCaptionSidecars } = await import("./captions.js").then((n) => n.t);
|
|
@@ -113,6 +179,7 @@ async function render(opts) {
|
|
|
113
179
|
const { loadYogaLayoutEngine } = await import("@glissade/scene/layout");
|
|
114
180
|
await loadYogaLayoutEngine();
|
|
115
181
|
}
|
|
182
|
+
validateAssetReferences(collectAssetReferences(scene.root), Object.keys(doc.assets ?? {}));
|
|
116
183
|
const videoSources = [];
|
|
117
184
|
const { resolveAssetPath: resolveAsset } = await import("./audioMix.js").then((n) => n.i);
|
|
118
185
|
const { createHash } = await import("node:crypto");
|
|
@@ -139,6 +206,8 @@ async function render(opts) {
|
|
|
139
206
|
}
|
|
140
207
|
}
|
|
141
208
|
}
|
|
209
|
+
const { GlobalFonts: GlobalFontsForValidation } = await import("@napi-rs/canvas");
|
|
210
|
+
const osFamilies = new Set(GlobalFontsForValidation.families.map((f) => f.family.toLowerCase()));
|
|
142
211
|
await validateSceneFonts(scene, doc, async (url) => {
|
|
143
212
|
try {
|
|
144
213
|
const buf = await readFile(resolveAsset(url, opts.modulePath));
|
|
@@ -146,7 +215,10 @@ async function render(opts) {
|
|
|
146
215
|
} catch {
|
|
147
216
|
return;
|
|
148
217
|
}
|
|
149
|
-
}, {
|
|
218
|
+
}, {
|
|
219
|
+
mode: opts.strictFonts ? "strict" : "dev",
|
|
220
|
+
osFamilies
|
|
221
|
+
});
|
|
150
222
|
for (const [assetId, ref] of Object.entries(doc.assets ?? {})) if (ref.kind === "font") {} else if (ref.kind === "image") {
|
|
151
223
|
const { loadImage } = await import("@napi-rs/canvas");
|
|
152
224
|
const imgPath = resolveAsset(ref.url, opts.modulePath);
|
|
@@ -208,7 +280,7 @@ async function render(opts) {
|
|
|
208
280
|
for (const source of videoSources) source.close();
|
|
209
281
|
const emitSidecars = (target) => {
|
|
210
282
|
if (captionsMode === "off") return;
|
|
211
|
-
const timingPath = timingPathFor(opts.modulePath);
|
|
283
|
+
const timingPath = timingPathFor(opts.modulePath, opts.locale);
|
|
212
284
|
if (!timingPath) {
|
|
213
285
|
if (captionsMode === "sidecar") process.stderr.write("note: --captions sidecar: no narration timing manifest found; run gs narrate first\n");
|
|
214
286
|
return;
|
|
@@ -305,7 +377,7 @@ async function collectAudioClips(opts, timelineClips) {
|
|
|
305
377
|
const audioClips = [...timelineClips];
|
|
306
378
|
const { bedAlreadyReferenced, buildMusicClip, buildNarrationClips, musicPathFor } = await import("./music.js");
|
|
307
379
|
if ((opts.narration ?? "auto") === "auto") {
|
|
308
|
-
const narrationPath = timingPathFor(opts.modulePath);
|
|
380
|
+
const narrationPath = timingPathFor(opts.modulePath, opts.locale);
|
|
309
381
|
if (narrationPath) {
|
|
310
382
|
const voice = buildNarrationClips(narrationPath);
|
|
311
383
|
if (voice) if (voice.clips.some((c) => bedAlreadyReferenced(audioClips, c.asset.url, opts.modulePath))) process.stderr.write("note: narration already in the timeline audio — auto-mix skipped\n");
|
|
@@ -318,7 +390,7 @@ async function collectAudioClips(opts, timelineClips) {
|
|
|
318
390
|
if ((opts.music ?? "auto") === "auto") {
|
|
319
391
|
const musicPath = musicPathFor(opts.modulePath);
|
|
320
392
|
if (musicPath) {
|
|
321
|
-
const bed = buildMusicClip(musicPath, timingPathFor(opts.modulePath));
|
|
393
|
+
const bed = buildMusicClip(musicPath, timingPathFor(opts.modulePath, opts.locale));
|
|
322
394
|
if (bed) if (bedAlreadyReferenced(audioClips, bed.clip.asset.url, opts.modulePath)) process.stderr.write("note: music bed already in the timeline audio — auto-mix skipped\n");
|
|
323
395
|
else {
|
|
324
396
|
audioClips.push(bed.clip);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glissade/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0-pre.0",
|
|
4
4
|
"description": "glissade CLI: headless rendering via backend-skia (+ FFmpeg mux).",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
@@ -23,15 +23,15 @@
|
|
|
23
23
|
"@napi-rs/canvas": "^0.1.65",
|
|
24
24
|
"esbuild": "^0.28.0",
|
|
25
25
|
"jiti": "^2.4.2",
|
|
26
|
-
"@glissade/backend-skia": "0.
|
|
27
|
-
"@glissade/core": "0.
|
|
28
|
-
"@glissade/interact": "0.
|
|
29
|
-
"@glissade/lottie": "0.
|
|
30
|
-
"@glissade/svg": "0.
|
|
31
|
-
"@glissade/narrate": "0.
|
|
32
|
-
"@glissade/player": "0.
|
|
33
|
-
"@glissade/scene": "0.
|
|
34
|
-
"@glissade/sfx": "0.
|
|
26
|
+
"@glissade/backend-skia": "0.14.0-pre.0",
|
|
27
|
+
"@glissade/core": "0.14.0-pre.0",
|
|
28
|
+
"@glissade/interact": "0.14.0-pre.0",
|
|
29
|
+
"@glissade/lottie": "0.14.0-pre.0",
|
|
30
|
+
"@glissade/svg": "0.14.0-pre.0",
|
|
31
|
+
"@glissade/narrate": "0.14.0-pre.0",
|
|
32
|
+
"@glissade/player": "0.14.0-pre.0",
|
|
33
|
+
"@glissade/scene": "0.14.0-pre.0",
|
|
34
|
+
"@glissade/sfx": "0.14.0-pre.0"
|
|
35
35
|
},
|
|
36
36
|
"repository": {
|
|
37
37
|
"type": "git",
|