@ossclip/core 0.1.31 → 0.1.33
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/package.json +1 -1
- package/src/browser.ts +19 -0
- package/src/captions.ts +101 -2
- package/src/config.ts +24 -0
- package/src/cover-in-video.ts +56 -0
- package/src/cutlist.ts +38 -1
- package/src/index.ts +4 -0
- package/src/ingest.ts +34 -0
- package/src/kept-takes.ts +170 -0
- package/src/overrides.ts +468 -35
- package/src/producer/youtube.ts +36 -2
- package/src/publish/captions.ts +79 -0
- package/src/publish/index.ts +3 -0
- package/src/publish/postiz.ts +232 -0
- package/src/publish/provider.ts +58 -0
- package/src/recut.ts +20 -4
- package/src/restamp.ts +383 -0
- package/src/retime-preview.ts +166 -60
- package/src/scene-schema.ts +13 -0
package/src/overrides.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type Theme,
|
|
11
11
|
} from "./scene-schema";
|
|
12
12
|
import { RemovalReasonSchema } from "./schema";
|
|
13
|
+
import type { TimeMap } from "./timemap";
|
|
13
14
|
import { resolveSceneProps } from "./scene-registry";
|
|
14
15
|
import type { CaptionLine, CaptionWord } from "./captions";
|
|
15
16
|
|
|
@@ -42,16 +43,75 @@ export const ElementTransformSchema = z.object({
|
|
|
42
43
|
});
|
|
43
44
|
export type ElementTransform = z.infer<typeof ElementTransformSchema>;
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* A pinned scene window, in one of two clocks — the last unconverted
|
|
48
|
+
* old-clock field in the doc (source-anchoring audit, 2026-08-26).
|
|
49
|
+
*
|
|
50
|
+
* {startSec, endSec} — LEGACY: absolute OUTPUT seconds on the clock of the
|
|
51
|
+
* LAST RENDER. Semantics unchanged, and it stays in
|
|
52
|
+
* `remapOverridesThroughRecut`'s hands: a re-cut moves those numbers.
|
|
53
|
+
* {srcStart, srcEnd} — SOURCE seconds, the §155 principle ("key on the
|
|
54
|
+
* property the disruption cannot move") that `cuts[].src`,
|
|
55
|
+
* `cleanup.kept`, `splits[].src` and the caption keys already anchor on.
|
|
56
|
+
* RECUT-IMMUNE by construction: the remap passes it through untouched,
|
|
57
|
+
* and it is resolved onto whichever clock is in hand by
|
|
58
|
+
* `resolveTimingPin`.
|
|
59
|
+
*
|
|
60
|
+
* The same DELIBERATE divergence `SplitSchema.src` claims applies here: the
|
|
61
|
+
* editor MAY write the src shape. A cut is a RANGE over props the editor did
|
|
62
|
+
* not compute; a pinned window is a pair of POINTS on a clock the editor
|
|
63
|
+
* holds exactly — the drag preview's own clock, converted at the gesture
|
|
64
|
+
* (`previewClockMappers.toSourceSec` under a live veto,
|
|
65
|
+
* `mapFromKeptSpans(spans).toSource` otherwise). That gesture-time
|
|
66
|
+
* conversion IS the fix: with a veto live, the preview seconds spoke the
|
|
67
|
+
* LIVE clock while `timing` spoke the last render's, so a dragged block was
|
|
68
|
+
* stored seconds from where it was dropped and snapped back (or vanished) on
|
|
69
|
+
* the next derive.
|
|
70
|
+
*
|
|
71
|
+
* No third shape and no dual-write: one of these is authoritative, and a
|
|
72
|
+
* doc that recorded both would need a rule for which clock wins after a
|
|
73
|
+
* re-cut moved only one of them.
|
|
74
|
+
*/
|
|
75
|
+
export const SceneTimingSchema = z.union([
|
|
76
|
+
z.object({
|
|
77
|
+
startSec: z.number().finite().nonnegative(),
|
|
78
|
+
endSec: z.number().finite().nonnegative(),
|
|
79
|
+
}),
|
|
80
|
+
// `.finite()` stated rather than assumed, `SplitSchema`'s reasoning: an
|
|
81
|
+
// overflowing literal (`1e400`) parses to Infinity, and a non-finite
|
|
82
|
+
// source second would resolve through `TimeMap` into a window nothing can
|
|
83
|
+
// render.
|
|
84
|
+
z
|
|
85
|
+
.object({
|
|
86
|
+
srcStart: z.number().finite().nonnegative(),
|
|
87
|
+
srcEnd: z.number().finite().nonnegative(),
|
|
88
|
+
})
|
|
89
|
+
// Ordered, because unlike the legacy arm there is no downstream clamp
|
|
90
|
+
// that could rescue it: `resolveTimingPin` maps both edges through the
|
|
91
|
+
// same map, so an inverted pair stays inverted and reaches `SceneLayer`,
|
|
92
|
+
// which assumes increasing windows.
|
|
93
|
+
.refine((t) => t.srcEnd > t.srcStart, { message: "srcEnd must be after srcStart" }),
|
|
94
|
+
]);
|
|
95
|
+
export type SceneTiming = z.infer<typeof SceneTimingSchema>;
|
|
96
|
+
|
|
97
|
+
/** The src-anchored arm of `SceneTimingSchema` — a guard, so no consumer
|
|
98
|
+
* re-derives the discriminant (and gets it subtly wrong on a doc where a
|
|
99
|
+
* legacy entry happens to carry an extra key). */
|
|
100
|
+
export function isSrcTiming(timing: SceneTiming): timing is { srcStart: number; srcEnd: number } {
|
|
101
|
+
return "srcStart" in timing;
|
|
102
|
+
}
|
|
103
|
+
|
|
45
104
|
export const SceneOverrideSchema = z.object({
|
|
46
105
|
/** Merged over the producer's props, key by key. */
|
|
47
106
|
props: z.record(z.string(), z.unknown()).default({}),
|
|
48
107
|
/** Per-element nudges, keyed by the component's `data-edit-id`. */
|
|
49
108
|
elements: z.record(z.string(), ElementTransformSchema).default({}),
|
|
50
109
|
/**
|
|
51
|
-
*
|
|
110
|
+
* An absolute window. Setting this PINS the scene: it stops tracking the
|
|
52
111
|
* words it was anchored to, which is why the UI has to say so out loud.
|
|
112
|
+
* `SceneTimingSchema` above owns which clock the numbers speak.
|
|
53
113
|
*/
|
|
54
|
-
timing:
|
|
114
|
+
timing: SceneTimingSchema.optional(),
|
|
55
115
|
/**
|
|
56
116
|
* Component/layout swaps (design spec Scope: v1 in-scope). Optional — most
|
|
57
117
|
* scenes never touch these — and validated against the same enums the
|
|
@@ -261,13 +321,96 @@ export const SplitSchema = z.union([
|
|
|
261
321
|
// words. zod v4's `z.number()` already rejects non-finite where v3's did
|
|
262
322
|
// not, so this is a requirement written down at the site instead of a
|
|
263
323
|
// default that has already changed once underneath this file.
|
|
264
|
-
|
|
324
|
+
//
|
|
325
|
+
// `src` (cut-review rework, 2026-08-26) is the split's SOURCE second — the
|
|
326
|
+
// §155 principle ("key on the property the disruption cannot move")
|
|
327
|
+
// applied to splits, the way `cuts[].src`, `cleanup.kept` and the caption
|
|
328
|
+
// keys already anchor. When present it is AUTHORITATIVE and the split is
|
|
329
|
+
// recut-immune (`remapOverridesThroughRecut` passes it through untouched);
|
|
330
|
+
// `at` then survives only as the historical old-clock record, the
|
|
331
|
+
// `cuts[].startSec` posture. Three shapes, all meaningful:
|
|
332
|
+
// {at} — legacy doc; produce backfills `src` once via priorMap.
|
|
333
|
+
// {at, src} — a normal ⌘B: the editor dual-writes both.
|
|
334
|
+
// {src} — ⌘B INSIDE revived material (a kept cleanup removal): no
|
|
335
|
+
// old-clock image exists, and none is invented.
|
|
336
|
+
//
|
|
337
|
+
// DELIBERATE divergence from the `cuts[].src` editor-never-writes rule:
|
|
338
|
+
// the editor MAY write `splits[].src`. A cut is a RANGE drawn on props the
|
|
339
|
+
// editor did not compute, resolvable only against produce's priorMap; a
|
|
340
|
+
// split is a POINT on a clock the editor itself holds exactly —
|
|
341
|
+
// `newMap.toSource(liveSec)` under a live veto (`livePreviewMap` builds
|
|
342
|
+
// that very map client-side), `mapFromKeptSpans(spans).toSource` otherwise.
|
|
343
|
+
z
|
|
344
|
+
.object({
|
|
345
|
+
at: z.number().finite().nonnegative().optional(),
|
|
346
|
+
src: z.number().finite().nonnegative().optional(),
|
|
347
|
+
id: z.string().min(1),
|
|
348
|
+
})
|
|
349
|
+
.refine((s) => s.at !== undefined || s.src !== undefined, {
|
|
350
|
+
message: "a split needs `at` or `src`",
|
|
351
|
+
}),
|
|
265
352
|
// Legacy: a bare number, upgraded in place so every overrides.json written
|
|
266
353
|
// before §137 parses and keeps its split-half overrides attached.
|
|
267
|
-
z
|
|
354
|
+
z
|
|
355
|
+
.number()
|
|
356
|
+
.finite()
|
|
357
|
+
.nonnegative()
|
|
358
|
+
// `src: undefined` spelled out so both union arms share one output
|
|
359
|
+
// shape — consumers read `s.src` without narrowing the arm first.
|
|
360
|
+
.transform((at) => ({ at: at as number | undefined, src: undefined as number | undefined, id: legacySplitId(at) })),
|
|
268
361
|
]);
|
|
269
362
|
export type Split = z.infer<typeof SplitSchema>;
|
|
270
363
|
|
|
364
|
+
/** A split resolved onto ONE clock — what `splitCues` actually consumes.
|
|
365
|
+
* `resolveSplitPoints` (src-aware, needs a TimeMap) and `atSplitPoints`
|
|
366
|
+
* (old-clock, no map needed) both produce this. */
|
|
367
|
+
export interface ResolvedSplitPoint {
|
|
368
|
+
at: number;
|
|
369
|
+
id: string;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The splits that carry an old-clock `at` — the editor's pass-1 input (its
|
|
373
|
+
* cue windows speak the last render's clock) and the no-map fallback. A
|
|
374
|
+
* src-only split is NOT here by definition; it applies via
|
|
375
|
+
* `resolveSplitPoints` on the clock the map defines. */
|
|
376
|
+
export function atSplitPoints(splits: readonly Split[]): ResolvedSplitPoint[] {
|
|
377
|
+
return splits.flatMap((s) => (s.at !== undefined ? [{ at: s.at, id: s.id }] : []));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Resolve every split onto the clock `map` defines. `src` wins when present
|
|
382
|
+
* (the authoritative anchor); a src whose `toOutput` is null sits inside
|
|
383
|
+
* material the CURRENT cut removes — the split is INERT this run, skipped
|
|
384
|
+
* with a report, never clamped to a seam (`cleanup.kept`'s inert-entry
|
|
385
|
+
* posture: the doc keeps it, and it wakes up when that material is kept
|
|
386
|
+
* again). A src-less split passes its old-clock `at` through — meaningful
|
|
387
|
+
* only when `map` IS that old clock, which is produce's situation after
|
|
388
|
+
* `remapOverridesThroughRecut` re-anchored it, and the editor's pass-1.
|
|
389
|
+
*/
|
|
390
|
+
export function resolveSplitPoints(
|
|
391
|
+
splits: readonly Split[],
|
|
392
|
+
map: TimeMap,
|
|
393
|
+
): { points: ResolvedSplitPoint[]; reports: string[] } {
|
|
394
|
+
const points: ResolvedSplitPoint[] = [];
|
|
395
|
+
const reports: string[] = [];
|
|
396
|
+
for (const s of splits) {
|
|
397
|
+
if (s.src !== undefined) {
|
|
398
|
+
const at = map.toOutput(s.src);
|
|
399
|
+
if (at === null) {
|
|
400
|
+
reports.push(
|
|
401
|
+
`split "${s.id}" sits in removed material (source ${s.src.toFixed(3)}s) — ` +
|
|
402
|
+
`inert until that material is kept again`,
|
|
403
|
+
);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
points.push({ at, id: s.id });
|
|
407
|
+
} else if (s.at !== undefined) {
|
|
408
|
+
points.push({ at: s.at, id: s.id });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return { points, reports };
|
|
412
|
+
}
|
|
413
|
+
|
|
271
414
|
/**
|
|
272
415
|
* A split id that no split in `existing` already holds.
|
|
273
416
|
*
|
|
@@ -390,17 +533,79 @@ export const OverrideDocSchema = z.object({
|
|
|
390
533
|
)
|
|
391
534
|
.default({}),
|
|
392
535
|
/**
|
|
393
|
-
*
|
|
394
|
-
*
|
|
395
|
-
*
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
536
|
+
* Per-LINE caption display WINDOWS — "show this caption from HERE to HERE",
|
|
537
|
+
* an ABSOLUTE span in SOURCE seconds, keyed by the LINE's FIRST WORD's
|
|
538
|
+
* SOURCE time (`captionKeyFor`, §137) like `captionLineTiming` above.
|
|
539
|
+
* Written by the editor's audio-first timing tool, which does all of its
|
|
540
|
+
* arithmetic against the waveform — SOURCE audio, the one clock a re-cut
|
|
541
|
+
* cannot move — so what the user heard while dragging is literally what is
|
|
542
|
+
* stored. Applied by `applyCaptionLineWindows` AFTER the nudge layer, which
|
|
543
|
+
* is what makes a window the LAST word on a line it is stored for.
|
|
399
544
|
*
|
|
400
|
-
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
545
|
+
* ABSOLUTE, NOT A DELTA, and that is the difference from `captionLineTiming`
|
|
546
|
+
* rather than a duplication of it. A nudge asks the derived window to move a
|
|
547
|
+
* bit and therefore has to be interpreted against whatever the derivation
|
|
548
|
+
* produced this run; a window states the answer. That is why this record
|
|
549
|
+
* needs NO SWEEP, NO COINCIDENCE RULE AND NO NEIGHBOUR WRITES: the sweep in
|
|
550
|
+
* `applyCaptionLineTiming` exists to keep SEAM edits ordered, and an
|
|
551
|
+
* absolute window is not a seam edit — the user placed it against the audio
|
|
552
|
+
* and only the user gets to say two captions may not share a moment.
|
|
553
|
+
* OVERLAP IS THEREFORE LEGAL here (the renderer mounts overlapping
|
|
554
|
+
* `<Sequence>`s happily, `CaptionTrack.tsx`); the editor TINTS a conflict
|
|
555
|
+
* instead of resolving it, because the neighbour it would have to move is
|
|
556
|
+
* material the user may be about to place too.
|
|
557
|
+
*
|
|
558
|
+
* Recut-immune BY CONSTRUCTION — both the key and the value are source
|
|
559
|
+
* seconds, so there is nothing in this record for `remapOverridesThroughRecut`
|
|
560
|
+
* to re-anchor and it deliberately has no entry there (the `splits[].src`
|
|
561
|
+
* property, and the reason the deltas above need none either). It DOES get
|
|
562
|
+
* re-keyed by `rekeyCaptionRecords` when a range re-transcription moves the
|
|
563
|
+
* stamps under it (Phase A): the KEY names a word whose stamp moved, and the
|
|
564
|
+
* value is a window the user placed against audio that did not.
|
|
565
|
+
*
|
|
566
|
+
* The floor and the ordering are the schema's, not a clamp: a window
|
|
567
|
+
* narrower than `MIN_CAPTION_SEC` is a delete wearing a timing gesture's
|
|
568
|
+
* clothes (the constant's own docstring) and an inverted one would mirror
|
|
569
|
+
* the line's word order through `scaleWordsIntoWindow`. The layer clamps
|
|
570
|
+
* once more in OUTPUT seconds anyway, because a cut INSIDE the window can
|
|
571
|
+
* narrow it however wide the source span was. `.default({})` keeps every
|
|
572
|
+
* pre-existing overrides.json parsing byte-identically, and the field never
|
|
573
|
+
* existed in the legacy positional-key era, so `migrateCaptionKeys` must not
|
|
574
|
+
* process it.
|
|
575
|
+
*/
|
|
576
|
+
captionLineWindows: z
|
|
577
|
+
.record(
|
|
578
|
+
z.string(),
|
|
579
|
+
z
|
|
580
|
+
.object({ srcStart: z.number(), srcEnd: z.number() })
|
|
581
|
+
// The message states the floor literally rather than interpolating
|
|
582
|
+
// the constant: this schema is built at module load, `MIN_CAPTION_SEC`
|
|
583
|
+
// is declared further down, and a template literal here would read it
|
|
584
|
+
// in its temporal dead zone (the refine CALLBACK runs at parse time,
|
|
585
|
+
// long after, so it may name it).
|
|
586
|
+
.refine((w) => w.srcEnd - w.srcStart >= MIN_CAPTION_SEC, {
|
|
587
|
+
message: "caption window must be at least 50ms (MIN_CAPTION_SEC) wide, ordered start → end",
|
|
588
|
+
}),
|
|
589
|
+
)
|
|
590
|
+
.default({}),
|
|
591
|
+
/**
|
|
592
|
+
* Scene split points (R16 §61 — Cmd/Ctrl+B at the playhead). Since the
|
|
593
|
+
* cut-review rework (2026-08-26) `src` — SOURCE seconds — is the
|
|
594
|
+
* authoritative anchor when present (recut-immune, the `cleanup.kept`
|
|
595
|
+
* trick; see SplitSchema for the three shapes and the editor-may-write-src
|
|
596
|
+
* divergence). `at` is the old-clock output second: authoritative only on
|
|
597
|
+
* src-less legacy entries, where it still moves under
|
|
598
|
+
* `remapOverridesThroughRecut`. `id` is minted once when the split is
|
|
599
|
+
* created and NEVER recomputed (§137). The split half is named
|
|
600
|
+
* `${rootId}@${id}`, so re-anchoring cannot rename the half out from under
|
|
601
|
+
* a `hidden` (or any other) override on it — the bug that resurrected a
|
|
602
|
+
* deleted scene in the field case.
|
|
603
|
+
*
|
|
604
|
+
* Time-anchored rather than scene-anchored on purpose: a re-plan can
|
|
605
|
+
* rename or move scenes, and WHERE to cut is a decision about a MOMENT of
|
|
606
|
+
* the footage. Applied by `splitCues` (over `resolveSplitPoints` /
|
|
607
|
+
* `atSplitPoints`) after the plain fill, so a split lands on graphic cues
|
|
608
|
+
* and takes alike.
|
|
404
609
|
*/
|
|
405
610
|
splits: z.array(SplitSchema).default([]),
|
|
406
611
|
/**
|
|
@@ -427,15 +632,38 @@ export const OverrideDocSchema = z.object({
|
|
|
427
632
|
* a historical record of what render-props they were looking at, never
|
|
428
633
|
* authoritative again once `src` is present.
|
|
429
634
|
*
|
|
430
|
-
* The editor (
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
*
|
|
437
|
-
*
|
|
438
|
-
*
|
|
635
|
+
* The editor MAY write `src` (cut-review rework, 2026-08-26 — the same
|
|
636
|
+
* divergence `SplitSchema` documents for `splits[].src`, and it retires
|
|
637
|
+
* this field's original editor-never-writes-src rule). The premise behind
|
|
638
|
+
* that rule was that only produce can resolve a cut's source range; that
|
|
639
|
+
* died with the client-side clock: `livePreviewMap`/`previewClockMappers`
|
|
640
|
+
* (retime-preview.ts) build the SAME `TimeMap` over the render-props' own
|
|
641
|
+
* `spans` that produce would resolve through, so the writer converts its
|
|
642
|
+
* window at the gesture and stores the answer. Produce then consumes it
|
|
643
|
+
* VERBATIM (`resolveCutSourceRanges`, recut.ts) — the arriving `src` wins,
|
|
644
|
+
* exactly as it does for a src produce itself resolved — and the priorMap
|
|
645
|
+
* fallback stays for legacy src-less entries, which is what keeps every
|
|
646
|
+
* overrides.json written before this reading identically.
|
|
647
|
+
*
|
|
648
|
+
* Three shapes, all meaningful:
|
|
649
|
+
* {startSec, endSec} — legacy/no-mapper: marked-only, produce
|
|
650
|
+
* resolves `src` on the next run.
|
|
651
|
+
* {startSec, endSec, src} — the editor's normal write: the record plus
|
|
652
|
+
* the anchor, resolved at write time.
|
|
653
|
+
* The record may be CLAMPED (or absent-in-spirit) when the window sits
|
|
654
|
+
* in revived material with no old-clock image — historical, never
|
|
655
|
+
* authoritative once `src` exists, so a clamped record is honest.
|
|
656
|
+
*
|
|
657
|
+
* `src` present also means LIVE-APPLIED in the editor: the preview
|
|
658
|
+
* subtracts those ranges and genuinely stops playing the material
|
|
659
|
+
* (retime-preview.ts's module doc). The rest of the original rule stands:
|
|
660
|
+
* if a cut's range is ever edited/moved (not currently exposed, but the
|
|
661
|
+
* rule holds for any future gesture that would), its `src` is DELETED
|
|
662
|
+
* rather than carried forward, so the next produce re-resolves it against
|
|
663
|
+
* the render-props current at that point rather than an anchor drawn for a
|
|
664
|
+
* range that no longer means the same thing; Restore removes the WHOLE
|
|
665
|
+
* entry, `src` included — there is no "not cut" state for one array entry
|
|
666
|
+
* to hold.
|
|
439
667
|
*/
|
|
440
668
|
cuts: z
|
|
441
669
|
.array(
|
|
@@ -482,8 +710,25 @@ export const OverrideDocSchema = z.object({
|
|
|
482
710
|
kept: z
|
|
483
711
|
.array(z.object({ srcIn: z.number().nonnegative(), srcOut: z.number().nonnegative() }))
|
|
484
712
|
.default([]),
|
|
713
|
+
/**
|
|
714
|
+
* DISMISSED proposals ("not a retake", cut-review rework 2026-08-26) —
|
|
715
|
+
* the user says the classification itself was wrong: the marker leaves
|
|
716
|
+
* the lane, the material is ordinary footage, and the state survives
|
|
717
|
+
* re-produce (buildCutlist re-proposes, the overlap match suppresses).
|
|
718
|
+
* SOURCE seconds and overlap-matched exactly like `kept` — recut-immune
|
|
719
|
+
* by construction. Distinct from `kept` on purpose: a veto says "remove
|
|
720
|
+
* proposed, I decline it (this once)", a dismissal says "there was
|
|
721
|
+
* nothing to remove"; `vetoedRemovals` must not paint a dismissed
|
|
722
|
+
* marker as "kept · retake". One state per range: the editor's dismiss
|
|
723
|
+
* action deletes any overlapping `kept` entry. `user`/`clip` spans are
|
|
724
|
+
* never dismissible (`cleanupVetoable`). Optional-with-default like
|
|
725
|
+
* `kept` — absent parses byte-identically to today.
|
|
726
|
+
*/
|
|
727
|
+
dismissed: z
|
|
728
|
+
.array(z.object({ srcIn: z.number().nonnegative(), srcOut: z.number().nonnegative() }))
|
|
729
|
+
.default([]),
|
|
485
730
|
})
|
|
486
|
-
.default({ reasons: {}, kept: [] }),
|
|
731
|
+
.default({ reasons: {}, kept: [], dismissed: [] }),
|
|
487
732
|
});
|
|
488
733
|
export type OverrideDoc = z.infer<typeof OverrideDocSchema>;
|
|
489
734
|
|
|
@@ -566,6 +811,77 @@ function effectiveOverride(
|
|
|
566
811
|
};
|
|
567
812
|
}
|
|
568
813
|
|
|
814
|
+
/**
|
|
815
|
+
* A pinned window resolved onto the clock `map` defines — `resolveSplitPoints`'
|
|
816
|
+
* posture, for windows instead of points.
|
|
817
|
+
*
|
|
818
|
+
* A LEGACY entry passes through verbatim and needs no map at all: its numbers
|
|
819
|
+
* are already output seconds, meaningful exactly when `map` IS that clock
|
|
820
|
+
* (produce's situation after `remapOverridesThroughRecut` re-anchored them,
|
|
821
|
+
* and the editor's pass-1). A SRC entry maps both edges; `null` on EITHER
|
|
822
|
+
* means the material this pin names is removed on this clock, so the pin is
|
|
823
|
+
* INERT this run — never half-resolved and never clamped onto a seam, the
|
|
824
|
+
* `cleanup.kept`/`resolveSplitPoints` inert-entry posture: the doc keeps the
|
|
825
|
+
* entry and it wakes up when that material is kept again.
|
|
826
|
+
*/
|
|
827
|
+
export function resolveTimingPin(
|
|
828
|
+
timing: SceneTiming,
|
|
829
|
+
map: TimeMap,
|
|
830
|
+
): { startSec: number; endSec: number } | null {
|
|
831
|
+
if (!isSrcTiming(timing)) return { startSec: timing.startSec, endSec: timing.endSec };
|
|
832
|
+
const startSec = map.toOutput(timing.srcStart);
|
|
833
|
+
const endSec = map.toOutput(timing.srcEnd);
|
|
834
|
+
if (startSec === null || endSec === null) return null;
|
|
835
|
+
return { startSec, endSec };
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Resolve every SRC-anchored pin in `doc` onto the clock `map` defines,
|
|
840
|
+
* leaving a doc whose `timing` entries are all output-clock — what
|
|
841
|
+
* `applyOverrides` (and, downstream of it, `reclampPinnedTiming`) consumes.
|
|
842
|
+
*
|
|
843
|
+
* A PRE-PASS rather than a `map` parameter on `applyOverrides` deliberately:
|
|
844
|
+
* that function is called four times across two callers, on three different
|
|
845
|
+
* clocks, and an optional map would let a src pin silently no-op wherever the
|
|
846
|
+
* argument was forgotten. This shape makes the clock an explicit decision at
|
|
847
|
+
* every call site — the caller states which cue list, and therefore which
|
|
848
|
+
* clock, it is about to merge onto.
|
|
849
|
+
*
|
|
850
|
+
* The returned doc is for THAT merge only and must never be written back:
|
|
851
|
+
* persisting the resolved numbers would spend the source anchor and put the
|
|
852
|
+
* pin back on an old clock, which is the whole bug this schema arm fixes.
|
|
853
|
+
* Docs with no src pin get the SAME object back, so a session that has never
|
|
854
|
+
* written one computes bit-identical values to before this existed.
|
|
855
|
+
*/
|
|
856
|
+
export function resolveSrcTimingPins(
|
|
857
|
+
doc: OverrideDoc,
|
|
858
|
+
map: TimeMap,
|
|
859
|
+
): { doc: OverrideDoc; reports: string[] } {
|
|
860
|
+
const pinned = Object.keys(doc.scenes).filter((id) => {
|
|
861
|
+
const t = doc.scenes[id]?.timing;
|
|
862
|
+
return t !== undefined && isSrcTiming(t);
|
|
863
|
+
});
|
|
864
|
+
if (pinned.length === 0) return { doc, reports: [] };
|
|
865
|
+
const reports: string[] = [];
|
|
866
|
+
const scenes = { ...doc.scenes };
|
|
867
|
+
for (const id of pinned) {
|
|
868
|
+
const scene = scenes[id]!;
|
|
869
|
+
const window = resolveTimingPin(scene.timing!, map);
|
|
870
|
+
if (window === null) {
|
|
871
|
+
// The pin LEAVES this doc rather than being carried unresolved: an
|
|
872
|
+
// unresolved src entry reaching `applyOverrides` is ignored there
|
|
873
|
+
// anyway, and dropping it here is what makes that ignoring provable
|
|
874
|
+
// rather than incidental.
|
|
875
|
+
const { timing: _inert, ...rest } = scene;
|
|
876
|
+
scenes[id] = rest;
|
|
877
|
+
reports.push(`pinned timing for "${id}" is not in this cut — pin inert`);
|
|
878
|
+
continue;
|
|
879
|
+
}
|
|
880
|
+
scenes[id] = { ...scene, timing: window };
|
|
881
|
+
}
|
|
882
|
+
return { doc: { ...doc, scenes }, reports };
|
|
883
|
+
}
|
|
884
|
+
|
|
569
885
|
export function applyOverrides(cues: readonly SceneCue[], doc: OverrideDoc): AppliedOverrides {
|
|
570
886
|
const ids = new Set(cues.map((c) => c.id));
|
|
571
887
|
const orphans = Object.keys(doc.scenes).filter((id) => !ids.has(id));
|
|
@@ -604,7 +920,13 @@ export function applyOverrides(cues: readonly SceneCue[], doc: OverrideDoc): App
|
|
|
604
920
|
// window to its first half — which kept the scene's id — would undo
|
|
605
921
|
// the cut and overlap the second half. An unsplit pinned cue skips a
|
|
606
922
|
// byte-identical re-application; a not-yet-pinned cue pins as before.
|
|
607
|
-
|
|
923
|
+
// A SRC-anchored pin is NOT applied here: this function has no map, and
|
|
924
|
+
// the numbers it would need depend on which clock `cues` is on. The
|
|
925
|
+
// caller resolves them first (`resolveSrcTimingPins`, above) — reaching
|
|
926
|
+
// this branch with a src entry means no pre-pass ran, and doing nothing
|
|
927
|
+
// is the only honest answer available (guessing an output window from a
|
|
928
|
+
// source second is exactly the old-clock lie this arm removes).
|
|
929
|
+
...(o.timing && !isSrcTiming(o.timing) && !cue.pinned
|
|
608
930
|
? { startSec: o.timing.startSec, endSec: o.timing.endSec, pinned: true }
|
|
609
931
|
: {}),
|
|
610
932
|
};
|
|
@@ -638,7 +960,10 @@ export const SPLIT_MIN_PIECE_SEC = 0.3;
|
|
|
638
960
|
* intro animation (a Sequence restarts at its own frame 0) — acceptable for
|
|
639
961
|
* the feature's real use, cutting takes and re-timing halves.
|
|
640
962
|
*/
|
|
641
|
-
export function splitCues(
|
|
963
|
+
export function splitCues(
|
|
964
|
+
cues: readonly SceneCue[],
|
|
965
|
+
splits: readonly ResolvedSplitPoint[],
|
|
966
|
+
): SceneCue[] {
|
|
642
967
|
const out = [...cues];
|
|
643
968
|
for (const s of [...splits].sort((a, b) => a.at - b.at)) {
|
|
644
969
|
const i = out.findIndex(
|
|
@@ -709,8 +1034,12 @@ export function dropHiddenCues(cues: readonly SceneCue[], doc: OverrideDoc): Dro
|
|
|
709
1034
|
export function splitThenDropHidden(
|
|
710
1035
|
cues: readonly SceneCue[],
|
|
711
1036
|
doc: OverrideDoc,
|
|
1037
|
+
// Resolved points, when the caller holds a map (produce, the editor's
|
|
1038
|
+
// post-carve pass). Default: the doc's old-clock `at` entries — src-only
|
|
1039
|
+
// splits have no image on that clock and are correctly absent.
|
|
1040
|
+
points: readonly ResolvedSplitPoint[] = atSplitPoints(doc.splits),
|
|
712
1041
|
): DropHiddenResult {
|
|
713
|
-
return dropHiddenCues(splitCues(cues,
|
|
1042
|
+
return dropHiddenCues(splitCues(cues, points), doc);
|
|
714
1043
|
}
|
|
715
1044
|
|
|
716
1045
|
/**
|
|
@@ -1637,8 +1966,9 @@ export function applyCaptionRangeEdits(
|
|
|
1637
1966
|
* only the rendered caption stream loses the word.
|
|
1638
1967
|
*
|
|
1639
1968
|
* Line WINDOWS are recomputed here, deliberately: `buildCaptionLines` derives
|
|
1640
|
-
* `start` from the first word and `end` from the last word plus a hold
|
|
1641
|
-
*
|
|
1969
|
+
* `start` from the first word and `end` from the last word plus a hold (its
|
|
1970
|
+
* hold/breakpoint clamp loop, then `enforceLineDwell`), so hiding a boundary
|
|
1971
|
+
* word would otherwise leave the
|
|
1642
1972
|
* line lingering on screen over silence — up for the hidden first word's
|
|
1643
1973
|
* duration, or held past the hidden last word's end. A hidden FIRST word moves
|
|
1644
1974
|
* `start` to the first survivor; a hidden LAST word re-bases the packer's hold
|
|
@@ -1701,7 +2031,7 @@ export function applyCaptionWordHides(
|
|
|
1701
2031
|
const firstKept = kept[0]!;
|
|
1702
2032
|
const lastKept = kept[kept.length - 1]!;
|
|
1703
2033
|
const start = firstKept === line.words[0] ? line.start : firstKept.start;
|
|
1704
|
-
// The packer's hold delta (
|
|
2034
|
+
// The packer's hold delta (`buildCaptionLines`) rides on whichever word
|
|
1705
2035
|
// is now last; clamped so the line never ends before its own last word
|
|
1706
2036
|
// (the delta can be negative when the hold was clamped to outputDuration).
|
|
1707
2037
|
const end =
|
|
@@ -1784,7 +2114,7 @@ export function scaleWordsIntoWindow(
|
|
|
1784
2114
|
* line's END and the next line's START are two separate numbers here — even
|
|
1785
2115
|
* though on a real transcript they are always equal, because the packer chains
|
|
1786
2116
|
* words (`transcribe.ts`: `next.start = w.end`) and clamps each line's end to
|
|
1787
|
-
* the next line's start (`
|
|
2117
|
+
* the next line's start (`buildCaptionLines`), giving inter-line gaps of
|
|
1788
2118
|
* exactly zero (measured 116/116, see `captionLineTiming`). A nudge CLOSES the
|
|
1789
2119
|
* two onto one value only when they were already COINCIDENT: that is what
|
|
1790
2120
|
* makes a lead on the packed stream move both sides of the boundary, one edit
|
|
@@ -1792,8 +2122,10 @@ export function scaleWordsIntoWindow(
|
|
|
1792
2122
|
*
|
|
1793
2123
|
* They are two numbers because GAPS ARE REAL: `applyCaptionWordHides` re-bases
|
|
1794
2124
|
* a line's window onto its surviving words, `MAX_CAPTION_WORD_LEAD_SEC`
|
|
1795
|
-
* (captions.ts
|
|
1796
|
-
* can be hand-edited.
|
|
2125
|
+
* (`captions.ts`) clamps a word's display start, and an overrides.json
|
|
2126
|
+
* can be hand-edited. (Citations here name the constant or function
|
|
2127
|
+
* deliberately — the line numbers they used to carry went stale the first
|
|
2128
|
+
* time `captions.ts` grew a constant.) This code
|
|
1797
2129
|
* used to hold ONE `seams` array whose interior entry was read off the later
|
|
1798
2130
|
* line's start, conflating the two: with lines `[0,2] [2,4] [5,6]`, a
|
|
1799
2131
|
* lead-only drag of the middle line (`{lead: -0.05, tail: 0}`, exactly what
|
|
@@ -1940,11 +2272,100 @@ export function applyCaptionLineTiming(
|
|
|
1940
2272
|
return { lines: out, dropped };
|
|
1941
2273
|
}
|
|
1942
2274
|
|
|
2275
|
+
/**
|
|
2276
|
+
* Apply per-LINE caption display WINDOWS (`captionLineWindows`) — the LAST
|
|
2277
|
+
* layer of all, after the nudges, because a window is the user's FINAL answer
|
|
2278
|
+
* about when a caption is on screen: it is stated absolutely, so anything that
|
|
2279
|
+
* ran before it (a derived window, a nudge on top of that window) is exactly
|
|
2280
|
+
* what it is overriding. A line carrying both records therefore shows its
|
|
2281
|
+
* window, and the nudge is inert rather than compounded — the audio-first tool
|
|
2282
|
+
* that writes windows replaced the nudge popover, so the pair only coexists on
|
|
2283
|
+
* docs edited across the change.
|
|
2284
|
+
*
|
|
2285
|
+
* SOURCE SECONDS IN, OUTPUT SECONDS OUT. The window is stored against the
|
|
2286
|
+
* waveform's clock (`captionLineWindows`' docstring: the one clock a re-cut
|
|
2287
|
+
* cannot move) and the caption track speaks output seconds, so each edge goes
|
|
2288
|
+
* through `map.toOutputClamped` — the same conversion `buildCaptionLines` uses
|
|
2289
|
+
* for the derived windows this replaces, with the same documented behaviour on
|
|
2290
|
+
* material that was cut: the edge lands on the nearest KEPT edge rather than
|
|
2291
|
+
* disappearing. A window whose material was removed ENTIRELY collapses both
|
|
2292
|
+
* edges onto one instant and comes back out at the floor below, sitting on the
|
|
2293
|
+
* seam: the user's window is kept and made visible, not silently dropped,
|
|
2294
|
+
* because the cut is the thing they are more likely to be about to undo.
|
|
2295
|
+
*
|
|
2296
|
+
* NO SWEEP, DELIBERATELY — see the field's docstring. Windows may overlap;
|
|
2297
|
+
* ordering against neighbours is not this layer's business, and every line
|
|
2298
|
+
* whose key carries no entry comes back VERBATIM (same reference, same word
|
|
2299
|
+
* stamps) so one placed caption cannot perturb the rest of the track. The
|
|
2300
|
+
* MIN_CAPTION_SEC floor is the ONE clamp, applied in output seconds because a
|
|
2301
|
+
* cut inside the window can narrow it however wide the source span was.
|
|
2302
|
+
*
|
|
2303
|
+
* Words are re-stamped into the new window by `scaleWordsIntoWindow` — the
|
|
2304
|
+
* karaoke highlight reads word stamps INSIDE the line's `<Sequence>`
|
|
2305
|
+
* (`CaptionTrack.tsx`), so a window moved without them lights the wrong words
|
|
2306
|
+
* or none.
|
|
2307
|
+
*
|
|
2308
|
+
* Same reporting contract as every other per-line layer: an anchor no line
|
|
2309
|
+
* starts on is `found: null` (a cut removed the word, or a hide emptied the
|
|
2310
|
+
* line), a second claimant is `duplicate-anchor`, and `expected` is always
|
|
2311
|
+
* `""` because timing is text-orthogonal (`applyCaptionLineTiming`'s no-`was`
|
|
2312
|
+
* reasoning applies unchanged).
|
|
2313
|
+
*/
|
|
2314
|
+
export function applyCaptionLineWindows(
|
|
2315
|
+
lines: readonly CaptionLine[],
|
|
2316
|
+
windows: Record<string, { srcStart: number; srcEnd: number }>,
|
|
2317
|
+
map: TimeMap,
|
|
2318
|
+
): AppliedCaptionEdits {
|
|
2319
|
+
const dropped: AppliedCaptionEdits["dropped"] = [];
|
|
2320
|
+
// NO LINES is not "no windows to report" — `applyCaptionLineTiming`'s own
|
|
2321
|
+
// note owns the why (silence here let produce report placements that never
|
|
2322
|
+
// happened); the editor's false-banner guard lives at the caller.
|
|
2323
|
+
if (lines.length === 0) {
|
|
2324
|
+
for (const key of Object.keys(windows)) dropped.push({ key, expected: "", found: null });
|
|
2325
|
+
return { lines: [], dropped };
|
|
2326
|
+
}
|
|
2327
|
+
if (Object.keys(windows).length === 0) return { lines: [...lines], dropped };
|
|
2328
|
+
|
|
2329
|
+
const seen = new Set<string>();
|
|
2330
|
+
const out = lines.map((line) => {
|
|
2331
|
+
// No anchor, no window — the same boundary rule as `applyCaptionEdits`: a
|
|
2332
|
+
// pre-§137 word cannot be addressed, and the stored windows then fall out
|
|
2333
|
+
// of the sweep below as `found: null`.
|
|
2334
|
+
const key = captionAnchorOf(line.words[0]);
|
|
2335
|
+
const entry = key === null ? undefined : windows[key];
|
|
2336
|
+
if (key === null || !entry) return line;
|
|
2337
|
+
// An earlier line already answered for this anchor — placing here too
|
|
2338
|
+
// would fan one window onto a second line (captions.ts:44-50: ms-quantised
|
|
2339
|
+
// anchors CAN collide).
|
|
2340
|
+
if (seen.has(key)) {
|
|
2341
|
+
dropped.push({ key, expected: "", found: line.words[0]!.text, reason: "duplicate-anchor" });
|
|
2342
|
+
return line;
|
|
2343
|
+
}
|
|
2344
|
+
seen.add(key);
|
|
2345
|
+
const start = map.toOutputClamped(entry.srcStart);
|
|
2346
|
+
const end = Math.max(map.toOutputClamped(entry.srcEnd), start + MIN_CAPTION_SEC);
|
|
2347
|
+
if (start === line.start && end === line.end) return line;
|
|
2348
|
+
return {
|
|
2349
|
+
...line,
|
|
2350
|
+
start,
|
|
2351
|
+
end,
|
|
2352
|
+
words: scaleWordsIntoWindow(line.words, line.start, line.end, start, end),
|
|
2353
|
+
};
|
|
2354
|
+
});
|
|
2355
|
+
|
|
2356
|
+
for (const key of Object.keys(windows)) {
|
|
2357
|
+
if (!seen.has(key)) dropped.push({ key, expected: "", found: null });
|
|
2358
|
+
}
|
|
2359
|
+
return { lines: out, dropped };
|
|
2360
|
+
}
|
|
2361
|
+
|
|
1943
2362
|
export interface AppliedCaptionLayers {
|
|
1944
2363
|
lines: CaptionLine[];
|
|
1945
2364
|
/** Every layer's drop reports, tagged with which layer refused them. */
|
|
1946
2365
|
dropped: Array<
|
|
1947
|
-
AppliedCaptionEdits["dropped"][number] & {
|
|
2366
|
+
AppliedCaptionEdits["dropped"][number] & {
|
|
2367
|
+
layer: "edit" | "range" | "hide" | "timing" | "window";
|
|
2368
|
+
}
|
|
1948
2369
|
>;
|
|
1949
2370
|
}
|
|
1950
2371
|
|
|
@@ -1968,22 +2389,34 @@ export interface AppliedCaptionLayers {
|
|
|
1968
2389
|
* window to move (`applyCaptionLineTiming`). Drop reports carry which layer
|
|
1969
2390
|
* refused them, since "the retype missed", "the rewrite missed" and "the
|
|
1970
2391
|
* delete missed" send the user to different gestures.
|
|
2392
|
+
*
|
|
2393
|
+
* WINDOWS run after the nudges, last of all — `applyCaptionLineWindows` owns
|
|
2394
|
+
* the why (an absolute answer overrides a relative one, never compounds with
|
|
2395
|
+
* it). That layer is the reason this composer takes a `map`: a window is
|
|
2396
|
+
* stored in SOURCE seconds and the caption track speaks output seconds, so
|
|
2397
|
+
* placing one needs the same cutlist the lines were built through. Required,
|
|
2398
|
+
* not optional — a caller without a map would silently skip the layer, which
|
|
2399
|
+
* is how the editor preview and the render would come to disagree about when
|
|
2400
|
+
* a caption is on screen (the one thing this chokepoint exists to prevent).
|
|
1971
2401
|
*/
|
|
1972
2402
|
export function applyCaptionLayers(
|
|
1973
2403
|
lines: readonly CaptionLine[],
|
|
1974
2404
|
doc: OverrideDoc,
|
|
2405
|
+
map: TimeMap,
|
|
1975
2406
|
): AppliedCaptionLayers {
|
|
1976
2407
|
const edited = applyCaptionEdits(lines, doc.captions);
|
|
1977
2408
|
const ranged = applyCaptionRangeEdits(edited.lines, doc.captionRangeEdits);
|
|
1978
2409
|
const hidden = applyCaptionWordHides(ranged.lines, doc.captionWordsHidden);
|
|
1979
2410
|
const timed = applyCaptionLineTiming(hidden.lines, doc.captionLineTiming);
|
|
2411
|
+
const windowed = applyCaptionLineWindows(timed.lines, doc.captionLineWindows, map);
|
|
1980
2412
|
return {
|
|
1981
|
-
lines:
|
|
2413
|
+
lines: windowed.lines,
|
|
1982
2414
|
dropped: [
|
|
1983
2415
|
...edited.dropped.map((d) => ({ ...d, layer: "edit" as const })),
|
|
1984
2416
|
...ranged.dropped.map((d) => ({ ...d, layer: "range" as const })),
|
|
1985
2417
|
...hidden.dropped.map((d) => ({ ...d, layer: "hide" as const })),
|
|
1986
2418
|
...timed.dropped.map((d) => ({ ...d, layer: "timing" as const })),
|
|
2419
|
+
...windowed.dropped.map((d) => ({ ...d, layer: "window" as const })),
|
|
1987
2420
|
],
|
|
1988
2421
|
};
|
|
1989
2422
|
}
|