@ossclip/core 0.1.18 → 0.1.20
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 +16 -2
- package/src/captions.ts +63 -3
- package/src/export-fcpxml.ts +112 -0
- package/src/index.ts +1 -0
- package/src/overrides.ts +522 -38
- package/src/recut.ts +60 -2
package/package.json
CHANGED
package/src/browser.ts
CHANGED
|
@@ -22,6 +22,20 @@ export {
|
|
|
22
22
|
// lineDirection is a VALUE export but stays browser-safe: captions.ts
|
|
23
23
|
// imports types only. CaptionTrack needs it at render time (Urdu field test
|
|
24
24
|
// 2026-08-05 — RTL lines were laying out LTR).
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// `backfillSrcStart` rides along for the same reason: the EDITOR is the load
|
|
26
|
+
// path that has to repair a pre-§137 render-props.json before anything derives
|
|
27
|
+
// a caption key from it (§137), and the editor imports this surface. Pure, and
|
|
28
|
+
// captions.ts stays browser-safe — it imports types plus timemap, which is
|
|
29
|
+
// itself type-only against ./schema.
|
|
30
|
+
export {
|
|
31
|
+
backfillSrcStart,
|
|
32
|
+
lineDirection,
|
|
33
|
+
type CaptionLine,
|
|
34
|
+
type CaptionWord,
|
|
35
|
+
} from "./captions";
|
|
36
|
+
// `mapFromKeptSpans` is a VALUE export and browser-safe (timemap.ts imports
|
|
37
|
+
// types only). The editor's load-path repair needs the MAP, not the raw span
|
|
38
|
+
// array, to decide whether a repair is possible at all — see
|
|
39
|
+
// `anchorCaptionLines` (§137): a non-empty array can still build an empty map.
|
|
40
|
+
export { mapFromKeptSpans, TimeMap, type KeptSpan } from "./timemap";
|
|
27
41
|
export type { Probe, Production, RenderSettings, Segment, Transcript, Word } from "./schema";
|
package/src/captions.ts
CHANGED
|
@@ -1,11 +1,23 @@
|
|
|
1
1
|
import type { Transcript, Word } from "./schema";
|
|
2
|
-
import type
|
|
2
|
+
import { mapFromKeptSpans, type KeptSpan, type TimeMap } from "./timemap";
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Caption *timing* lives in OUTPUT time: `start`/`end` are what the renderer
|
|
6
|
+
* draws against and they know nothing about cuts. `srcStart` is the deliberate
|
|
7
|
+
* exception (§137) — it is SOURCE time, carried so a re-cut cannot move it.
|
|
8
|
+
* Never read it as an output instant.
|
|
9
|
+
*/
|
|
5
10
|
export interface CaptionWord {
|
|
6
11
|
text: string;
|
|
7
12
|
start: number;
|
|
8
13
|
end: number;
|
|
14
|
+
/**
|
|
15
|
+
* The word's start in SOURCE seconds (§137). `start`/`end` above are OUTPUT
|
|
16
|
+
* times and a re-cut moves them; this does not, which is what lets a caption
|
|
17
|
+
* edit survive one. The field the edit layer keys on — see
|
|
18
|
+
* `captionKeyFor` in overrides.ts.
|
|
19
|
+
*/
|
|
20
|
+
srcStart: number;
|
|
9
21
|
}
|
|
10
22
|
|
|
11
23
|
export interface CaptionLine {
|
|
@@ -14,6 +26,52 @@ export interface CaptionLine {
|
|
|
14
26
|
end: number;
|
|
15
27
|
}
|
|
16
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Fill in `srcStart` for caption lines read back from a `render-props.json`
|
|
31
|
+
* written before the field existed (§137).
|
|
32
|
+
*
|
|
33
|
+
* The type promises `srcStart` on every word, but the on-disk format predates
|
|
34
|
+
* it and there is no schema at that boundary — the editor loads render props
|
|
35
|
+
* as an unvalidated cast — so lines that TYPECHECK as `CaptionLine` can still
|
|
36
|
+
* arrive with the field missing. Without this, every legacy word would key on
|
|
37
|
+
* the same absent value and a retype would anchor to the wrong word: the exact
|
|
38
|
+
* failure the source anchor exists to prevent, arriving silently instead of as
|
|
39
|
+
* a crash.
|
|
40
|
+
*
|
|
41
|
+
* It is recoverable because the same file still carries `spans`: the word's
|
|
42
|
+
* output start projected back through the map those spans describe. That is a
|
|
43
|
+
* BEST-EFFORT recovery, not an inverse — exact for a word whose start survived
|
|
44
|
+
* the cut uncut, approximate in two cases the file no longer records:
|
|
45
|
+
* - at a seam (`outOut_k === outIn_{k+1}`) an output instant has two source
|
|
46
|
+
* preimages and `toSource` returns the earlier one (`timemap.ts:21-23`), so
|
|
47
|
+
* a word that truly began at `srcIn_{k+1}` lands on the far side of the cut;
|
|
48
|
+
* - a word whose start fell INSIDE a removed span was clamped to the nearest
|
|
49
|
+
* kept edge by `toOutputClamped` when the file was written, so its source
|
|
50
|
+
* instant is simply gone and backfill returns the edge.
|
|
51
|
+
* Both are the best the data on disk supports — the alternative is no anchor at
|
|
52
|
+
* all. Words written with a real `srcStart` are never re-derived this way.
|
|
53
|
+
* Pure — the caller owns reading the file.
|
|
54
|
+
*/
|
|
55
|
+
export function backfillSrcStart(
|
|
56
|
+
lines: readonly CaptionLine[],
|
|
57
|
+
spans: readonly KeptSpan[],
|
|
58
|
+
): CaptionLine[] {
|
|
59
|
+
/** The shape a legacy file actually holds, versus the one the type promises. */
|
|
60
|
+
type LegacyWord = Omit<CaptionWord, "srcStart"> & { srcStart?: number };
|
|
61
|
+
const legacy = (line: CaptionLine) => line.words as readonly LegacyWord[];
|
|
62
|
+
// Nothing to recover from a file already written with the field — and an
|
|
63
|
+
// existing `srcStart` is never recomputed, because the map that produced it
|
|
64
|
+
// may not be the one in `spans`.
|
|
65
|
+
if (lines.every((l) => legacy(l).every((w) => w.srcStart !== undefined))) return [...lines];
|
|
66
|
+
const map = mapFromKeptSpans(spans);
|
|
67
|
+
return lines.map((line) => ({
|
|
68
|
+
...line,
|
|
69
|
+
words: legacy(line).map((w) =>
|
|
70
|
+
w.srcStart === undefined ? { ...w, srcStart: map.toSource(w.start) } : (w as CaptionWord),
|
|
71
|
+
),
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
17
75
|
/**
|
|
18
76
|
* Per-LINE direction from the text itself — the first-strong-character
|
|
19
77
|
* heuristic (Unicode UAX #9 rules P2/P3), not the transcript's language
|
|
@@ -65,7 +123,9 @@ export function buildCaptionLines(
|
|
|
65
123
|
const mapped: CaptionWord[] = [];
|
|
66
124
|
for (const w of transcript.words) {
|
|
67
125
|
const m = map.mapWord(w as Word);
|
|
68
|
-
|
|
126
|
+
// `w.start` is source time, `m.start` output — both are needed, and only
|
|
127
|
+
// the source one is stable across a re-cut (§137).
|
|
128
|
+
if (m) mapped.push({ text: w.text, start: m.start, end: m.end, srcStart: w.start });
|
|
69
129
|
}
|
|
70
130
|
|
|
71
131
|
const lines: CaptionLine[] = [];
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import type { Production, Segment } from "./schema";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* FCPXML 1.10 marker export — the file format half of `ossclip analyse`
|
|
7
|
+
* (next-directions §2; design doc 2026-08-12-analyse-fcpxml-export-design.md).
|
|
8
|
+
* Pure by the house split: Production in, XML string out; the CLI owns the
|
|
9
|
+
* file write. The document is a project whose single asset-clip is the
|
|
10
|
+
* ORIGINAL source with one named marker per planned cut — markers, not
|
|
11
|
+
* applied cuts, because an editor wants to REVIEW "dead air here / blooper
|
|
12
|
+
* here" before acting on it, and both Resolve and Premiere read this format.
|
|
13
|
+
*
|
|
14
|
+
* No marker colours, resolved deliberately: stock FCPXML `<marker>` carries
|
|
15
|
+
* no colour attribute, and colour does not reliably survive a Resolve round
|
|
16
|
+
* trip — so the cut REASON travels in the marker name (the report.txt
|
|
17
|
+
* vocabulary), which every importer displays.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A frame's duration as an exact rational — FCPXML's own time currency. */
|
|
21
|
+
export interface FrameDuration {
|
|
22
|
+
num: number;
|
|
23
|
+
den: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* NTSC rates have no integer-denominator form — 29.97 is exactly 30000/1001,
|
|
28
|
+
* and ffprobe reports it as a float that equals neither 29.97 nor the
|
|
29
|
+
* rational. Matched by tolerance for that reason; anything else is treated
|
|
30
|
+
* as the integer rate it rounds to.
|
|
31
|
+
*/
|
|
32
|
+
const NTSC_RATES: Array<{ fps: number; fd: FrameDuration }> = [
|
|
33
|
+
{ fps: 24000 / 1001, fd: { num: 1001, den: 24000 } },
|
|
34
|
+
{ fps: 30000 / 1001, fd: { num: 1001, den: 30000 } },
|
|
35
|
+
{ fps: 60000 / 1001, fd: { num: 1001, den: 60000 } },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export function fpsToFrameDuration(fps: number): FrameDuration {
|
|
39
|
+
for (const { fps: ntsc, fd } of NTSC_RATES) {
|
|
40
|
+
if (Math.abs(fps - ntsc) < 0.01) return fd;
|
|
41
|
+
}
|
|
42
|
+
return { num: 1, den: Math.round(fps) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A seconds value as a frame-aligned FCPXML rational (`"53/30s"`). A float
|
|
47
|
+
* like `1.77s` is legal FCPXML but frame-misaligned, and importers round it
|
|
48
|
+
* silently — quantizing here keeps the marker on the frame the report named,
|
|
49
|
+
* with the rounding visible in this codebase instead of implicit in theirs.
|
|
50
|
+
*/
|
|
51
|
+
export function quantizeToFrame(seconds: number, fd: FrameDuration): string {
|
|
52
|
+
const frames = Math.round((seconds * fd.den) / fd.num);
|
|
53
|
+
return `${frames * fd.num}/${fd.den}s`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function esc(s: string): string {
|
|
57
|
+
return s
|
|
58
|
+
.replaceAll("&", "&")
|
|
59
|
+
.replaceAll("<", "<")
|
|
60
|
+
.replaceAll(">", ">")
|
|
61
|
+
.replaceAll('"', """)
|
|
62
|
+
.replaceAll("'", "'");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The report.txt line, minus the timestamps the marker position already is. */
|
|
66
|
+
function markerName(seg: Segment): string {
|
|
67
|
+
const dur = (seg.srcOut - seg.srcIn).toFixed(2);
|
|
68
|
+
const conf = seg.confidence !== undefined ? ` (conf ${seg.confidence.toFixed(2)})` : "";
|
|
69
|
+
return `${seg.reason ?? "cut"} −${dur}s${conf}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function buildFcpxmlMarkers(production: Production): string {
|
|
73
|
+
const { path, probe } = production.source;
|
|
74
|
+
const fd = fpsToFrameDuration(probe.fps);
|
|
75
|
+
const dur = quantizeToFrame(probe.duration, fd);
|
|
76
|
+
const name = basename(path);
|
|
77
|
+
const removals = (production.cutlist ?? []).filter((s) => s.kind === "remove");
|
|
78
|
+
const markers = removals
|
|
79
|
+
.map(
|
|
80
|
+
(s) =>
|
|
81
|
+
` <marker start="${quantizeToFrame(s.srcIn, fd)}" ` +
|
|
82
|
+
`duration="${fd.num}/${fd.den}s" value="${esc(markerName(s))}"/>`,
|
|
83
|
+
)
|
|
84
|
+
.join("\n");
|
|
85
|
+
// pathToFileURL, not string concat: it percent-encodes the characters a
|
|
86
|
+
// URL cannot carry (spaces, #) while the XML escaping below handles the
|
|
87
|
+
// ones an ATTRIBUTE cannot (&) — two encodings, two owners.
|
|
88
|
+
const srcUrl = esc(pathToFileURL(path).href);
|
|
89
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
90
|
+
<!DOCTYPE fcpxml>
|
|
91
|
+
<fcpxml version="1.10">
|
|
92
|
+
<resources>
|
|
93
|
+
<format id="r1" frameDuration="${fd.num}/${fd.den}s" width="${probe.width}" height="${probe.height}"/>
|
|
94
|
+
<asset id="r2" name="${esc(name)}" start="0/${fd.den}s" duration="${dur}" hasVideo="1" hasAudio="${probe.hasAudio ? 1 : 0}" format="r1">
|
|
95
|
+
<media-rep kind="original-media" src="${srcUrl}"/>
|
|
96
|
+
</asset>
|
|
97
|
+
</resources>
|
|
98
|
+
<library>
|
|
99
|
+
<event name="ossclip">
|
|
100
|
+
<project name="${esc(`${name} — ossclip markers`)}">
|
|
101
|
+
<sequence format="r1" duration="${dur}" tcStart="0/${fd.den}s">
|
|
102
|
+
<spine>
|
|
103
|
+
<asset-clip ref="r2" offset="0/${fd.den}s" name="${esc(name)}" start="0/${fd.den}s" duration="${dur}" format="r1">
|
|
104
|
+
${markers ? `${markers}\n` : ""} </asset-clip>
|
|
105
|
+
</spine>
|
|
106
|
+
</sequence>
|
|
107
|
+
</project>
|
|
108
|
+
</event>
|
|
109
|
+
</library>
|
|
110
|
+
</fcpxml>
|
|
111
|
+
`;
|
|
112
|
+
}
|
package/src/index.ts
CHANGED
package/src/overrides.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
type Theme,
|
|
9
9
|
} from "./scene-schema";
|
|
10
10
|
import { resolveSceneProps } from "./scene-registry";
|
|
11
|
-
import type { CaptionLine } from "./captions";
|
|
11
|
+
import type { CaptionLine, CaptionWord } from "./captions";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* The user's edit layer (SPEC: direct manipulation).
|
|
@@ -98,8 +98,8 @@ export const SceneOverrideSchema = z.object({
|
|
|
98
98
|
/**
|
|
99
99
|
* Vertical centre for this scene's captions (R15 §56). NOT part of the
|
|
100
100
|
* top-level `captions` key — that one is the caption TEXT retype map,
|
|
101
|
-
* keyed
|
|
102
|
-
*
|
|
101
|
+
* keyed per word; position is a property of the SCENE, where the timeline
|
|
102
|
+
* selection can address it and "apply to all" can fan it out.
|
|
103
103
|
*/
|
|
104
104
|
captionY: z.number().min(0).max(1).optional(),
|
|
105
105
|
/** Caption size multiplier (R16 §64) — same per-scene, fan-out-able shape
|
|
@@ -136,12 +136,13 @@ export type SceneOverride = z.infer<typeof SceneOverrideSchema>;
|
|
|
136
136
|
* One retyped caption word (PLAN 2026-07-29 Task 7, scope (a) — decided with
|
|
137
137
|
* the author: 1:1 in-place retype, timing untouched).
|
|
138
138
|
*
|
|
139
|
-
* Keyed by the word's
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
139
|
+
* Keyed by the word's SOURCE time since §137 (`captionKeyFor` below — the
|
|
140
|
+
* original positional key is what a user cut broke), GUARDED by the text that
|
|
141
|
+
* was there when the edit was made — the same verification-anchor pattern as
|
|
142
|
+
* `AppliedRepair.heard` (§17). Captions are derived (repaired transcript
|
|
143
|
+
* through the TimeMap), so a changed cleanup level or repair set can still
|
|
144
|
+
* re-word the stream under an anchor that survived; the guard means a stale
|
|
145
|
+
* edit is DROPPED WITH A LOG rather than silently landing on the wrong word.
|
|
145
146
|
*/
|
|
146
147
|
export const CaptionEditSchema = z.object({
|
|
147
148
|
/** The replacement text. */
|
|
@@ -162,10 +163,78 @@ export type CaptionEdit = z.infer<typeof CaptionEditSchema>;
|
|
|
162
163
|
*/
|
|
163
164
|
export function captionEditWas(
|
|
164
165
|
captions: Record<string, CaptionEdit>,
|
|
165
|
-
|
|
166
|
+
key: string,
|
|
166
167
|
seen: string,
|
|
167
168
|
): string {
|
|
168
|
-
return captions[
|
|
169
|
+
return captions[key]?.was ?? seen;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The id a pre-§137 split gets when it is upgraded: the output milliseconds of
|
|
174
|
+
* whatever `at` the file holds NOW.
|
|
175
|
+
*
|
|
176
|
+
* Load-bearing for the migration — a saved doc hiding `scene-0@600` should
|
|
177
|
+
* still match that half after the upgrade — but ONLY for a doc that has not
|
|
178
|
+
* already been through a re-anchoring produce run, and the distinction is not
|
|
179
|
+
* cosmetic (final review, Important 3). `at` is the one thing a re-cut moves,
|
|
180
|
+
* so on a doc the §137 bug already damaged this reproduces the CURRENT time,
|
|
181
|
+
* not the original: the field workdir's live `overrides.json` holds
|
|
182
|
+
* `splits: [0]`, mints id `"0"`, and its saved `scene-0@600` matches nothing.
|
|
183
|
+
* There is no better derivation available — the original ms is genuinely gone
|
|
184
|
+
* from a re-anchored file, and nothing on disk records it — so the honest
|
|
185
|
+
* statement is that a doc damaged BEFORE this fix landed cannot recover its
|
|
186
|
+
* split-half overrides, and only `overrides.json.bak` can (which is why
|
|
187
|
+
* `produce`'s write gate must not spend it; final review, Critical 2).
|
|
188
|
+
*/
|
|
189
|
+
export function legacySplitId(at: number): string {
|
|
190
|
+
return String(Math.round(at * 1000));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export const SplitSchema = z.union([
|
|
194
|
+
// `.finite()` is stated rather than assumed. JSON has no Infinity literal
|
|
195
|
+
// but an overflowing one (`1e400`) parses to it, and a non-finite `at`
|
|
196
|
+
// would derive `id: "Infinity"` — one shared name for every such split,
|
|
197
|
+
// the same garbage-derived-key failure `captionKeyFor` refuses for caption
|
|
198
|
+
// words. zod v4's `z.number()` already rejects non-finite where v3's did
|
|
199
|
+
// not, so this is a requirement written down at the site instead of a
|
|
200
|
+
// default that has already changed once underneath this file.
|
|
201
|
+
z.object({ at: z.number().finite().nonnegative(), id: z.string().min(1) }),
|
|
202
|
+
// Legacy: a bare number, upgraded in place so every overrides.json written
|
|
203
|
+
// before §137 parses and keeps its split-half overrides attached.
|
|
204
|
+
z.number().finite().nonnegative().transform((at) => ({ at, id: legacySplitId(at) })),
|
|
205
|
+
]);
|
|
206
|
+
export type Split = z.infer<typeof SplitSchema>;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A split id that no split in `existing` already holds.
|
|
210
|
+
*
|
|
211
|
+
* Uniqueness is load-bearing (§137): the id is the ONLY thing tying an
|
|
212
|
+
* override to a split half — `splitCues` names the half `${rootId}@${id}` and
|
|
213
|
+
* `dropHiddenCues` filters on that exact string — so two splits sharing an id
|
|
214
|
+
* mint two cues with one name, and deleting one half deletes both, as does
|
|
215
|
+
* any framing or timing edit on it.
|
|
216
|
+
*
|
|
217
|
+
* Decoupling `id` from `at` is what made this reachable. While the id was
|
|
218
|
+
* recomputed from the time, two ids could only collide if two splits sat
|
|
219
|
+
* within 0.5ms of each other, which `SPLIT_MIN_PIECE_SEC` forbids. Now a
|
|
220
|
+
* split minted at 1.2s and re-anchored to 0.6s by a re-cut still holds
|
|
221
|
+
* `"1200"`, so ⌘B at 1.2s again asks for an id that is taken — and
|
|
222
|
+
* `addSplit`'s dedupe cannot see it, because that compares `at` and 0.6 is
|
|
223
|
+
* nowhere near 1.2.
|
|
224
|
+
*
|
|
225
|
+
* The suffix is a COUNTER, deliberately, not a nonce or a timestamp: this
|
|
226
|
+
* value is persisted in the user's `overrides.json` and names a cue, so it
|
|
227
|
+
* has to be reproducible from the doc alone.
|
|
228
|
+
*/
|
|
229
|
+
export function mintSplitId(at: number, existing: readonly Split[]): string {
|
|
230
|
+
const base = legacySplitId(at);
|
|
231
|
+
const taken = new Set(existing.map((s) => s.id));
|
|
232
|
+
if (!taken.has(base)) return base;
|
|
233
|
+
// Starts at 2 so the first collision reads as "the second `1200`".
|
|
234
|
+
for (let n = 2; ; n++) {
|
|
235
|
+
const candidate = `${base}-${n}`;
|
|
236
|
+
if (!taken.has(candidate)) return candidate;
|
|
237
|
+
}
|
|
169
238
|
}
|
|
170
239
|
|
|
171
240
|
export const OverrideDocSchema = z.object({
|
|
@@ -185,16 +254,22 @@ export const OverrideDocSchema = z.object({
|
|
|
185
254
|
*/
|
|
186
255
|
captionsHidden: z.boolean().optional(),
|
|
187
256
|
scenes: z.record(z.string(), SceneOverrideSchema).default({}),
|
|
188
|
-
/** Retyped caption words, keyed by
|
|
257
|
+
/** Retyped caption words, keyed by the word's source time (§137). */
|
|
189
258
|
captions: z.record(z.string(), CaptionEditSchema).default({}),
|
|
190
259
|
/**
|
|
191
|
-
* Scene split points
|
|
192
|
-
* the playhead)
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
260
|
+
* Scene split points. `at` is ABSOLUTE output seconds (R16 §61 — Cmd/Ctrl+B
|
|
261
|
+
* at the playhead) and moves when a re-cut re-anchors the doc; `id` is
|
|
262
|
+
* minted once when the split is created and NEVER recomputed (§137). The
|
|
263
|
+
* split half is named `${rootId}@${id}`, so re-anchoring `at` cannot rename
|
|
264
|
+
* the half out from under a `hidden` (or any other) override on it — the
|
|
265
|
+
* bug that resurrected a deleted scene in the field case.
|
|
266
|
+
*
|
|
267
|
+
* `at` stays time-anchored rather than scene-anchored on purpose: a re-plan
|
|
268
|
+
* can rename or move scenes, and WHERE to cut is a decision about a MOMENT
|
|
269
|
+
* of the output. Applied by `splitCues` after the plain fill, so a split
|
|
270
|
+
* lands on graphic cues and takes alike.
|
|
196
271
|
*/
|
|
197
|
-
splits: z.array(
|
|
272
|
+
splits: z.array(SplitSchema).default([]),
|
|
198
273
|
/**
|
|
199
274
|
* User cuts — ranges of the OUTPUT to remove, in the output seconds of the
|
|
200
275
|
* CURRENT render-props (what the user saw when they cut) (PLAN 2026-08-04
|
|
@@ -292,8 +367,9 @@ function resolveSwappedProps(
|
|
|
292
367
|
/**
|
|
293
368
|
* The override entry a cue resolves against: its own, layered over its split
|
|
294
369
|
* ROOT's (R16 §68). A split half is still the same scene — captions scaled
|
|
295
|
-
* on the original must render scaled on both halves — so `id
|
|
296
|
-
* everything from `id
|
|
370
|
+
* on the original must render scaled on both halves — so `id@<split id>`
|
|
371
|
+
* inherits everything from `id` (the suffix is the split's own minted id since
|
|
372
|
+
* §137, not a time), with two exceptions that describe the WHOLE original
|
|
297
373
|
* rather than a piece of it: `timing` (the root's absolute window would undo
|
|
298
374
|
* the split) and `hidden` (deleting the original is not deleting one half).
|
|
299
375
|
* The half's OWN entry wins key by key, and the record-shaped keys merge
|
|
@@ -378,10 +454,14 @@ export const SPLIT_MIN_PIECE_SEC = 0.3;
|
|
|
378
454
|
* Cut cues at the stored split points (R16 §61).
|
|
379
455
|
*
|
|
380
456
|
* Both halves keep everything but their window; the half STARTING at the
|
|
381
|
-
* split takes the id `${
|
|
382
|
-
* stay attached while the split exists,
|
|
383
|
-
* original cue, and are reported as
|
|
384
|
-
*
|
|
457
|
+
* split takes the id `${rootId}@${split.id}` — named by the split's OWN
|
|
458
|
+
* minted id (§137), so edits on it stay attached while the split exists,
|
|
459
|
+
* survive further splits of the same original cue, and are reported as
|
|
460
|
+
* orphans (never misapplied) if the split is removed. The id used to be
|
|
461
|
+
* recomputed from the split's CURRENT start time, which meant a re-cut
|
|
462
|
+
* re-anchoring `at` renamed the half and orphaned every override on it —
|
|
463
|
+
* the field case where a deleted scene came back after a 0.6s cut.
|
|
464
|
+
* Runs AFTER `fillPlainCues` so takes split like scenes do, and
|
|
385
465
|
* BEFORE the final override pass so the halves' own edits (framing, timing,
|
|
386
466
|
* elements) land on them. A split that misses every cue — after a re-plan
|
|
387
467
|
* moved the material — is skipped; the time stays in the doc, harmless.
|
|
@@ -389,23 +469,25 @@ export const SPLIT_MIN_PIECE_SEC = 0.3;
|
|
|
389
469
|
* intro animation (a Sequence restarts at its own frame 0) — acceptable for
|
|
390
470
|
* the feature's real use, cutting takes and re-timing halves.
|
|
391
471
|
*/
|
|
392
|
-
export function splitCues(cues: readonly SceneCue[],
|
|
472
|
+
export function splitCues(cues: readonly SceneCue[], splits: readonly Split[]): SceneCue[] {
|
|
393
473
|
const out = [...cues];
|
|
394
|
-
for (const
|
|
474
|
+
for (const s of [...splits].sort((a, b) => a.at - b.at)) {
|
|
395
475
|
const i = out.findIndex(
|
|
396
|
-
(c) =>
|
|
476
|
+
(c) => s.at >= c.startSec + SPLIT_MIN_PIECE_SEC && s.at <= c.endSec - SPLIT_MIN_PIECE_SEC,
|
|
397
477
|
);
|
|
398
478
|
if (i === -1) continue;
|
|
399
479
|
const cue = out[i]!;
|
|
400
480
|
// Derive from the ROOT id, not the (possibly already-split) cue id:
|
|
401
481
|
// `take-0@6000`, never `take-0@3000@6000` — so a half's id depends only
|
|
402
|
-
// on the original cue and
|
|
482
|
+
// on the original cue and the split that made it, and adding an EARLIER
|
|
403
483
|
// split cannot rename later halves out from under their edits.
|
|
404
484
|
out.splice(
|
|
405
485
|
i,
|
|
406
486
|
1,
|
|
407
|
-
{ ...cue, endSec:
|
|
408
|
-
|
|
487
|
+
{ ...cue, endSec: s.at },
|
|
488
|
+
// The suffix comes from the SPLIT's own id, not from `s.at` — that is
|
|
489
|
+
// the §137 fix.
|
|
490
|
+
{ ...cue, id: `${cue.id.split("@")[0]}@${s.id}`, startSec: s.at },
|
|
409
491
|
);
|
|
410
492
|
}
|
|
411
493
|
return out;
|
|
@@ -462,17 +544,396 @@ export function splitThenDropHidden(
|
|
|
462
544
|
return dropHiddenCues(splitCues(cues, doc.splits), doc);
|
|
463
545
|
}
|
|
464
546
|
|
|
547
|
+
/**
|
|
548
|
+
* A caption edit's key: the word's source start, quantised to milliseconds
|
|
549
|
+
* (§137). Positional indices were the original design and a user cut breaks
|
|
550
|
+
* them — removing one word shifts every later index, so the `was` guard below
|
|
551
|
+
* fires on every edit and the user's retypes vanish into the report nobody
|
|
552
|
+
* printed. Source time is the one property of a word that a re-cut cannot
|
|
553
|
+
* move.
|
|
554
|
+
*
|
|
555
|
+
* THROWS on a non-finite `srcStart` rather than minting `wNaN`. The type
|
|
556
|
+
* promises a number and `captions.ts:33-39` says outright that the promise is
|
|
557
|
+
* a lie at the render-props boundary — the editor loads that file as an
|
|
558
|
+
* unvalidated cast, so a pre-§137 workdir yields words with the field absent.
|
|
559
|
+
* `w${Math.round(NaN * 1000)}` is `"wNaN"` for EVERY word: one shared anchor
|
|
560
|
+
* for a whole video, under which a single stored edit would rewrite every word
|
|
561
|
+
* in it. That is the failure this whole change exists to prevent, arriving
|
|
562
|
+
* silently. Parse, never coerce — and a loud throw is the parse here, since a
|
|
563
|
+
* missing anchor has no honest fallback. `backfillSrcStart` (Task 6's load
|
|
564
|
+
* path) is what keeps legacy files from reaching this.
|
|
565
|
+
*/
|
|
566
|
+
export function captionKeyFor(srcStart: number): string {
|
|
567
|
+
if (!Number.isFinite(srcStart)) {
|
|
568
|
+
throw new Error(
|
|
569
|
+
`captionKeyFor: caption words need a finite srcStart (§137), got ${String(srcStart)} — ` +
|
|
570
|
+
`run backfillSrcStart on lines read from a pre-§137 render-props.json`,
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
return `w${Math.round(srcStart * 1000)}`;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** A pre-§137 key: a bare non-negative integer, i.e. a caption-word position. */
|
|
577
|
+
export function isLegacyCaptionKey(key: string): boolean {
|
|
578
|
+
return /^\d+$/.test(key);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* The anchor a word carries, or null when it carries none.
|
|
583
|
+
*
|
|
584
|
+
* `captionKeyFor` THROWS on a non-finite `srcStart` and should: reaching it
|
|
585
|
+
* with one is a programmer error. A word that simply has no `srcStart` is a
|
|
586
|
+
* DATA condition, not a programmer error — the render-props boundary is an
|
|
587
|
+
* unvalidated cast (`captions.ts:33-39`), so a pre-§137 workdir loads with the
|
|
588
|
+
* field absent on every word (the editor's own e2e fixture is exactly that,
|
|
589
|
+
* preserved on purpose). The editor calls `applyCaptionEdits` inside a
|
|
590
|
+
* render-time `useMemo` with no error boundary above it, so a throw down there
|
|
591
|
+
* white-screens the whole editor over a file that merely predates the field
|
|
592
|
+
* (§137 review). Distinguishing the two here keeps the parse loud where it
|
|
593
|
+
* means something and turns the boundary case into "this word can carry no
|
|
594
|
+
* edit" — the edits that then find no home are REPORTED (`found: null` /
|
|
595
|
+
* `unresolved`), which is the honest answer. `backfillSrcStart` on the load
|
|
596
|
+
* path is what makes these words anchorable again.
|
|
597
|
+
*
|
|
598
|
+
* PUBLIC since §137 Task 5, deliberately: the editor needs the same verdict
|
|
599
|
+
* before it writes an edit, and a second copy of "is this word anchorable"
|
|
600
|
+
* living in `apps/editor` is how the two would drift apart. Every caller that
|
|
601
|
+
* holds a word it did not itself construct should come through here rather
|
|
602
|
+
* than calling `captionKeyFor` — a `useEdits` retype runs in a React event
|
|
603
|
+
* handler with no error boundary above it, so a throw there is a crash on any
|
|
604
|
+
* pre-§137 workdir, not a caught parse failure.
|
|
605
|
+
*/
|
|
606
|
+
export function captionAnchorOf(word: CaptionWord | undefined): string | null {
|
|
607
|
+
if (!word || !Number.isFinite(word.srcStart)) return null;
|
|
608
|
+
return captionKeyFor(word.srcStart);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* How far either side of the stored index the migration will look for `was`
|
|
613
|
+
* (§137).
|
|
614
|
+
*
|
|
615
|
+
* A JUDGEMENT, not a measurement — stated because this is the constant that
|
|
616
|
+
* decides how much of a user's saved work the one-shot upgrade recovers, and
|
|
617
|
+
* it shipped with a *what* comment and no *why* (final review, Important 2).
|
|
618
|
+
* The trade runs in both directions at once: every extra word the scan
|
|
619
|
+
* considers is another chance that a common word (`the`, `it`) matches by
|
|
620
|
+
* coincidence, and the ambiguity rule below turns a coincidence into a
|
|
621
|
+
* REFUSAL. So widening this recovers more far-drifted edits and refuses more
|
|
622
|
+
* near ones; it is not a free "more is better" dial. Eight words is on the
|
|
623
|
+
* order of two or three seconds of speech — comfortably past the field case (a
|
|
624
|
+
* 0.6s trim moved every stored index by one) while still short enough that the
|
|
625
|
+
* window usually holds a given word once.
|
|
626
|
+
*
|
|
627
|
+
* The bound is affordable only because being past it is no longer a LOSS. Such
|
|
628
|
+
* an edit is reported `out-of-range` — the word is still on screen, merely too
|
|
629
|
+
* far from where the edit was stored to be sure it is the same one — and both
|
|
630
|
+
* migration callers carry it through into the doc they keep, so a later run
|
|
631
|
+
* against a different cut can still place it. Deleting it was the final
|
|
632
|
+
* review's Critical 1.
|
|
633
|
+
*
|
|
634
|
+
* THE TWO MIGRATION PATHS SEE DIFFERENT DRIFT, which is what makes the value
|
|
635
|
+
* user-visible rather than internal. The editor migrates against the LAST
|
|
636
|
+
* run's `render-props.json`, and its live preview deliberately never applies
|
|
637
|
+
* `doc.cuts` (`App.tsx`) — so the positions the doc stored and the positions
|
|
638
|
+
* it resolves against are the same ones: drift 0, every legacy edit exact-hits
|
|
639
|
+
* and shows as applied. `produce` migrates against lines built AFTER the
|
|
640
|
+
* user's new cut, so its drift is the number of caption words that cut
|
|
641
|
+
* removed. A cut removing more than this many words therefore shows every
|
|
642
|
+
* retype in the preview and reports them `out-of-range` in the render. Closing
|
|
643
|
+
* that gap would mean re-implementing the EDL in the browser, which is the one
|
|
644
|
+
* thing `App.tsx`'s live memo exists to avoid, so the divergence is STATED
|
|
645
|
+
* rather than fixed — and `out-of-range` plus the write-back's preservation
|
|
646
|
+
* rule is what keeps it a message instead of vanished work.
|
|
647
|
+
*
|
|
648
|
+
* Exported so the report lines can name the bound they hit, and so the
|
|
649
|
+
* boundary tests are written against the constant rather than against `8`.
|
|
650
|
+
*/
|
|
651
|
+
export const MIGRATION_SEARCH_RADIUS = 8;
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* WHY an edit could not be migrated. Reported rather than folded into one
|
|
655
|
+
* message (§137 Task 6 review, Minor 7): each cause needs something different
|
|
656
|
+
* from the user, and blaming the cut for all of them sends someone looking for
|
|
657
|
+
* a word that is still sitting on screen.
|
|
658
|
+
* - `not-found`: no word here says `was` any more — a cut removed it, or a
|
|
659
|
+
* re-plan rewrote it.
|
|
660
|
+
* - `out-of-range`: a word here DOES say `was`, but only past
|
|
661
|
+
* `MIGRATION_SEARCH_RADIUS` from the stored index. Split out of
|
|
662
|
+
* `not-found` (final review, Important 2): the two are the same silence to
|
|
663
|
+
* the code and opposite advice to the user — one word is gone, the other is
|
|
664
|
+
* sitting on screen untouched, and telling that user "the cut removed it"
|
|
665
|
+
* sends them to redo work they can still see.
|
|
666
|
+
* - `ambiguous`: several words nearby say `was` and the search cannot tell
|
|
667
|
+
* which one the user meant.
|
|
668
|
+
* - `unanchorable`: the word IS here, but carries no source time to key on —
|
|
669
|
+
* a render-props.json with no usable `spans` to backfill from.
|
|
670
|
+
* - `collision`: two legacy edits resolved to the same word; neither can be
|
|
671
|
+
* trusted over the other.
|
|
672
|
+
* - `superseded`: a legacy edit resolved onto a word an already-source-keyed
|
|
673
|
+
* edit holds. The CURRENT-format edit wins and is kept; this is the older
|
|
674
|
+
* duplicate being retired, not a loss of the live edit.
|
|
675
|
+
*/
|
|
676
|
+
export type CaptionMigrationReason =
|
|
677
|
+
| "not-found"
|
|
678
|
+
| "out-of-range"
|
|
679
|
+
| "ambiguous"
|
|
680
|
+
| "unanchorable"
|
|
681
|
+
| "collision"
|
|
682
|
+
| "superseded";
|
|
683
|
+
|
|
684
|
+
export interface CaptionKeyMigration {
|
|
685
|
+
edits: Record<string, CaptionEdit>;
|
|
686
|
+
/**
|
|
687
|
+
* Edits the migration would not commit — reported, never guessed at. Keyed
|
|
688
|
+
* by their ORIGINAL doc key, which is the only name the user's file knows
|
|
689
|
+
* them by, and carrying WHY (see `CaptionMigrationReason`).
|
|
690
|
+
*/
|
|
691
|
+
unresolved: Array<{ key: string; was: string; reason: CaptionMigrationReason }>;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* An answer from `resolveCaptionAnchor`: an anchor, or why there is none.
|
|
696
|
+
* `collision`/`superseded` are excluded because they are not properties of one
|
|
697
|
+
* edit at all — they need the other claims to be visible first.
|
|
698
|
+
*/
|
|
699
|
+
type AnchorClaim =
|
|
700
|
+
| { to: string }
|
|
701
|
+
| { to: null; reason: Exclude<CaptionMigrationReason, "collision" | "superseded"> };
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Where one stored edit wants to land, or why it cannot land anywhere.
|
|
705
|
+
*
|
|
706
|
+
* Split out of `migrateCaptionKeys` so every edit can be resolved BEFORE any
|
|
707
|
+
* of them is written: a collision is only visible once both claims exist, and
|
|
708
|
+
* a function that writes as it goes cannot see the second claim coming.
|
|
709
|
+
*/
|
|
710
|
+
function resolveCaptionAnchor(
|
|
711
|
+
key: string,
|
|
712
|
+
edit: CaptionEdit,
|
|
713
|
+
words: readonly CaptionWord[],
|
|
714
|
+
): AnchorClaim {
|
|
715
|
+
// Already a source key (or something that is not a position at all) — the
|
|
716
|
+
// doc's own key stands, and the collision check downstream still applies.
|
|
717
|
+
if (!isLegacyCaptionKey(key)) return { to: key };
|
|
718
|
+
const at = Number(key);
|
|
719
|
+
// The record confirming itself — see `migrateCaptionKeys` for why this
|
|
720
|
+
// wins ahead of the ambiguity rule rather than through it. A confirmed
|
|
721
|
+
// position that carries no anchor resolves to NOTHING rather than falling
|
|
722
|
+
// through to the search: the record already named the word, and letting the
|
|
723
|
+
// search then pick a same-text word elsewhere would rewrite one the user
|
|
724
|
+
// did not edit.
|
|
725
|
+
if (words[at]?.text === edit.was) return anchorOrUnanchorable(words[at]);
|
|
726
|
+
const matches: number[] = [];
|
|
727
|
+
for (let d = 1; d <= MIGRATION_SEARCH_RADIUS; d++) {
|
|
728
|
+
for (const i of [at - d, at + d]) {
|
|
729
|
+
if (words[i]?.text === edit.was) matches.push(i);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
// Ambiguity is judged on the TEXT matches, before anchors are considered —
|
|
733
|
+
// an unanchorable candidate still means the search could not tell two words
|
|
734
|
+
// apart, so it must not silently narrow the field to one.
|
|
735
|
+
if (matches.length === 0) {
|
|
736
|
+
// Nothing WITHIN the radius — but that is two different facts, and they
|
|
737
|
+
// owe the user opposite advice (final review, Important 2). A full scan
|
|
738
|
+
// (only ever on the failure path, so it costs nothing on a healthy doc)
|
|
739
|
+
// separates "the cut removed this word" from "the word is right there,
|
|
740
|
+
// further from the stored index than the search is willing to trust". The
|
|
741
|
+
// second is not re-anchored — past the radius the position record has been
|
|
742
|
+
// proven wrong by too much for a lone text match to stand in for it — but
|
|
743
|
+
// it is REPORTED as what it is, and the callers keep the edit in the doc
|
|
744
|
+
// so a later run can still place it.
|
|
745
|
+
const elsewhere = words.some((w) => w.text === edit.was);
|
|
746
|
+
return { to: null, reason: elsewhere ? "out-of-range" : "not-found" };
|
|
747
|
+
}
|
|
748
|
+
if (matches.length > 1) return { to: null, reason: "ambiguous" };
|
|
749
|
+
return anchorOrUnanchorable(words[matches[0]!]);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** The word was FOUND; whether it can be keyed on is a separate question. */
|
|
753
|
+
function anchorOrUnanchorable(word: CaptionWord | undefined): AnchorClaim {
|
|
754
|
+
const anchor = captionAnchorOf(word);
|
|
755
|
+
return anchor === null ? { to: null, reason: "unanchorable" } : { to: anchor };
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Upgrade pre-§137 positional keys to source-time keys.
|
|
760
|
+
*
|
|
761
|
+
* Position first, and an exact position hit WINS OUTRIGHT — it is not a
|
|
762
|
+
* guess. The stored index is the position the editor recorded when the user
|
|
763
|
+
* made the edit, and `was` matching the word now sitting there is that record
|
|
764
|
+
* confirming itself: two independent facts agreeing. The same word appearing
|
|
765
|
+
* elsewhere nearby weakens neither, so the ambiguity rule below deliberately
|
|
766
|
+
* does NOT gate this branch (ruling on the §137 plan, task 2: folding the
|
|
767
|
+
* exact hit into the candidate scan makes a confirmed record lose to an
|
|
768
|
+
* unrelated coincidence, and a doc that never drifted at all would stop
|
|
769
|
+
* migrating because the user happened to edit a repeated word — that breaks
|
|
770
|
+
* "every existing overrides.json keeps working" for a case where we have the
|
|
771
|
+
* answer).
|
|
772
|
+
*
|
|
773
|
+
* When the word at that position is NOT the edit's `was`, the position has
|
|
774
|
+
* been PROVEN wrong (a cut removed words before it), so search outward for
|
|
775
|
+
* the `was`: that recovers the field case rather than discarding work the
|
|
776
|
+
* user already did. Ambiguity gates that search alone — two candidates a
|
|
777
|
+
* search genuinely cannot tell apart are reported instead, because a wrong
|
|
778
|
+
* anchor silently rewrites the wrong word, which is worse than an edit the
|
|
779
|
+
* user has to redo.
|
|
780
|
+
*
|
|
781
|
+
* TWO LEGACY EDITS RESOLVING TO THE SAME WORD are that same ambiguity one
|
|
782
|
+
* level up, and both go to `unresolved` — the output is a Record, so writing as
|
|
783
|
+
* we went would have the second edit silently overwrite the first and report
|
|
784
|
+
* nothing, which is the very bug this task was opened for. It is not a corner
|
|
785
|
+
* case:
|
|
786
|
+
* - an outward search can land on the word another edit exact-hit (`the cat
|
|
787
|
+
* sat on a mat`, edits at "0" and "5", both `was: "the"`);
|
|
788
|
+
* - two words can share a `srcStart` outright — `backfillSrcStart`
|
|
789
|
+
* (`captions.ts:44-50`) maps seam preimages and cut-clamped words onto the
|
|
790
|
+
* same source instant BY DESIGN, so duplicate keys are manufactured, not
|
|
791
|
+
* float trivia.
|
|
792
|
+
* Neither edit is guessed at, both are named, and the user can re-apply the
|
|
793
|
+
* one they meant.
|
|
794
|
+
*
|
|
795
|
+
* A LEGACY EDIT COLLIDING WITH AN ALREADY-SOURCE-KEYED ONE is NOT that case,
|
|
796
|
+
* and refusing both was a real bug (§137 Task 6 review, Important 3): a doc
|
|
797
|
+
* holding both key spaces at once — `{"0": …, "w6000": …}` over one word — is
|
|
798
|
+
* the normal shape of any project edited before and after this change, and
|
|
799
|
+
* treating it as an unbreakable tie DELETED the newer, current-format edit
|
|
800
|
+
* whose anchor was never in doubt. The source-keyed edit WINS: it is the one
|
|
801
|
+
* the editor wrote most recently, it names its word directly rather than by a
|
|
802
|
+
* position something may have shifted, and it is the format everything else
|
|
803
|
+
* reads. Only the legacy claim is retired, reported as `superseded`. (There
|
|
804
|
+
* can be at most one source-keyed claimant per anchor: such a claim resolves
|
|
805
|
+
* to its own key, and a Record cannot hold one key twice.)
|
|
806
|
+
*/
|
|
807
|
+
export function migrateCaptionKeys(
|
|
808
|
+
edits: Record<string, CaptionEdit>,
|
|
809
|
+
lines: readonly CaptionLine[],
|
|
810
|
+
): CaptionKeyMigration {
|
|
811
|
+
const words = lines.flatMap((l) => l.words);
|
|
812
|
+
const out: Record<string, CaptionEdit> = {};
|
|
813
|
+
const unresolved: CaptionKeyMigration["unresolved"] = [];
|
|
814
|
+
|
|
815
|
+
// Resolve every edit first, write second — see the collision paragraph.
|
|
816
|
+
const claims = Object.entries(edits).map(([key, edit]) => ({
|
|
817
|
+
key,
|
|
818
|
+
edit,
|
|
819
|
+
legacy: isLegacyCaptionKey(key),
|
|
820
|
+
claim: resolveCaptionAnchor(key, edit, words),
|
|
821
|
+
}));
|
|
822
|
+
type Claim = (typeof claims)[number];
|
|
823
|
+
const claimants = new Map<string, Claim[]>();
|
|
824
|
+
for (const c of claims) {
|
|
825
|
+
if (c.claim.to === null) continue;
|
|
826
|
+
const rivals = claimants.get(c.claim.to);
|
|
827
|
+
if (rivals) rivals.push(c);
|
|
828
|
+
else claimants.set(c.claim.to, [c]);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
for (const c of claims) {
|
|
832
|
+
if (c.claim.to === null) {
|
|
833
|
+
unresolved.push({ key: c.key, was: c.edit.was, reason: c.claim.reason });
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
const rivals = claimants.get(c.claim.to)!;
|
|
837
|
+
// Identity, not `.key` — the winner has to be THIS claim object, or a
|
|
838
|
+
// second claimant would write itself in over the one that already won.
|
|
839
|
+
const winner = rivals.length === 1 ? rivals[0]! : rivals.find((r) => !r.legacy);
|
|
840
|
+
if (winner === c) {
|
|
841
|
+
out[c.claim.to] = c.edit;
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
unresolved.push({
|
|
845
|
+
key: c.key,
|
|
846
|
+
was: c.edit.was,
|
|
847
|
+
// `winner` undefined means every claimant was legacy: a genuine tie.
|
|
848
|
+
reason: winner === undefined ? "collision" : "superseded",
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
return { edits: out, unresolved };
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* The caption map to KEEP after a migration: everything it placed, plus every
|
|
856
|
+
* edit it would not place, left exactly as the user's file holds it.
|
|
857
|
+
*
|
|
858
|
+
* `migration.edits` alone is what both callers wrote back at first, and it is
|
|
859
|
+
* a DELETE (final review, Critical 1): an edit produce cannot anchor this run
|
|
860
|
+
* may well be anchorable the next one — a different cut, re-planned lines, or
|
|
861
|
+
* simply a `MIGRATION_SEARCH_RADIUS` the drift no longer exceeds — and
|
|
862
|
+
* dropping it forecloses that, permanently, on a run the user only asked to
|
|
863
|
+
* render. Nobody asked for a delete. An unresolved key left in the doc costs
|
|
864
|
+
* nothing: it addresses no word, so it applies to nothing, and it is reported
|
|
865
|
+
* by name on every run. That was the pre-§137 status quo for a stale key and
|
|
866
|
+
* it is the right one.
|
|
867
|
+
*
|
|
868
|
+
* `superseded` is the ONE retirement, and it is not a loss: a newer
|
|
869
|
+
* source-keyed edit already covers that word (see `migrateCaptionKeys`), so
|
|
870
|
+
* keeping the older legacy duplicate would re-report the same collision on
|
|
871
|
+
* every run forever with nothing the user could do about it.
|
|
872
|
+
*
|
|
873
|
+
* Preserved keys cannot collide with placed ones — a preserved key is legacy
|
|
874
|
+
* (`/^\d+$/`, since a source key always resolves to itself and wins its
|
|
875
|
+
* anchor) and a placed key is always `w<ms>`. `before` must have been through
|
|
876
|
+
* `OverrideDocSchema`, for the `"__proto__"` reason `migrateCaptionKeys`
|
|
877
|
+
* states.
|
|
878
|
+
*/
|
|
879
|
+
export function captionEditsToKeep(
|
|
880
|
+
before: Record<string, CaptionEdit>,
|
|
881
|
+
migration: CaptionKeyMigration,
|
|
882
|
+
): Record<string, CaptionEdit> {
|
|
883
|
+
const out: Record<string, CaptionEdit> = { ...migration.edits };
|
|
884
|
+
for (const u of migration.unresolved) {
|
|
885
|
+
if (u.reason === "superseded") continue;
|
|
886
|
+
const edit = before[u.key];
|
|
887
|
+
// Unreachable — `unresolved` is built from `before`'s own entries — but
|
|
888
|
+
// this is user data on its way back to disk, and a lookup that came back
|
|
889
|
+
// undefined must not be written into a map typed as `CaptionEdit`.
|
|
890
|
+
if (edit !== undefined) out[u.key] = edit;
|
|
891
|
+
}
|
|
892
|
+
return out;
|
|
893
|
+
}
|
|
894
|
+
|
|
465
895
|
export interface AppliedCaptionEdits {
|
|
466
896
|
lines: CaptionLine[];
|
|
467
|
-
/**
|
|
468
|
-
|
|
897
|
+
/**
|
|
898
|
+
* Edits that did not apply. `found: null` means no word carries that source
|
|
899
|
+
* anchor any more (a cut removed it); a string means the word is there but
|
|
900
|
+
* says something else (a re-plan changed it).
|
|
901
|
+
*
|
|
902
|
+
* `reason` is present ONLY for the third case — a SECOND word carrying an
|
|
903
|
+
* anchor an earlier word already claimed, which is not a stale edit at all
|
|
904
|
+
* (the edit may well have applied, to the first word). Written only when it
|
|
905
|
+
* has something to say, the same rule the override doc's own optional keys
|
|
906
|
+
* follow; absent means the ordinary stale report `found` already
|
|
907
|
+
* distinguishes. A key can therefore appear in this array more than once.
|
|
908
|
+
*/
|
|
909
|
+
dropped: Array<{
|
|
910
|
+
key: string;
|
|
911
|
+
expected: string;
|
|
912
|
+
found: string | null;
|
|
913
|
+
reason?: "duplicate-anchor";
|
|
914
|
+
}>;
|
|
469
915
|
}
|
|
470
916
|
|
|
471
917
|
/**
|
|
472
918
|
* Apply retyped caption words. Text only, never timing — the stamps drive the
|
|
473
919
|
* kinetic highlight and the 1:1 constraint is what keeps scene anchors and
|
|
474
|
-
* §21's copy/caption agreement intact.
|
|
475
|
-
*
|
|
920
|
+
* §21's copy/caption agreement intact.
|
|
921
|
+
*
|
|
922
|
+
* Keyed by source time since §137, so a user cut earlier in the video no
|
|
923
|
+
* longer shifts every later edit onto the wrong word. An edit that does not
|
|
924
|
+
* apply is REPORTED — callers must surface `dropped`; the editor discarding it
|
|
925
|
+
* is what made this failure invisible in the field case.
|
|
926
|
+
*
|
|
927
|
+
* AT MOST ONE WORD per edit — the first carrying the key, and the guard's
|
|
928
|
+
* verdict on that word is final. Keys are millisecond-quantised, so two words
|
|
929
|
+
* CAN share one (`captions.ts:44-50`: backfilled seam preimages and
|
|
930
|
+
* cut-clamped words land on the same source instant by design, and rounding
|
|
931
|
+
* closes sub-millisecond gaps besides). A plain `.map()` rewrites every word
|
|
932
|
+
* that matches — fanning one retype out onto a word the user never touched,
|
|
933
|
+
* which is exactly the "wrong anchor silently rewrites the wrong word" the
|
|
934
|
+
* migration's ambiguity rule refuses to commit, and a breach of the 1:1
|
|
935
|
+
* in-place retype contract above. Later claimants are reported with
|
|
936
|
+
* `reason: "duplicate-anchor"` and left alone rather than edited.
|
|
476
937
|
*/
|
|
477
938
|
export function applyCaptionEdits(
|
|
478
939
|
lines: readonly CaptionLine[],
|
|
@@ -480,19 +941,40 @@ export function applyCaptionEdits(
|
|
|
480
941
|
): AppliedCaptionEdits {
|
|
481
942
|
const dropped: AppliedCaptionEdits["dropped"] = [];
|
|
482
943
|
if (Object.keys(edits).length === 0) return { lines: [...lines], dropped };
|
|
483
|
-
|
|
944
|
+
|
|
945
|
+
const seen = new Set<string>();
|
|
484
946
|
const out = lines.map((line) => ({
|
|
485
947
|
...line,
|
|
486
948
|
words: line.words.map((w) => {
|
|
487
|
-
|
|
949
|
+
// No anchor, no edit — a pre-§137 word cannot be addressed, and this is
|
|
950
|
+
// the boundary that must not throw (see `captionAnchorOf`). The stored
|
|
951
|
+
// edits then fall out of the sweep below as `found: null`.
|
|
952
|
+
const key = captionAnchorOf(w);
|
|
953
|
+
if (key === null) return w;
|
|
954
|
+
const edit = edits[key];
|
|
488
955
|
if (!edit) return w;
|
|
956
|
+
// An earlier word already answered for this anchor — whichever way it
|
|
957
|
+
// answered. Applying here too would fan one retype onto a second word;
|
|
958
|
+
// re-running the guard here would let an edit be applied AND reported
|
|
959
|
+
// dropped for the same key.
|
|
960
|
+
if (seen.has(key)) {
|
|
961
|
+
dropped.push({ key, expected: edit.was, found: w.text, reason: "duplicate-anchor" });
|
|
962
|
+
return w;
|
|
963
|
+
}
|
|
964
|
+
seen.add(key);
|
|
489
965
|
if (w.text !== edit.was) {
|
|
490
|
-
dropped.push({
|
|
966
|
+
dropped.push({ key, expected: edit.was, found: w.text });
|
|
491
967
|
return w;
|
|
492
968
|
}
|
|
493
969
|
return { ...w, text: edit.text };
|
|
494
970
|
}),
|
|
495
971
|
}));
|
|
972
|
+
|
|
973
|
+
// An anchor no word carries any more — the cut removed the word the user
|
|
974
|
+
// edited. Silence here is exactly the field case, so say it.
|
|
975
|
+
for (const [key, edit] of Object.entries(edits)) {
|
|
976
|
+
if (!seen.has(key)) dropped.push({ key, expected: edit.was, found: null });
|
|
977
|
+
}
|
|
496
978
|
return { lines: out, dropped };
|
|
497
979
|
}
|
|
498
980
|
|
|
@@ -605,7 +1087,9 @@ export interface ReclampResult {
|
|
|
605
1087
|
}
|
|
606
1088
|
|
|
607
1089
|
/**
|
|
608
|
-
* The scene a cue id belongs to, stripping a split half's
|
|
1090
|
+
* The scene a cue id belongs to, stripping a split half's `@<split id>`
|
|
1091
|
+
* suffix. That suffix is opaque since §137 — a minted id, not a time — so the
|
|
1092
|
+
* `@` is the only thing this can key on; it is the
|
|
609
1093
|
* same idiom `splitCues` itself uses to derive a later half's id from its
|
|
610
1094
|
* root, and the same one `effectiveOverride` above inlines to find a half's
|
|
611
1095
|
* root entry. Two cues sharing a root are the SAME scene, cut in two.
|
package/src/recut.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { SPLIT_MIN_PIECE_SEC, type OverrideDoc } from "./overrides";
|
|
2
2
|
import type { Segment } from "./schema";
|
|
3
3
|
import { mapsClose, TimeMap } from "./timemap";
|
|
4
4
|
|
|
@@ -56,7 +56,65 @@ export function remapOverridesThroughRecut(
|
|
|
56
56
|
): RecutRemap {
|
|
57
57
|
const reports: string[] = [];
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
// Only `at` moves: a split's `id` is minted once and never recomputed
|
|
60
|
+
// (§137, `SplitSchema`) — re-deriving it here is what renamed the half and
|
|
61
|
+
// orphaned the overrides on it.
|
|
62
|
+
const splits = doc.splits.map((s) => {
|
|
63
|
+
const before = reports.length;
|
|
64
|
+
const at = remapPoint(`split "${s.id}"`, s.at, oldMap, newMap, reports);
|
|
65
|
+
// `splitCues` needs a cue with `at >= startSec + SPLIT_MIN_PIECE_SEC` AND
|
|
66
|
+
// `at <= endSec - SPLIT_MIN_PIECE_SEC`. Output time runs [0,
|
|
67
|
+
// outputDuration] and every cue lives inside it, so a split closer than
|
|
68
|
+
// that to EITHER end can match no cue at all and is skipped: the half it
|
|
69
|
+
// named stops existing, every override keyed to it is orphaned, and the
|
|
70
|
+
// scene the user deleted comes back. Before §137 the only trace of that
|
|
71
|
+
// was produce.ts's generic `edit for scene-0@600 dropped — the plan no
|
|
72
|
+
// longer has that scene`, which blames the plan and names neither the
|
|
73
|
+
// split nor the re-cut that moved it. Name both.
|
|
74
|
+
//
|
|
75
|
+
// Start: the field case — a 0.6s cut pushed a split at 0.6s to 0.
|
|
76
|
+
// End: `at` need not move at all; cutting the tail moves `outputDuration`
|
|
77
|
+
// out from under it (a split at 9.5s of 10s, with 9.6–10.0 cut).
|
|
78
|
+
//
|
|
79
|
+
// Both fire only when THIS re-cut is what pushed the split past the bar:
|
|
80
|
+
// `reports` is the "a value MOVED" channel (see `RecutRemap`), and a
|
|
81
|
+
// split already past it beforehand is a pre-existing condition — one the
|
|
82
|
+
// editor's own SPLIT_MIN_PIECE_SEC guard refuses to create — that would
|
|
83
|
+
// otherwise be re-announced on every identity re-cut, forever.
|
|
84
|
+
//
|
|
85
|
+
// The two guards look mirrored and are NOT symmetric about a pure shift,
|
|
86
|
+
// which is worth stating because reading them as a matched pair invites
|
|
87
|
+
// "a shift can never be reported" — false at the start. The END bar is
|
|
88
|
+
// `outputDuration - MIN`, so it slides with the timeline and both sides of
|
|
89
|
+
// that comparison move together. The START bar is absolute 0 + MIN and
|
|
90
|
+
// does not slide, so `at < MIN && s.at >= MIN` DOES fire under a pure
|
|
91
|
+
// shift — and must: trimming 0.6s off the front is exactly what dragged a
|
|
92
|
+
// split at 0.6s down to 0 in the field case (recut.test.ts, "reports the
|
|
93
|
+
// split whose remapped `at` can no longer divide anything").
|
|
94
|
+
//
|
|
95
|
+
// `remapPoint` states the new time itself when it snapped this split onto
|
|
96
|
+
// a cut edge; restating it here would read as a second, separate move
|
|
97
|
+
// rather than the consequence of the one already reported.
|
|
98
|
+
const where = reports.length > before ? "is" : `is now ${at.toFixed(3)}s —`;
|
|
99
|
+
if (at < SPLIT_MIN_PIECE_SEC && s.at >= SPLIT_MIN_PIECE_SEC) {
|
|
100
|
+
reports.push(
|
|
101
|
+
`split "${s.id}" ${where} too close to the start to divide a scene, ` +
|
|
102
|
+
`so any edit on its second half will not apply`,
|
|
103
|
+
);
|
|
104
|
+
// `else`, not a second `if`: an output shorter than two minimum pieces
|
|
105
|
+
// trips both bars for the same split, and one line already says it can
|
|
106
|
+
// no longer divide anything — two would read as two problems.
|
|
107
|
+
} else if (
|
|
108
|
+
at > newMap.outputDuration - SPLIT_MIN_PIECE_SEC &&
|
|
109
|
+
s.at <= oldMap.outputDuration - SPLIT_MIN_PIECE_SEC
|
|
110
|
+
) {
|
|
111
|
+
reports.push(
|
|
112
|
+
`split "${s.id}" ${where} too close to the end to divide a scene, ` +
|
|
113
|
+
`so any edit on its second half will not apply`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return { ...s, at };
|
|
117
|
+
});
|
|
60
118
|
|
|
61
119
|
// Record-shaped: rebuild key by key rather than mutate, matching every
|
|
62
120
|
// other `OverrideDoc`-shaping function in overrides.ts (e.g.
|