@glissade/narrate 0.67.0 → 0.68.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/index.d.ts +107 -19
- package/dist/index.js +140 -19
- package/package.json +3 -3
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AssetRef, AudioClip, Key, Track } from "@glissade/core";
|
|
2
|
-
import { FilterSpec, Text } from "@glissade/scene";
|
|
2
|
+
import { FilterSpec, Text, TextMeasurer } from "@glissade/scene";
|
|
3
3
|
import { SafeArea } from "@glissade/scene/diagnostics";
|
|
4
4
|
|
|
5
5
|
//#region src/index.d.ts
|
|
@@ -86,14 +86,13 @@ interface NarrationScript {
|
|
|
86
86
|
*/
|
|
87
87
|
align?: string;
|
|
88
88
|
/**
|
|
89
|
-
* Split long caption segments into timed sub-cues
|
|
90
|
-
*
|
|
91
|
-
*
|
|
89
|
+
* Split long caption segments into timed sub-cues. `{ maxChars }` = the legacy
|
|
90
|
+
* char-budget word-boundary split; `{ mode: 'band' }` = the measured band-fit split
|
|
91
|
+
* (per-locale sentence→clause→word, needs the render size/style via captionTrack's
|
|
92
|
+
* opts). Persisted so the burned track and the .srt/.vtt sidecars split identically.
|
|
92
93
|
* Omit for no split (the default).
|
|
93
94
|
*/
|
|
94
|
-
captionSplit?:
|
|
95
|
-
maxChars: number;
|
|
96
|
-
};
|
|
95
|
+
captionSplit?: CaptionSplitPolicy;
|
|
97
96
|
/**
|
|
98
97
|
* Anchor budgets (s): per-id ceilings on a segment's (or pause's) beat length,
|
|
99
98
|
* checked by `gs narration-lint` (Tier-1, can fail CI). Committed with the
|
|
@@ -165,10 +164,8 @@ interface NarrationTiming {
|
|
|
165
164
|
segments: TimedSegment[];
|
|
166
165
|
/** explicit pause windows, addressable like segments; omitted when none */
|
|
167
166
|
pauses?: TimedPause[];
|
|
168
|
-
/** caption split
|
|
169
|
-
captionSplit?:
|
|
170
|
-
maxChars: number;
|
|
171
|
-
};
|
|
167
|
+
/** caption split policy, committed so burned + sidecar split identically */
|
|
168
|
+
captionSplit?: CaptionSplitPolicy;
|
|
172
169
|
/**
|
|
173
170
|
* Anchor budgets (s) carried from the script — per id, segments + pauses.
|
|
174
171
|
* `gs narration-lint` reads them from the committed manifest (Tier-1). A
|
|
@@ -240,6 +237,23 @@ interface CaptionTrackOptions {
|
|
|
240
237
|
target?: string;
|
|
241
238
|
/** v1 granularity is per segment; 'word' is reserved (karaoke highlight, later) */
|
|
242
239
|
granularity?: 'segment';
|
|
240
|
+
/**
|
|
241
|
+
* Render context for `captionSplit: { mode: 'band' }` — the SAME `size` + `style`
|
|
242
|
+
* the paired `captionNode(size, style)` uses, so the band-fit split measures what
|
|
243
|
+
* the render lays out (measure-consistency). REQUIRED when the policy is band mode
|
|
244
|
+
* (throws without `size`); ignored for the legacy `{ maxChars }` path.
|
|
245
|
+
*/
|
|
246
|
+
size?: {
|
|
247
|
+
w: number;
|
|
248
|
+
h: number;
|
|
249
|
+
};
|
|
250
|
+
style?: CaptionStyle;
|
|
251
|
+
/** the measurer the render lays out with (band mode) — defaults to the process fallback. */
|
|
252
|
+
measurer?: TextMeasurer;
|
|
253
|
+
/** band-mode measurer-fail-loud opt-out (LOUD: an estimate-split may overflow at render). */
|
|
254
|
+
estimate?: boolean;
|
|
255
|
+
/** band-mode boundary locale (falls back to the policy's `locale`). */
|
|
256
|
+
locale?: string;
|
|
243
257
|
}
|
|
244
258
|
/** One caption cue within a segment's window. */
|
|
245
259
|
interface CaptionCue {
|
|
@@ -247,6 +261,23 @@ interface CaptionCue {
|
|
|
247
261
|
start: number;
|
|
248
262
|
end: number;
|
|
249
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* How to split long caption segments into sub-cues:
|
|
266
|
+
* - `{ maxChars }` — the legacy char-budget, word-boundary split ({@link splitCaption}).
|
|
267
|
+
* - `{ mode: 'band', locale? }` — the measured band-fit split ({@link captionAutoSplit}):
|
|
268
|
+
* breaks at per-locale sentence → clause → word so every cue fits the caption band at
|
|
269
|
+
* the min-legible floor. Needs the render context (size + style) supplied via
|
|
270
|
+
* {@link CaptionTrackOptions}. `maxLines` / `minLegiblePx` deliberately live in
|
|
271
|
+
* {@link CaptionStyle} (NOT here) — they must match the paired `captionNode`'s layout
|
|
272
|
+
* for measure-consistency, so there is ONE source of truth for them; only `locale`
|
|
273
|
+
* (a split-only, render-irrelevant concern) rides the policy.
|
|
274
|
+
*/
|
|
275
|
+
type CaptionSplitPolicy = {
|
|
276
|
+
maxChars: number;
|
|
277
|
+
} | {
|
|
278
|
+
mode: 'band';
|
|
279
|
+
locale?: string;
|
|
280
|
+
};
|
|
250
281
|
/**
|
|
251
282
|
* Split a segment's caption into timed sub-cues at ~`maxChars` (word-boundary).
|
|
252
283
|
* With per-word timings, each sub-cue is timed from its first word; without
|
|
@@ -285,16 +316,73 @@ interface CaptionStyle {
|
|
|
285
316
|
maxLines?: number;
|
|
286
317
|
/** floor for auto-shrink, as a fraction of the base font size (autoFit only); default 0.7 */
|
|
287
318
|
minScale?: number;
|
|
319
|
+
/**
|
|
320
|
+
* Absolute legibility floor in px — the auto-shrink (and the band-split fit)
|
|
321
|
+
* never go below this, on top of the `minScale` fraction. Off by default
|
|
322
|
+
* (byte-identical), so the floor stays `round(baseFont·minScale)` unless set.
|
|
323
|
+
* `captionAutoSplit` splits so every cue fits at this same floor.
|
|
324
|
+
*/
|
|
325
|
+
minLegiblePx?: number;
|
|
288
326
|
}
|
|
289
|
-
/**
|
|
290
|
-
* Bottom-centered captions inside the platform-safe area: portrait scenes
|
|
291
|
-
* (9:16 cutdowns live under reels/shorts UI chrome) sit higher than
|
|
292
|
-
* landscape. Same node id pairs with captionTrack's default target.
|
|
293
|
-
*/
|
|
294
327
|
declare function captionNode(size: {
|
|
295
328
|
w: number;
|
|
296
329
|
h: number;
|
|
297
330
|
}, style?: CaptionStyle): Text;
|
|
331
|
+
/**
|
|
332
|
+
* Thrown by {@link captionAutoSplit} when a single word in a cue is too wide to fit
|
|
333
|
+
* the caption band even at the min-legible floor — it can't be split further, so the
|
|
334
|
+
* author must intervene rather than the caption silently degrading legibility or
|
|
335
|
+
* dropping words (a hard throw, no clamp). Names the word + its segment id + the fixes
|
|
336
|
+
* in priority order: reword/shorten the word FIRST (it is almost always a URL or a long
|
|
337
|
+
* token), and only as a last resort — since they trade the legibility the split exists
|
|
338
|
+
* to protect — widen the band or lower the min font.
|
|
339
|
+
*/
|
|
340
|
+
declare class CaptionFitError extends Error {
|
|
341
|
+
readonly word: string;
|
|
342
|
+
readonly segmentId: string;
|
|
343
|
+
readonly bandWidth: number;
|
|
344
|
+
readonly minFontPx: number;
|
|
345
|
+
constructor(word: string, segmentId: string, bandWidth: number, minFontPx: number);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Band-fit caption auto-split options. The render CONTEXT so the split's fit-measure
|
|
349
|
+
* matches the render's layout (measure-consistency): `size` + `style` MUST be the same
|
|
350
|
+
* a paired `captionNode(size, style)` uses — both derive the band via `captionBandParams`.
|
|
351
|
+
*/
|
|
352
|
+
interface CaptionAutoSplitOpts {
|
|
353
|
+
/** the scene size the caption renders into (drives band width + base font). */
|
|
354
|
+
size: {
|
|
355
|
+
w: number;
|
|
356
|
+
h: number;
|
|
357
|
+
};
|
|
358
|
+
/** the SAME style passed to captionNode (widthFrac / fontSize / minScale / minLegiblePx / maxLines). */
|
|
359
|
+
style?: CaptionStyle;
|
|
360
|
+
/**
|
|
361
|
+
* the measurer the RENDER lays out with — MUST be the same for measure-consistency.
|
|
362
|
+
* Defaults to the process fallback (fails loud without a real one — see `estimate`).
|
|
363
|
+
*/
|
|
364
|
+
measurer?: TextMeasurer;
|
|
365
|
+
/**
|
|
366
|
+
* measurer-fail-loud opt-out, and LOUD for captions: an estimate-split may OVERFLOW
|
|
367
|
+
* the band when rendered with real metrics (cues fit only if the render also
|
|
368
|
+
* estimates). Prefer a real measurer for a guaranteed fit. Default false (fail loud).
|
|
369
|
+
*/
|
|
370
|
+
estimate?: boolean;
|
|
371
|
+
/** BCP-47 locale for per-locale boundary detection (sentence + word segmentation). */
|
|
372
|
+
locale?: string;
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Split a segment's caption into timed sub-cues that each FIT the caption band at the
|
|
376
|
+
* min-legible floor — the measured, band-aware alternative to {@link splitCaption}'s
|
|
377
|
+
* char-count budget. Splits at per-locale **sentence → clause → word** boundaries (via
|
|
378
|
+
* `splitToFit`), so a long line breaks at meaningful points, never mid-thought. A cue
|
|
379
|
+
* that already fits stays whole (a short caption is a single cue → the default is a
|
|
380
|
+
* no-op). Sub-cue timing mirrors splitCaption: with per-word timings each cue starts at
|
|
381
|
+
* its first word; without them the segment window divides evenly. Throws
|
|
382
|
+
* {@link CaptionFitError} when a single word can't fit even alone. Pure / build-time
|
|
383
|
+
* (like splitText) — the fit measurer never enters the render or cert hash.
|
|
384
|
+
*/
|
|
385
|
+
declare function captionAutoSplit(segment: TimedSegment, opts: CaptionAutoSplitOpts): CaptionCue[];
|
|
298
386
|
/**
|
|
299
387
|
* The TOP (device px) of the reserved caption band — the Y above which foreground
|
|
300
388
|
* art is safe and below which it collides with captions. Derived from `captionNode`'s
|
|
@@ -415,7 +503,7 @@ interface MusicAnchors {
|
|
|
415
503
|
declare function validateMusicTiming(timing: MusicTiming): void;
|
|
416
504
|
/** Beat-grid anchors over a music manifest; `at` places the clip on the timeline. */
|
|
417
505
|
declare function music(timing: MusicTiming, at?: number): MusicAnchors;
|
|
418
|
-
declare function toSrt(timing: NarrationTiming): string;
|
|
419
|
-
declare function toVtt(timing: NarrationTiming): string;
|
|
506
|
+
declare function toSrt(timing: NarrationTiming, ctx?: Pick<CaptionTrackOptions, 'size' | 'style' | 'measurer' | 'estimate' | 'locale'>): string;
|
|
507
|
+
declare function toVtt(timing: NarrationTiming, ctx?: Pick<CaptionTrackOptions, 'size' | 'style' | 'measurer' | 'estimate' | 'locale'>): string;
|
|
420
508
|
//#endregion
|
|
421
|
-
export { BedMode, CAPTION_NODE_ID, CaptionCue, CaptionStyle, CaptionTrackOptions, DuckOptions, MusicAnchors, MusicClipOptions, MusicTiming, NarrationAnchors, NarrationElement, NarrationError, NarrationPause, NarrationScript, NarrationSegment, NarrationTiming, TimedPause, TimedSegment, TimedWord, VoiceBlend, VoiceBlendEntry, VoiceSpec, captionNode, captionSafeArea, captionTop, captionTrack, duckEnvelope, isPause, isVoiceBlend, music, narration, splitCaption, toSrt, toVtt, validateMusicTiming };
|
|
509
|
+
export { BedMode, CAPTION_NODE_ID, CaptionAutoSplitOpts, CaptionCue, CaptionFitError, CaptionSplitPolicy, CaptionStyle, CaptionTrackOptions, DuckOptions, MusicAnchors, MusicClipOptions, MusicTiming, NarrationAnchors, NarrationElement, NarrationError, NarrationPause, NarrationScript, NarrationSegment, NarrationTiming, TimedPause, TimedSegment, TimedWord, VoiceBlend, VoiceBlendEntry, VoiceSpec, captionAutoSplit, captionNode, captionSafeArea, captionTop, captionTrack, duckEnvelope, isPause, isVoiceBlend, music, narration, splitCaption, toSrt, toVtt, validateMusicTiming };
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { key, track } from "@glissade/core";
|
|
2
2
|
import { Text, breakLines, estimatingMeasurer, glow, quantize } from "@glissade/scene";
|
|
3
3
|
import "@glissade/scene/diagnostics";
|
|
4
|
+
import { TextFitError, splitToFit } from "@glissade/scene/type";
|
|
4
5
|
//#region src/index.ts
|
|
5
6
|
/**
|
|
6
7
|
* @glissade/narrate — narration + captions, the PURE side. TTS happens only
|
|
@@ -164,34 +165,58 @@ function splitCaption(segment, maxChars) {
|
|
|
164
165
|
* are subtree-matched exempt in CAPTION_COLLISION).
|
|
165
166
|
*/
|
|
166
167
|
const CAPTION_NODE_ID = "captions";
|
|
168
|
+
/**
|
|
169
|
+
* Dispatch a segment to its cues by the {@link CaptionSplitPolicy}: band mode →
|
|
170
|
+
* {@link captionAutoSplit} (needs the render `size`/`style` from `ctx`, fails loud
|
|
171
|
+
* without `size`); `{ maxChars }` or absent → {@link splitCaption} (byte-identical to
|
|
172
|
+
* pre-0.68). The SAME dispatch drives the burned track AND the .srt/.vtt sidecars, so
|
|
173
|
+
* they match by construction.
|
|
174
|
+
*/
|
|
175
|
+
function segmentCues(segment, policy, ctx) {
|
|
176
|
+
if (policy && "mode" in policy && policy.mode === "band") {
|
|
177
|
+
if (!ctx.size) throw new Error("captionTrack: captionSplit { mode: 'band' } needs the render size — pass { size } (and the same { style } you pass to captionNode) so the band-fit split matches the caption band.");
|
|
178
|
+
const locale = policy.locale ?? ctx.locale;
|
|
179
|
+
return captionAutoSplit(segment, {
|
|
180
|
+
size: ctx.size,
|
|
181
|
+
...ctx.style !== void 0 ? { style: ctx.style } : {},
|
|
182
|
+
...ctx.measurer !== void 0 ? { measurer: ctx.measurer } : {},
|
|
183
|
+
...ctx.estimate !== void 0 ? { estimate: ctx.estimate } : {},
|
|
184
|
+
...locale !== void 0 ? { locale } : {}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return splitCaption(segment, policy && "maxChars" in policy ? policy.maxChars : void 0);
|
|
188
|
+
}
|
|
167
189
|
function captionTrack(timing, opts = {}) {
|
|
168
190
|
const target = opts.target ?? `captions/text`;
|
|
169
|
-
const budget = timing.captionSplit?.maxChars;
|
|
170
191
|
const keys = [key(0, "", { interp: "hold" })];
|
|
171
192
|
let cursor = 0;
|
|
172
193
|
for (const s of timing.segments) {
|
|
173
194
|
if (s.start > cursor + 1e-9) {
|
|
174
195
|
if (keys[keys.length - 1].value !== "") keys.push(key(cursor, "", { interp: "hold" }));
|
|
175
196
|
}
|
|
176
|
-
for (const cue of
|
|
197
|
+
for (const cue of segmentCues(s, timing.captionSplit, opts)) if (cue.start <= 1e-9) keys[0] = key(0, cue.text, { interp: "hold" });
|
|
177
198
|
else keys.push(key(cue.start, cue.text, { interp: "hold" }));
|
|
178
199
|
cursor = s.start + s.duration;
|
|
179
200
|
}
|
|
180
201
|
keys.push(key(cursor, "", { interp: "hold" }));
|
|
181
202
|
return track(target, "string", keys);
|
|
182
203
|
}
|
|
183
|
-
|
|
184
|
-
* Bottom-centered captions inside the platform-safe area: portrait scenes
|
|
185
|
-
* (9:16 cutdowns live under reels/shorts UI chrome) sit higher than
|
|
186
|
-
* landscape. Same node id pairs with captionTrack's default target.
|
|
187
|
-
*/
|
|
188
|
-
function captionNode(size, style = {}) {
|
|
204
|
+
function captionBandParams(size, style) {
|
|
189
205
|
const portrait = size.h > size.w;
|
|
190
|
-
const inset = style.bottomInsetFrac ?? (portrait ? .18 : .1);
|
|
191
206
|
const baseFont = style.fontSize ?? Math.round(Math.min(size.w, size.h) * (portrait ? .052 : .06));
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
207
|
+
return {
|
|
208
|
+
portrait,
|
|
209
|
+
fontFamily: style.fontFamily ?? "sans-serif",
|
|
210
|
+
baseFont,
|
|
211
|
+
width: Math.round(size.w * (style.widthFrac ?? .82)),
|
|
212
|
+
lineHeight: style.lineHeight ?? 1.3,
|
|
213
|
+
maxLines: Math.max(1, style.maxLines ?? 2),
|
|
214
|
+
minFont: Math.max(1, style.minLegiblePx ?? 0, Math.round(baseFont * (style.minScale ?? .7)))
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function captionNode(size, style = {}) {
|
|
218
|
+
const { portrait, fontFamily, baseFont, width, lineHeight, maxLines, minFont } = captionBandParams(size, style);
|
|
219
|
+
const inset = style.bottomInsetFrac ?? (portrait ? .18 : .1);
|
|
195
220
|
const bottomY = Math.round(size.h * (1 - inset));
|
|
196
221
|
const node = new Text({
|
|
197
222
|
id: CAPTION_NODE_ID,
|
|
@@ -206,8 +231,6 @@ function captionNode(size, style = {}) {
|
|
|
206
231
|
filters: style.filters ?? glow("#000000cc", 3, 1)
|
|
207
232
|
});
|
|
208
233
|
if (style.autoFit) {
|
|
209
|
-
const maxLines = Math.max(1, style.maxLines ?? 2);
|
|
210
|
-
const minFont = Math.max(1, Math.round(baseFont * (style.minScale ?? .7)));
|
|
211
234
|
const lineCountAt = (font, m) => {
|
|
212
235
|
const t = node.text();
|
|
213
236
|
if (!t) return 0;
|
|
@@ -235,6 +258,89 @@ function captionNode(size, style = {}) {
|
|
|
235
258
|
return node;
|
|
236
259
|
}
|
|
237
260
|
/**
|
|
261
|
+
* Thrown by {@link captionAutoSplit} when a single word in a cue is too wide to fit
|
|
262
|
+
* the caption band even at the min-legible floor — it can't be split further, so the
|
|
263
|
+
* author must intervene rather than the caption silently degrading legibility or
|
|
264
|
+
* dropping words (a hard throw, no clamp). Names the word + its segment id + the fixes
|
|
265
|
+
* in priority order: reword/shorten the word FIRST (it is almost always a URL or a long
|
|
266
|
+
* token), and only as a last resort — since they trade the legibility the split exists
|
|
267
|
+
* to protect — widen the band or lower the min font.
|
|
268
|
+
*/
|
|
269
|
+
var CaptionFitError = class extends Error {
|
|
270
|
+
word;
|
|
271
|
+
segmentId;
|
|
272
|
+
bandWidth;
|
|
273
|
+
minFontPx;
|
|
274
|
+
constructor(word, segmentId, bandWidth, minFontPx) {
|
|
275
|
+
super(`captionAutoSplit: segment ${JSON.stringify(segmentId)} has a word ${JSON.stringify(word)} too wide to fit the ${bandWidth}px caption band at the ${minFontPx}px min-legible floor and cannot be split further — reword/shorten the word, or (last resort, trades legibility) widen the caption widthFrac or lower minScale/minLegiblePx.`);
|
|
276
|
+
this.word = word;
|
|
277
|
+
this.segmentId = segmentId;
|
|
278
|
+
this.bandWidth = bandWidth;
|
|
279
|
+
this.minFontPx = minFontPx;
|
|
280
|
+
this.name = "CaptionFitError";
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
/**
|
|
284
|
+
* Split a segment's caption into timed sub-cues that each FIT the caption band at the
|
|
285
|
+
* min-legible floor — the measured, band-aware alternative to {@link splitCaption}'s
|
|
286
|
+
* char-count budget. Splits at per-locale **sentence → clause → word** boundaries (via
|
|
287
|
+
* `splitToFit`), so a long line breaks at meaningful points, never mid-thought. A cue
|
|
288
|
+
* that already fits stays whole (a short caption is a single cue → the default is a
|
|
289
|
+
* no-op). Sub-cue timing mirrors splitCaption: with per-word timings each cue starts at
|
|
290
|
+
* its first word; without them the segment window divides evenly. Throws
|
|
291
|
+
* {@link CaptionFitError} when a single word can't fit even alone. Pure / build-time
|
|
292
|
+
* (like splitText) — the fit measurer never enters the render or cert hash.
|
|
293
|
+
*/
|
|
294
|
+
function captionAutoSplit(segment, opts) {
|
|
295
|
+
const { fontFamily, width, maxLines, minFont } = captionBandParams(opts.size, opts.style ?? {});
|
|
296
|
+
const font = {
|
|
297
|
+
family: fontFamily,
|
|
298
|
+
size: minFont,
|
|
299
|
+
weight: 400
|
|
300
|
+
};
|
|
301
|
+
const end = segment.start + segment.duration;
|
|
302
|
+
let parts;
|
|
303
|
+
try {
|
|
304
|
+
parts = splitToFit(segment.text, {
|
|
305
|
+
maxWidth: width,
|
|
306
|
+
font,
|
|
307
|
+
maxLines,
|
|
308
|
+
...opts.measurer !== void 0 ? { measurer: opts.measurer } : {},
|
|
309
|
+
...opts.estimate !== void 0 ? { estimate: opts.estimate } : {},
|
|
310
|
+
...opts.locale !== void 0 ? { locale: opts.locale } : {}
|
|
311
|
+
});
|
|
312
|
+
} catch (e) {
|
|
313
|
+
if (e instanceof TextFitError) throw new CaptionFitError(e.token, segment.id, width, minFont);
|
|
314
|
+
throw e;
|
|
315
|
+
}
|
|
316
|
+
if (parts.length <= 1) return [{
|
|
317
|
+
text: segment.text,
|
|
318
|
+
start: segment.start,
|
|
319
|
+
end
|
|
320
|
+
}];
|
|
321
|
+
if (segment.words && segment.words.length > 0) {
|
|
322
|
+
const cues = [];
|
|
323
|
+
let wi = 0;
|
|
324
|
+
for (let pi = 0; pi < parts.length; pi++) {
|
|
325
|
+
const startWord = segment.words[Math.min(wi, segment.words.length - 1)];
|
|
326
|
+
wi += Math.max(1, parts[pi].split(/\s+/).filter(Boolean).length);
|
|
327
|
+
const cueEnd = pi + 1 < parts.length && wi < segment.words.length ? segment.words[wi].start : end;
|
|
328
|
+
cues.push({
|
|
329
|
+
text: parts[pi],
|
|
330
|
+
start: startWord.start,
|
|
331
|
+
end: cueEnd
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
return cues;
|
|
335
|
+
}
|
|
336
|
+
const span = segment.duration / parts.length;
|
|
337
|
+
return parts.map((text, i) => ({
|
|
338
|
+
text,
|
|
339
|
+
start: segment.start + i * span,
|
|
340
|
+
end: segment.start + (i + 1) * span
|
|
341
|
+
}));
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
238
344
|
* The TOP (device px) of the reserved caption band — the Y above which foreground
|
|
239
345
|
* art is safe and below which it collides with captions. Derived from `captionNode`'s
|
|
240
346
|
* OWN layout: the caption baseline sits at `h·(1−inset)` (inset 0.10 landscape /
|
|
@@ -426,11 +532,26 @@ function srtTime(t, sep) {
|
|
|
426
532
|
const p = (n, w) => String(n).padStart(w, "0");
|
|
427
533
|
return `${p(h, 2)}:${p(m, 2)}:${p(s, 2)}${sep}${p(f, 3)}`;
|
|
428
534
|
}
|
|
429
|
-
|
|
430
|
-
|
|
535
|
+
/**
|
|
536
|
+
* Sidecar cues, tolerant of band mode: with a render `ctx` (size/style) band mode
|
|
537
|
+
* splits exactly like the burned track (burned == sidecar); WITHOUT one — a text
|
|
538
|
+
* sidecar has no pixel band — a band-mode segment stays a single cue (the split is a
|
|
539
|
+
* burned-caption concern). The legacy `{ maxChars }` path needs no context.
|
|
540
|
+
*/
|
|
541
|
+
function sidecarCues(timing, ctx) {
|
|
542
|
+
const policy = timing.captionSplit;
|
|
543
|
+
const bandNoContext = policy !== void 0 && "mode" in policy && !ctx.size;
|
|
544
|
+
return timing.segments.flatMap((s) => bandNoContext ? [{
|
|
545
|
+
text: s.text,
|
|
546
|
+
start: s.start,
|
|
547
|
+
end: s.start + s.duration
|
|
548
|
+
}] : segmentCues(s, policy, ctx));
|
|
549
|
+
}
|
|
550
|
+
function toSrt(timing, ctx = {}) {
|
|
551
|
+
return sidecarCues(timing, ctx).map((c, i) => `${i + 1}\n${srtTime(c.start, ",")} --> ${srtTime(c.end, ",")}\n${c.text}`).join("\n\n") + "\n";
|
|
431
552
|
}
|
|
432
|
-
function toVtt(timing) {
|
|
433
|
-
return "WEBVTT\n\n" + timing
|
|
553
|
+
function toVtt(timing, ctx = {}) {
|
|
554
|
+
return "WEBVTT\n\n" + sidecarCues(timing, ctx).map((c) => `${srtTime(c.start, ".")} --> ${srtTime(c.end, ".")}\n${c.text}`).join("\n\n") + "\n";
|
|
434
555
|
}
|
|
435
556
|
//#endregion
|
|
436
|
-
export { CAPTION_NODE_ID, NarrationError, captionNode, captionSafeArea, captionTop, captionTrack, duckEnvelope, isPause, isVoiceBlend, music, narration, splitCaption, toSrt, toVtt, validateMusicTiming };
|
|
557
|
+
export { CAPTION_NODE_ID, CaptionFitError, NarrationError, captionAutoSplit, captionNode, captionSafeArea, captionTop, captionTrack, duckEnvelope, isPause, isVoiceBlend, music, narration, splitCaption, toSrt, toVtt, validateMusicTiming };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glissade/narrate",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.68.0-pre.0",
|
|
4
4
|
"description": "glissade narration + captions: TTS at prepare time (gs narrate), deterministic caching, narration-anchored timeline beats, and captions as plain tracks. Render stays offline.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"engines": {
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"dist"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@glissade/core": "0.
|
|
26
|
-
"@glissade/scene": "0.
|
|
25
|
+
"@glissade/core": "0.68.0-pre.0",
|
|
26
|
+
"@glissade/scene": "0.68.0-pre.0"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
29
|
"kokoro-js": "^1.2.0"
|