@ossclip/core 0.1.30 → 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/assemble.ts +3 -0
- 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 +713 -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 +22 -0
package/src/overrides.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { z } from "zod/v4";
|
|
2
2
|
import {
|
|
3
3
|
LayoutSchema,
|
|
4
|
+
SceneAnchorSchema,
|
|
4
5
|
SceneComponentIdSchema,
|
|
5
6
|
ThemeSchema,
|
|
7
|
+
type SceneAnchor,
|
|
6
8
|
type SceneCue,
|
|
7
9
|
type SceneComponentId,
|
|
8
10
|
type Theme,
|
|
9
11
|
} from "./scene-schema";
|
|
10
12
|
import { RemovalReasonSchema } from "./schema";
|
|
13
|
+
import type { TimeMap } from "./timemap";
|
|
11
14
|
import { resolveSceneProps } from "./scene-registry";
|
|
12
15
|
import type { CaptionLine, CaptionWord } from "./captions";
|
|
13
16
|
|
|
@@ -40,16 +43,75 @@ export const ElementTransformSchema = z.object({
|
|
|
40
43
|
});
|
|
41
44
|
export type ElementTransform = z.infer<typeof ElementTransformSchema>;
|
|
42
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
|
+
|
|
43
104
|
export const SceneOverrideSchema = z.object({
|
|
44
105
|
/** Merged over the producer's props, key by key. */
|
|
45
106
|
props: z.record(z.string(), z.unknown()).default({}),
|
|
46
107
|
/** Per-element nudges, keyed by the component's `data-edit-id`. */
|
|
47
108
|
elements: z.record(z.string(), ElementTransformSchema).default({}),
|
|
48
109
|
/**
|
|
49
|
-
*
|
|
110
|
+
* An absolute window. Setting this PINS the scene: it stops tracking the
|
|
50
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.
|
|
51
113
|
*/
|
|
52
|
-
timing:
|
|
114
|
+
timing: SceneTimingSchema.optional(),
|
|
53
115
|
/**
|
|
54
116
|
* Component/layout swaps (design spec Scope: v1 in-scope). Optional — most
|
|
55
117
|
* scenes never touch these — and validated against the same enums the
|
|
@@ -121,6 +183,16 @@ export const SceneOverrideSchema = z.object({
|
|
|
121
183
|
h: z.number().min(0.05).max(1),
|
|
122
184
|
})
|
|
123
185
|
.optional(),
|
|
186
|
+
/**
|
|
187
|
+
* The word range of the cue this edit was made against, stamped by the
|
|
188
|
+
* editor at save time (stampSceneAnchors). This is the edit's IDENTITY
|
|
189
|
+
* across a re-plan: ids are positional (`scene-${i}`) and a re-plan can
|
|
190
|
+
* hand an id to a different moment — matching on the anchor instead is
|
|
191
|
+
* what stops that edit landing there silently (handoff-edit-anchoring).
|
|
192
|
+
* Optional: docs written before this field keep id-only behaviour, the
|
|
193
|
+
* same no-retroactive-protection posture §137 took for captions.
|
|
194
|
+
*/
|
|
195
|
+
anchor: SceneAnchorSchema.optional(),
|
|
124
196
|
/**
|
|
125
197
|
* The scene is deleted — SOFTLY (PLAN 2026-07-30 Task C): the cue drops
|
|
126
198
|
* from the render (`dropHiddenCues`) and its window becomes a plain take,
|
|
@@ -249,13 +321,96 @@ export const SplitSchema = z.union([
|
|
|
249
321
|
// words. zod v4's `z.number()` already rejects non-finite where v3's did
|
|
250
322
|
// not, so this is a requirement written down at the site instead of a
|
|
251
323
|
// default that has already changed once underneath this file.
|
|
252
|
-
|
|
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
|
+
}),
|
|
253
352
|
// Legacy: a bare number, upgraded in place so every overrides.json written
|
|
254
353
|
// before §137 parses and keeps its split-half overrides attached.
|
|
255
|
-
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) })),
|
|
256
361
|
]);
|
|
257
362
|
export type Split = z.infer<typeof SplitSchema>;
|
|
258
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
|
+
|
|
259
414
|
/**
|
|
260
415
|
* A split id that no split in `existing` already holds.
|
|
261
416
|
*
|
|
@@ -378,17 +533,79 @@ export const OverrideDocSchema = z.object({
|
|
|
378
533
|
)
|
|
379
534
|
.default({}),
|
|
380
535
|
/**
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
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.
|
|
387
544
|
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
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.
|
|
392
609
|
*/
|
|
393
610
|
splits: z.array(SplitSchema).default([]),
|
|
394
611
|
/**
|
|
@@ -415,15 +632,38 @@ export const OverrideDocSchema = z.object({
|
|
|
415
632
|
* a historical record of what render-props they were looking at, never
|
|
416
633
|
* authoritative again once `src` is present.
|
|
417
634
|
*
|
|
418
|
-
* The editor (
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
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.
|
|
427
667
|
*/
|
|
428
668
|
cuts: z
|
|
429
669
|
.array(
|
|
@@ -470,8 +710,25 @@ export const OverrideDocSchema = z.object({
|
|
|
470
710
|
kept: z
|
|
471
711
|
.array(z.object({ srcIn: z.number().nonnegative(), srcOut: z.number().nonnegative() }))
|
|
472
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([]),
|
|
473
730
|
})
|
|
474
|
-
.default({ reasons: {}, kept: [] }),
|
|
731
|
+
.default({ reasons: {}, kept: [], dismissed: [] }),
|
|
475
732
|
});
|
|
476
733
|
export type OverrideDoc = z.infer<typeof OverrideDocSchema>;
|
|
477
734
|
|
|
@@ -554,6 +811,77 @@ function effectiveOverride(
|
|
|
554
811
|
};
|
|
555
812
|
}
|
|
556
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
|
+
|
|
557
885
|
export function applyOverrides(cues: readonly SceneCue[], doc: OverrideDoc): AppliedOverrides {
|
|
558
886
|
const ids = new Set(cues.map((c) => c.id));
|
|
559
887
|
const orphans = Object.keys(doc.scenes).filter((id) => !ids.has(id));
|
|
@@ -592,7 +920,13 @@ export function applyOverrides(cues: readonly SceneCue[], doc: OverrideDoc): App
|
|
|
592
920
|
// window to its first half — which kept the scene's id — would undo
|
|
593
921
|
// the cut and overlap the second half. An unsplit pinned cue skips a
|
|
594
922
|
// byte-identical re-application; a not-yet-pinned cue pins as before.
|
|
595
|
-
|
|
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
|
|
596
930
|
? { startSec: o.timing.startSec, endSec: o.timing.endSec, pinned: true }
|
|
597
931
|
: {}),
|
|
598
932
|
};
|
|
@@ -626,7 +960,10 @@ export const SPLIT_MIN_PIECE_SEC = 0.3;
|
|
|
626
960
|
* intro animation (a Sequence restarts at its own frame 0) — acceptable for
|
|
627
961
|
* the feature's real use, cutting takes and re-timing halves.
|
|
628
962
|
*/
|
|
629
|
-
export function splitCues(
|
|
963
|
+
export function splitCues(
|
|
964
|
+
cues: readonly SceneCue[],
|
|
965
|
+
splits: readonly ResolvedSplitPoint[],
|
|
966
|
+
): SceneCue[] {
|
|
630
967
|
const out = [...cues];
|
|
631
968
|
for (const s of [...splits].sort((a, b) => a.at - b.at)) {
|
|
632
969
|
const i = out.findIndex(
|
|
@@ -697,8 +1034,245 @@ export function dropHiddenCues(cues: readonly SceneCue[], doc: OverrideDoc): Dro
|
|
|
697
1034
|
export function splitThenDropHidden(
|
|
698
1035
|
cues: readonly SceneCue[],
|
|
699
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),
|
|
700
1041
|
): DropHiddenResult {
|
|
701
|
-
return dropHiddenCues(splitCues(cues,
|
|
1042
|
+
return dropHiddenCues(splitCues(cues, points), doc);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Stamp every scene override with the anchor of the cue it currently targets
|
|
1047
|
+
* — the edit's identity across a re-plan (see SceneOverrideSchema.anchor).
|
|
1048
|
+
* Called by the EDITOR at save time, from the cues in its memory, never from
|
|
1049
|
+
* render-props.json on disk: after a mid-session re-render the disk can
|
|
1050
|
+
* describe a newer plan than the one the user is looking at, and stamping
|
|
1051
|
+
* from it would record the wrong identity — the misapply this exists to stop.
|
|
1052
|
+
* Cues without an anchor (plain takes, pre-anchor render-props) stamp nothing.
|
|
1053
|
+
*
|
|
1054
|
+
* RE-stamps on every save, deliberately: the cue on screen is always the
|
|
1055
|
+
* freshest truth about what the user is editing, so a stale stamp from an
|
|
1056
|
+
* earlier plan is overwritten rather than preserved. A split half's cue
|
|
1057
|
+
* carries its root's anchor verbatim (`splitCues` spreads the root cue), so
|
|
1058
|
+
* the `id@<split id>` entry stamps through the same by-id lookup as its root.
|
|
1059
|
+
*
|
|
1060
|
+
* `doc` must have been through `OverrideDocSchema` — the `captionEditsToKeep`
|
|
1061
|
+
* rule: a literal `"__proto__"` key surviving `JSON.parse` as an own property
|
|
1062
|
+
* would assign through the prototype in the record rebuild below.
|
|
1063
|
+
*/
|
|
1064
|
+
export function stampSceneAnchors(doc: OverrideDoc, cues: readonly SceneCue[]): OverrideDoc {
|
|
1065
|
+
const anchorById = new Map<string, SceneAnchor>();
|
|
1066
|
+
for (const c of cues) {
|
|
1067
|
+
if (c.anchor) anchorById.set(c.id, c.anchor);
|
|
1068
|
+
}
|
|
1069
|
+
const scenes: Record<string, SceneOverride> = {};
|
|
1070
|
+
for (const [id, entry] of Object.entries(doc.scenes)) {
|
|
1071
|
+
const anchor = anchorById.get(id);
|
|
1072
|
+
scenes[id] = anchor ? { ...entry, anchor } : entry;
|
|
1073
|
+
}
|
|
1074
|
+
return { ...doc, scenes };
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/** Shared word count of two anchors — inclusive word-index ranges, so
|
|
1078
|
+
* touching at a single word counts as 1 and disjoint ranges go ≤ 0. */
|
|
1079
|
+
const wordOverlap = (a: SceneAnchor, b: SceneAnchor): number =>
|
|
1080
|
+
Math.min(a.endWord, b.endWord) - Math.max(a.startWord, b.startWord) + 1;
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* The inert suffix a misapply-blocked edit is parked under. `#` never
|
|
1084
|
+
* appears in a cue id (`scene-${i}`, `take-*`, split halves use `@`), so a
|
|
1085
|
+
* parked key matches no cue and the edit sits harmless — data and anchor
|
|
1086
|
+
* intact — until a later plan brings its words back and rescues it.
|
|
1087
|
+
*/
|
|
1088
|
+
const PARKED_SUFFIX = "#orphaned";
|
|
1089
|
+
|
|
1090
|
+
/** The one exported spelling of the parked-key convention: produce's orphan
|
|
1091
|
+
* warning must ask this, never re-spell the literal, or the two sides drift. */
|
|
1092
|
+
export function isParkedOverrideKey(key: string): boolean {
|
|
1093
|
+
return key.endsWith(PARKED_SUFFIX);
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
/** The key a parked entry was parked FROM — what the user's warning should
|
|
1097
|
+
* name, since the suffix is bookkeeping, not something they ever typed. */
|
|
1098
|
+
export function parkedOverrideBaseKey(key: string): string {
|
|
1099
|
+
return isParkedOverrideKey(key) ? key.slice(0, -PARKED_SUFFIX.length) : key;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
export interface SceneRemapResult {
|
|
1103
|
+
doc: OverrideDoc;
|
|
1104
|
+
/** Human sentences for produce to print — one per re-keyed, parked, or blocked entry. */
|
|
1105
|
+
notes: string[];
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Re-key scene overrides onto the cues that carry their WORDS — the
|
|
1110
|
+
* produce-side counterpart of `stampSceneAnchors` above
|
|
1111
|
+
* (handoff-edit-anchoring; §137 is the caption-side precedent).
|
|
1112
|
+
*
|
|
1113
|
+
* Ids are positional (`scene-${i}`) and a re-plan renumbers them freely: in
|
|
1114
|
+
* the two plan pairs measured for this change, 8/11 and 2/10 ids moved while
|
|
1115
|
+
* every anchor still found its moment at 100% overlap — and in the field,
|
|
1116
|
+
* `scene-4` was a TerminalMock over words 85..116 in one plan and a
|
|
1117
|
+
* FlowDiagram over words 47..57 in the next. An edit keyed by id alone lands
|
|
1118
|
+
* on that impostor silently. So the stored anchor, not the key, is the
|
|
1119
|
+
* edit's identity: an entry whose id still means the same moment (any word
|
|
1120
|
+
* overlap) is untouched; one whose words moved follows them to their new id;
|
|
1121
|
+
* one whose words are GONE while its id points at a different moment is
|
|
1122
|
+
* parked under `${key}#orphaned` rather than left to join the impostor. A
|
|
1123
|
+
* parked entry's root id is historical, not a claim on today's cue, so it
|
|
1124
|
+
* skips the id-agreement shortcut and matches purely by anchor. A parked
|
|
1125
|
+
* entry whose words are STILL gone stays quiet on later runs; one whose
|
|
1126
|
+
* words are back while its target key is held re-parks and re-notes on
|
|
1127
|
+
* EVERY run — deliberately, because that collision is actionable.
|
|
1128
|
+
*
|
|
1129
|
+
* Anchor-less (pre-migration) entries pass through byte-identical with no
|
|
1130
|
+
* note — the same no-retroactive-protection posture §137 took for captions.
|
|
1131
|
+
* Total on any parsed doc: conflicts resolve deterministically (kept entries
|
|
1132
|
+
* are immovable; contending re-keys go to the larger overlap; an occupied
|
|
1133
|
+
* park slot keeps its incumbent), never a throw.
|
|
1134
|
+
*
|
|
1135
|
+
* Runs on the post-fill, PRE-`splitCues` cue list, so a split-half key
|
|
1136
|
+
* (`id@splitId`) re-keys by its ROOT and keeps its suffix — the half cue it
|
|
1137
|
+
* must match only exists after `splitCues` runs. `doc` must have been
|
|
1138
|
+
* through `OverrideDocSchema` — the `captionEditsToKeep` rule: a literal
|
|
1139
|
+
* `"__proto__"` key surviving `JSON.parse` as an own property would assign
|
|
1140
|
+
* through the prototype in the record rebuild below.
|
|
1141
|
+
*/
|
|
1142
|
+
export function remapSceneOverrides(
|
|
1143
|
+
doc: OverrideDoc,
|
|
1144
|
+
cues: readonly SceneCue[],
|
|
1145
|
+
): SceneRemapResult {
|
|
1146
|
+
// Anchor-bearing root cues only. Plain fill takes carry no anchor by
|
|
1147
|
+
// construction, and the `@` filter guards against a caller passing a
|
|
1148
|
+
// POST-split list — a half carries its root's anchor verbatim
|
|
1149
|
+
// (`splitCues` spreads the root cue), and matching a half's id here would
|
|
1150
|
+
// mint double-suffixed keys like `scene-1@2000@abc`.
|
|
1151
|
+
const anchored = cues.filter(
|
|
1152
|
+
(c): c is SceneCue & { anchor: SceneAnchor } =>
|
|
1153
|
+
c.anchor !== undefined && !c.id.includes("@"),
|
|
1154
|
+
);
|
|
1155
|
+
const notes: string[] = [];
|
|
1156
|
+
const scenes: Record<string, SceneOverride> = {};
|
|
1157
|
+
interface Claim {
|
|
1158
|
+
key: string;
|
|
1159
|
+
entry: SceneOverride;
|
|
1160
|
+
/** Where this entry lands if it loses its target: `${baseKey}#orphaned`. */
|
|
1161
|
+
parkKey: string;
|
|
1162
|
+
ov: number;
|
|
1163
|
+
}
|
|
1164
|
+
/** Entries headed for a park slot, with the sentence explaining why. */
|
|
1165
|
+
const parks: Array<{ key: string; entry: SceneOverride; parkKey: string; note: string }> = [];
|
|
1166
|
+
/** Re-keying entries, grouped by the key they want — collisions resolve below. */
|
|
1167
|
+
const rekeys = new Map<string, Claim[]>();
|
|
1168
|
+
|
|
1169
|
+
for (const [key, entry] of Object.entries(doc.scenes)) {
|
|
1170
|
+
const stored = entry.anchor;
|
|
1171
|
+
if (!stored) {
|
|
1172
|
+
// Pre-migration entry: exactly today's id-only behaviour, silently.
|
|
1173
|
+
scenes[key] = entry;
|
|
1174
|
+
continue;
|
|
1175
|
+
}
|
|
1176
|
+
const isParked = key.endsWith(PARKED_SUFFIX);
|
|
1177
|
+
const baseKey = isParked ? key.slice(0, -PARKED_SUFFIX.length) : key;
|
|
1178
|
+
const at = baseKey.indexOf("@");
|
|
1179
|
+
const rootId = at === -1 ? baseKey : baseKey.slice(0, at);
|
|
1180
|
+
const parkKey = `${baseKey}${PARKED_SUFFIX}`;
|
|
1181
|
+
const current = anchored.find((c) => c.id === rootId);
|
|
1182
|
+
if (!isParked && current && wordOverlap(stored, current.anchor) > 0) {
|
|
1183
|
+
scenes[key] = entry; // the id still means the same moment
|
|
1184
|
+
continue;
|
|
1185
|
+
}
|
|
1186
|
+
// Id missing, pointing at a different moment, or historical (parked):
|
|
1187
|
+
// follow the anchor.
|
|
1188
|
+
const best = anchored
|
|
1189
|
+
.map((c) => ({ c, ov: wordOverlap(stored, c.anchor) }))
|
|
1190
|
+
.filter((x) => x.ov > 0)
|
|
1191
|
+
// Larger overlap first; on a tie, the cue whose id matches the stored
|
|
1192
|
+
// root (reachable only for parked entries — an unparked id match with
|
|
1193
|
+
// overlap was kept above), then the earlier cue.
|
|
1194
|
+
.sort(
|
|
1195
|
+
(a, b) =>
|
|
1196
|
+
b.ov - a.ov ||
|
|
1197
|
+
Number(b.c.id === rootId) - Number(a.c.id === rootId) ||
|
|
1198
|
+
a.c.startSec - b.c.startSec,
|
|
1199
|
+
)[0];
|
|
1200
|
+
if (!best) {
|
|
1201
|
+
if (!isParked && current) {
|
|
1202
|
+
// The words are gone AND the id now belongs to a different moment.
|
|
1203
|
+
// Leaving the entry under `key` would join the impostor — the exact
|
|
1204
|
+
// silent misapply this pass exists to prevent. Park it.
|
|
1205
|
+
parks.push({
|
|
1206
|
+
key,
|
|
1207
|
+
entry,
|
|
1208
|
+
parkKey,
|
|
1209
|
+
note: `edit for ${key} parked — its words left the plan, and ${rootId} now shows a different moment`,
|
|
1210
|
+
});
|
|
1211
|
+
} else {
|
|
1212
|
+
// The old id matches nothing (or the entry is already parked):
|
|
1213
|
+
// today's orphan path — `applyOverrides` reports it, nothing can
|
|
1214
|
+
// misapply, so no note either.
|
|
1215
|
+
scenes[key] = entry;
|
|
1216
|
+
}
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
const newKey = at === -1 ? best.c.id : `${best.c.id}${baseKey.slice(at)}`;
|
|
1220
|
+
const list = rekeys.get(newKey) ?? [];
|
|
1221
|
+
list.push({ key, entry, parkKey, ov: best.ov });
|
|
1222
|
+
rekeys.set(newKey, list);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
for (const [newKey, contenders] of rekeys) {
|
|
1226
|
+
if (scenes[newKey] !== undefined) {
|
|
1227
|
+
// Kept entries are immovable: an anchor-less one must behave exactly
|
|
1228
|
+
// as today, and an id-plus-anchor match is the strongest claim there
|
|
1229
|
+
// is. A re-keyer arriving at a held key parks instead of evicting.
|
|
1230
|
+
for (const c of contenders) {
|
|
1231
|
+
parks.push({
|
|
1232
|
+
key: c.key,
|
|
1233
|
+
entry: c.entry,
|
|
1234
|
+
parkKey: c.parkKey,
|
|
1235
|
+
note: `edit for ${c.key} parked — ${newKey} already carries its own edit`,
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
// Two entries re-keying onto one cue: the larger overlap wins, the loser
|
|
1241
|
+
// parks, both get a sentence. On an exact tie, doc order — deterministic,
|
|
1242
|
+
// and as good as any claim two different stored anchors can make on the
|
|
1243
|
+
// same cue. (`sort` is stable, so equal overlaps keep insertion order.)
|
|
1244
|
+
const [winner, ...losers] = [...contenders].sort((a, b) => b.ov - a.ov);
|
|
1245
|
+
scenes[newKey] = winner!.entry;
|
|
1246
|
+
notes.push(
|
|
1247
|
+
winner!.key.endsWith(PARKED_SUFFIX)
|
|
1248
|
+
? `edit for ${winner!.key} rescued to ${newKey} — its words are back in the plan`
|
|
1249
|
+
: `edit for ${winner!.key} re-keyed to ${newKey} — the plan renumbered, its words moved there`,
|
|
1250
|
+
);
|
|
1251
|
+
for (const l of losers) {
|
|
1252
|
+
parks.push({
|
|
1253
|
+
key: l.key,
|
|
1254
|
+
entry: l.entry,
|
|
1255
|
+
parkKey: l.parkKey,
|
|
1256
|
+
note: `edit for ${l.key} parked — the edit from ${winner!.key} overlaps ${newKey}'s words more (${winner!.ov} vs ${l.ov})`,
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
for (const p of parks) {
|
|
1262
|
+
if (scenes[p.parkKey] !== undefined) {
|
|
1263
|
+
// Doubly-pathological: the slot already holds a still-parked edit for
|
|
1264
|
+
// the same base key (edit → re-plan parks it → edit again → re-plan
|
|
1265
|
+
// again). One inert slot, two edits — keep the incumbent, like every
|
|
1266
|
+
// other hold, and say the loss out loud rather than overwriting
|
|
1267
|
+
// silently.
|
|
1268
|
+
notes.push(`edit for ${p.key} dropped — ${p.parkKey} already holds an earlier parked edit`);
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
scenes[p.parkKey] = p.entry;
|
|
1272
|
+
notes.push(p.note);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
return { doc: { ...doc, scenes }, notes };
|
|
702
1276
|
}
|
|
703
1277
|
|
|
704
1278
|
/**
|
|
@@ -1392,8 +1966,9 @@ export function applyCaptionRangeEdits(
|
|
|
1392
1966
|
* only the rendered caption stream loses the word.
|
|
1393
1967
|
*
|
|
1394
1968
|
* Line WINDOWS are recomputed here, deliberately: `buildCaptionLines` derives
|
|
1395
|
-
* `start` from the first word and `end` from the last word plus a hold
|
|
1396
|
-
*
|
|
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
|
|
1397
1972
|
* line lingering on screen over silence — up for the hidden first word's
|
|
1398
1973
|
* duration, or held past the hidden last word's end. A hidden FIRST word moves
|
|
1399
1974
|
* `start` to the first survivor; a hidden LAST word re-bases the packer's hold
|
|
@@ -1456,7 +2031,7 @@ export function applyCaptionWordHides(
|
|
|
1456
2031
|
const firstKept = kept[0]!;
|
|
1457
2032
|
const lastKept = kept[kept.length - 1]!;
|
|
1458
2033
|
const start = firstKept === line.words[0] ? line.start : firstKept.start;
|
|
1459
|
-
// The packer's hold delta (
|
|
2034
|
+
// The packer's hold delta (`buildCaptionLines`) rides on whichever word
|
|
1460
2035
|
// is now last; clamped so the line never ends before its own last word
|
|
1461
2036
|
// (the delta can be negative when the hold was clamped to outputDuration).
|
|
1462
2037
|
const end =
|
|
@@ -1539,7 +2114,7 @@ export function scaleWordsIntoWindow(
|
|
|
1539
2114
|
* line's END and the next line's START are two separate numbers here — even
|
|
1540
2115
|
* though on a real transcript they are always equal, because the packer chains
|
|
1541
2116
|
* words (`transcribe.ts`: `next.start = w.end`) and clamps each line's end to
|
|
1542
|
-
* the next line's start (`
|
|
2117
|
+
* the next line's start (`buildCaptionLines`), giving inter-line gaps of
|
|
1543
2118
|
* exactly zero (measured 116/116, see `captionLineTiming`). A nudge CLOSES the
|
|
1544
2119
|
* two onto one value only when they were already COINCIDENT: that is what
|
|
1545
2120
|
* makes a lead on the packed stream move both sides of the boundary, one edit
|
|
@@ -1547,8 +2122,10 @@ export function scaleWordsIntoWindow(
|
|
|
1547
2122
|
*
|
|
1548
2123
|
* They are two numbers because GAPS ARE REAL: `applyCaptionWordHides` re-bases
|
|
1549
2124
|
* a line's window onto its surviving words, `MAX_CAPTION_WORD_LEAD_SEC`
|
|
1550
|
-
* (captions.ts
|
|
1551
|
-
* 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
|
|
1552
2129
|
* used to hold ONE `seams` array whose interior entry was read off the later
|
|
1553
2130
|
* line's start, conflating the two: with lines `[0,2] [2,4] [5,6]`, a
|
|
1554
2131
|
* lead-only drag of the middle line (`{lead: -0.05, tail: 0}`, exactly what
|
|
@@ -1695,11 +2272,100 @@ export function applyCaptionLineTiming(
|
|
|
1695
2272
|
return { lines: out, dropped };
|
|
1696
2273
|
}
|
|
1697
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
|
+
|
|
1698
2362
|
export interface AppliedCaptionLayers {
|
|
1699
2363
|
lines: CaptionLine[];
|
|
1700
2364
|
/** Every layer's drop reports, tagged with which layer refused them. */
|
|
1701
2365
|
dropped: Array<
|
|
1702
|
-
AppliedCaptionEdits["dropped"][number] & {
|
|
2366
|
+
AppliedCaptionEdits["dropped"][number] & {
|
|
2367
|
+
layer: "edit" | "range" | "hide" | "timing" | "window";
|
|
2368
|
+
}
|
|
1703
2369
|
>;
|
|
1704
2370
|
}
|
|
1705
2371
|
|
|
@@ -1723,22 +2389,34 @@ export interface AppliedCaptionLayers {
|
|
|
1723
2389
|
* window to move (`applyCaptionLineTiming`). Drop reports carry which layer
|
|
1724
2390
|
* refused them, since "the retype missed", "the rewrite missed" and "the
|
|
1725
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).
|
|
1726
2401
|
*/
|
|
1727
2402
|
export function applyCaptionLayers(
|
|
1728
2403
|
lines: readonly CaptionLine[],
|
|
1729
2404
|
doc: OverrideDoc,
|
|
2405
|
+
map: TimeMap,
|
|
1730
2406
|
): AppliedCaptionLayers {
|
|
1731
2407
|
const edited = applyCaptionEdits(lines, doc.captions);
|
|
1732
2408
|
const ranged = applyCaptionRangeEdits(edited.lines, doc.captionRangeEdits);
|
|
1733
2409
|
const hidden = applyCaptionWordHides(ranged.lines, doc.captionWordsHidden);
|
|
1734
2410
|
const timed = applyCaptionLineTiming(hidden.lines, doc.captionLineTiming);
|
|
2411
|
+
const windowed = applyCaptionLineWindows(timed.lines, doc.captionLineWindows, map);
|
|
1735
2412
|
return {
|
|
1736
|
-
lines:
|
|
2413
|
+
lines: windowed.lines,
|
|
1737
2414
|
dropped: [
|
|
1738
2415
|
...edited.dropped.map((d) => ({ ...d, layer: "edit" as const })),
|
|
1739
2416
|
...ranged.dropped.map((d) => ({ ...d, layer: "range" as const })),
|
|
1740
2417
|
...hidden.dropped.map((d) => ({ ...d, layer: "hide" as const })),
|
|
1741
2418
|
...timed.dropped.map((d) => ({ ...d, layer: "timing" as const })),
|
|
2419
|
+
...windowed.dropped.map((d) => ({ ...d, layer: "window" as const })),
|
|
1742
2420
|
],
|
|
1743
2421
|
};
|
|
1744
2422
|
}
|