ossclip 0.1.18 → 0.1.19

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.
@@ -0,0 +1 @@
1
+ .ossclip-scroll-list{scrollbar-width:thin;scrollbar-color:#3a3a44 transparent}.ossclip-scroll-list::-webkit-scrollbar{width:8px}.ossclip-scroll-list::-webkit-scrollbar-track{background:transparent}.ossclip-scroll-list::-webkit-scrollbar-thumb{background:#3a3a44;border-radius:4px}.ossclip-picker-row{color:#c9c9d4;background:transparent;border:1px solid transparent}.ossclip-picker-row.is-workdir{color:#ededf2;background:#1a1a21;border-color:#2a2a33}.ossclip-picker-row:disabled{cursor:default;opacity:.5}.ossclip-picker-row:hover:not(:disabled){background:#1f1f28;border-color:#3a3a48}.ossclip-picker-row:focus{outline:none;background:#23232e;border-color:#8ab4f8}.ossclip-picker-row:active:not(:disabled){background:#2a2a36}
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>ossclip editor</title>
7
- <script type="module" crossorigin src="/assets/index-CdwzWN3j.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-C8IPo60X.css">
7
+ <script type="module" crossorigin src="/assets/index-B44I-xE4.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-ChRVBVLj.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ossclip",
3
- "version": "0.1.18",
3
+ "version": "0.1.19",
4
4
  "description": "Local-first CLI video producer: cuts silence and fillers, word-timed captions, face-aware framing, and LLM-planned code-rendered graphics — transcription and rendering never leave your machine",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,9 +36,9 @@
36
36
  "commander": "^12.1.0",
37
37
  "tsx": "^4.19.0",
38
38
  "zod": "^3.25.76",
39
- "@ossclip/core": "0.1.18",
40
- "@ossclip/renderer": "0.1.18",
41
- "@ossclip/scenes": "0.1.18"
39
+ "@ossclip/core": "0.1.19",
40
+ "@ossclip/renderer": "0.1.19",
41
+ "@ossclip/scenes": "0.1.19"
42
42
  },
43
43
  "homepage": "https://github.com/AhsanAyaz/ossclip#readme",
44
44
  "bugs": {
@@ -0,0 +1,217 @@
1
+ import {
2
+ applyCaptionEdits,
3
+ captionEditsToKeep,
4
+ isLegacyCaptionKey,
5
+ migrateCaptionKeys,
6
+ MIGRATION_SEARCH_RADIUS,
7
+ } from "@ossclip/core";
8
+ import type {
9
+ AppliedCaptionEdits,
10
+ CaptionEdit,
11
+ CaptionKeyMigration,
12
+ CaptionLine,
13
+ OverrideDoc,
14
+ } from "@ossclip/core";
15
+
16
+ /**
17
+ * What `produce` does with the user's retyped caption words, and what it says
18
+ * about the ones that did not land (§137).
19
+ *
20
+ * Pure, and in its own module rather than inline in `produce.ts`, for the
21
+ * house reason: this is a decision about the user's data (which edits are
22
+ * re-anchored, which are applied, and why the others were not) and a
23
+ * `produce()` run needs ffmpeg, a transcript and a workdir before it reaches
24
+ * any of it. `produce.ts` keeps the `console.log` and the file writes.
25
+ */
26
+
27
+ /** One drop, as a console line. Three cases, and the caller must not merge them. */
28
+ export function captionDropLine(drop: AppliedCaptionEdits["dropped"][number]): string {
29
+ // `found: null` used to be interpolated straight into the sentence, so a
30
+ // word the cut removed reported `the transcript now has "null"` — the one
31
+ // case §137 exists for, described as a JSON literal. The three cases carry
32
+ // genuinely different advice, so they get genuinely different sentences.
33
+ if (drop.reason === "duplicate-anchor") {
34
+ // NOT a stale edit: the edit almost certainly applied, to the FIRST word
35
+ // carrying this anchor. Two words share one source instant by design
36
+ // (captions.ts:44-50 — backfilled seam preimages and cut-clamped words),
37
+ // so this is a note about reach, not a failure.
38
+ return (
39
+ ` ⚠ caption edit "${drop.expected}" (${drop.key}): a second word shares that ` +
40
+ `source moment and was left as it is — only the first was retyped`
41
+ );
42
+ }
43
+ if (drop.found === null) {
44
+ // A key that is a POSITION, not a source anchor, never had a moment to
45
+ // lose — it is a pre-§137 doc the migration could not upgrade. Saying "the
46
+ // cut removed it" there sends the user to redo work that is sitting intact
47
+ // on screen (§137 Task 6 review, Important 2). Reachable whenever an edit
48
+ // reaches `applyCaptionEdits` without going through `migrateCaptionKeys`.
49
+ if (isLegacyCaptionKey(drop.key)) {
50
+ return (
51
+ ` ⚠ caption edit "${drop.expected}" (position ${drop.key}) not applied: it is keyed ` +
52
+ `by word POSITION, from a project saved before source anchors, and nothing ` +
53
+ `re-anchored it — open the project in the editor, or retype it there`
54
+ );
55
+ }
56
+ return (
57
+ ` ⚠ caption edit "${drop.expected}" (${drop.key}) dropped: no word starts at that ` +
58
+ `source moment any more — the cut removed the word it was typed over. ` +
59
+ `Retype it in the editor if you still want it.`
60
+ );
61
+ }
62
+ return (
63
+ ` ⚠ caption edit "${drop.expected}" (${drop.key}) dropped: the transcript now says ` +
64
+ `"${drop.found}" there`
65
+ );
66
+ }
67
+
68
+ /**
69
+ * How many stored edits actually landed.
70
+ *
71
+ * NOT `keys.length - dropped.length` (§137): `dropped` is not one entry per
72
+ * key. A `duplicate-anchor` entry is pushed for every EXTRA word carrying an
73
+ * anchor, so a single key can appear in `dropped` two or three times — and it
74
+ * may have applied anyway. The old subtraction therefore undercounted, and
75
+ * with enough duplicates went NEGATIVE, which the `> 0` guard then hid
76
+ * entirely: the run printed nothing at all about edits that had applied.
77
+ *
78
+ * The rule comes straight from `applyCaptionEdits`' own contract: a key is
79
+ * marked `seen` by the first word carrying it, and that word either applied
80
+ * the edit or was reported with `reason` ABSENT. So an edit landed exactly
81
+ * when nothing was reported for its key without a `reason`.
82
+ */
83
+ export function appliedCaptionEditCount(
84
+ edits: Record<string, CaptionEdit>,
85
+ dropped: AppliedCaptionEdits["dropped"],
86
+ ): number {
87
+ const failed = new Set(dropped.filter((d) => d.reason === undefined).map((d) => d.key));
88
+ return Object.keys(edits).filter((key) => !failed.has(key)).length;
89
+ }
90
+
91
+ /**
92
+ * One console line for a legacy edit the migration would not place (§137).
93
+ *
94
+ * One sentence per CAUSE, like the editor's own notice: three of the four
95
+ * leave the word sitting right there in the transcript, and a single message
96
+ * blaming the cut would send the user hunting for it.
97
+ */
98
+ export function captionMigrationLine(u: CaptionKeyMigration["unresolved"][number]): string {
99
+ const head = ` ⚠ caption edit "${u.was}" (${u.key}) could not be re-anchored`;
100
+ switch (u.reason) {
101
+ case "out-of-range":
102
+ // The word is ON SCREEN. Blaming the cut here (which the shared
103
+ // `not-found` sentence did until the final review) sends the user to
104
+ // retype something that is sitting intact in the transcript — and the
105
+ // edit is kept in the doc, so the next run against a different cut may
106
+ // place it without them doing anything at all.
107
+ // No promise that retyping fixes it, either: the word this found may
108
+ // itself be unanchorable (a pre-§137 render-props.json with nothing to
109
+ // backfill from), and "re-anchors it for good" would be a guarantee this
110
+ // line cannot make.
111
+ return `${head}: the word is still here, but more than ${MIGRATION_SEARCH_RADIUS} words from where the edit was stored, so it was left alone rather than applied to the wrong one — it is kept in overrides.json, so retype it in the editor if that is the word you meant`;
112
+ case "ambiguous":
113
+ return `${head}: more than one word says it here, so it was left alone rather than applied to the wrong one — retype the one you meant in the editor`;
114
+ case "unanchorable":
115
+ return `${head}: the word is here but carries no source timing to key on`;
116
+ case "collision":
117
+ return `${head}: two stored edits point at the same word — neither was applied, retype the one you meant in the editor`;
118
+ case "superseded":
119
+ return `${head}: a newer edit already covers that word — the newer one was kept`;
120
+ default:
121
+ return `${head}: no word says it any more — the cut or a re-plan removed it. Retype it in the editor if you still want it.`;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * How many edits came out under a key they did not go in under — the ones the
127
+ * migration actually moved.
128
+ *
129
+ * NOT `Object.keys(migration.edits).length`, which is what the count in the log
130
+ * line was first written as: a MIXED doc (`{"0": …, "w6000": …}` over one word)
131
+ * keeps its already-source-keyed edit and retires the legacy one, so that
132
+ * count announced "1 caption edit re-anchored" about a key nothing had
133
+ * touched. A number the user can check against their own file has to be true
134
+ * for the same reason the drop lines do.
135
+ */
136
+ export function reanchoredKeyCount(
137
+ before: Record<string, CaptionEdit>,
138
+ migration: CaptionKeyMigration,
139
+ ): number {
140
+ return Object.keys(migration.edits).filter((key) => !(key in before)).length;
141
+ }
142
+
143
+ export interface CaptionReconciliation {
144
+ /** The doc with its caption keys upgraded — what produce writes back. */
145
+ doc: OverrideDoc;
146
+ /** The caption lines with every edit that could be applied, applied. */
147
+ lines: CaptionLine[];
148
+ /**
149
+ * Whether the migration actually MOVED an edit onto a source anchor — the
150
+ * write-back gate, and the only thing that earns spending the `.bak`.
151
+ *
152
+ * It used to be `captionKeysMigrated`: true whenever anything was
153
+ * unresolved, including when nothing at all was placed. That is a write with
154
+ * no repair in it, and on the field workdir (`cutResult.changed` false,
155
+ * because the cut already carries `src`) it was a NEW write on a run that
156
+ * previously touched nothing — spending `overrides.json.bak`, the user's
157
+ * only surviving pre-cut save and the sole route back to the split half they
158
+ * deleted, on a copy of the already-damaged document (final review, Critical
159
+ * 2). A run that placed nothing has nothing to write and no business
160
+ * touching the `.bak`. Renamed as well as re-defined, because "keys changed"
161
+ * is no longer even true of it: a `superseded` retirement changes the keys
162
+ * and deliberately does not fire this.
163
+ */
164
+ reanchored: boolean;
165
+ /** Everything produce should print about this, in order. */
166
+ log: string[];
167
+ }
168
+
169
+ /**
170
+ * Migrate, then apply, then account for the difference — the whole caption
171
+ * half of a produce run, in one pure pass.
172
+ *
173
+ * MIGRATE FIRST, and against these same lines: `applyCaptionEdits` addresses
174
+ * words by source anchor, so a pre-§137 positional key matches nothing at all
175
+ * and every retype in an old project would be silently absent from the render
176
+ * (§137 Task 6 review, Critical 1). Nothing needs backfilling here — produce
177
+ * builds these lines itself, with a real `srcStart` on every word — which is
178
+ * exactly why this migration belongs in produce even though the same call in
179
+ * the edit server would be inert.
180
+ *
181
+ * WHAT IS APPLIED AND WHAT IS KEPT ARE DIFFERENT SETS, deliberately (final
182
+ * review, Critical 1). Only the edits the migration PLACED are applied — an
183
+ * unresolved key addresses no word, so handing it to `applyCaptionEdits` would
184
+ * buy nothing but a second, worse-worded report of the same edit. The doc
185
+ * written back keeps them anyway (`captionEditsToKeep`), because a run that
186
+ * cannot place an edit today is not a licence to delete it.
187
+ *
188
+ * The caller must hand a doc that has been through `OverrideDocSchema`
189
+ * (`produce.ts` parses it at the top of the run). On raw `JSON.parse` output a
190
+ * literal `"__proto__"` key would be assigned THROUGH rather than kept, and
191
+ * the migration would report nothing lost while losing it.
192
+ */
193
+ export function reconcileCaptionEdits(
194
+ doc: OverrideDoc,
195
+ baseLines: readonly CaptionLine[],
196
+ ): CaptionReconciliation {
197
+ const log: string[] = [];
198
+ const migration = migrateCaptionKeys(doc.captions, baseLines);
199
+ // The MOVED count is both the log gate and the write gate. As a log gate:
200
+ // announcing "0 caption edit(s) re-anchored" above the lines saying why is
201
+ // noise on the one run where the user is reading carefully. As a write gate:
202
+ // see `CaptionReconciliation.reanchored` — a run that placed nothing must
203
+ // not spend the `.bak`.
204
+ const reanchored = reanchoredKeyCount(doc.captions, migration);
205
+ if (reanchored > 0) {
206
+ log.push(
207
+ `▸ ${reanchored} caption edit(s) re-anchored from word positions to source time (§137)`,
208
+ );
209
+ }
210
+ for (const u of migration.unresolved) log.push(captionMigrationLine(u));
211
+ const migrated = { ...doc, captions: captionEditsToKeep(doc.captions, migration) };
212
+ const { lines, dropped } = applyCaptionEdits(baseLines, migration.edits);
213
+ const live = appliedCaptionEditCount(migration.edits, dropped);
214
+ if (live > 0) log.push(`▸ ${live} caption word(s) retyped by the editor`);
215
+ for (const d of dropped) log.push(captionDropLine(d));
216
+ return { doc: migrated, lines, reanchored: reanchored > 0, log };
217
+ }
package/src/edit.ts CHANGED
@@ -259,9 +259,29 @@ export async function startEditServer(
259
259
  return send(200, { noWorkdir: true, recent: await readRecentProjects(opts.recentDir) });
260
260
  }
261
261
  const renderProps = JSON.parse(await readFile(propsPath(), "utf8"));
262
+ // Parsed, never cast — and that parse is also what makes the doc
263
+ // safe to migrate downstream: a literal `"__proto__"` caption key
264
+ // survives `JSON.parse` as an own property, and any later pass that
265
+ // rebuilds the record would assign through it instead of keeping it.
262
266
  const overrides = existsSync(overridesPath())
263
267
  ? OverrideDocSchema.parse(JSON.parse(await readFile(overridesPath(), "utf8")))
264
268
  : emptyOverrideDoc();
269
+ // §137 DECISION (Task 6): the pre-§137 caption-key migration does
270
+ // NOT run here. It resolves a positional key by finding the word it
271
+ // named and taking that word's source anchor — and these render
272
+ // props are served exactly as they sit on disk, where a pre-§137
273
+ // file's caption words have no `srcStart` at all. Every word would
274
+ // answer "no anchor", every edit would land in `unresolved`, and the
275
+ // migration would report total loss while doing nothing: a call that
276
+ // passes its own tests and is inert in production.
277
+ // Anchoring them here instead would mean a second copy of the "no
278
+ // usable map, no repair" rule (`anchorCaptionLines`, apps/editor) in
279
+ // this package — the CLI cannot import the editor's source, which it
280
+ // only ever ships as a built `editor-dist/` — and that rule is
281
+ // exactly the one §137 refuses to have two of. So the EDITOR owns
282
+ // the repair, at the one point that holds anchored lines and the doc
283
+ // at the same time (App.tsx's load path), and it loses no reach:
284
+ // this endpoint has exactly one consumer.
265
285
  return send(200, {
266
286
  renderProps,
267
287
  overrides,
@@ -450,6 +470,24 @@ export async function startEditServer(
450
470
  for await (const c of req) chunks.push(c as Buffer);
451
471
  const parsed = OverrideDocSchema.safeParse(JSON.parse(Buffer.concat(chunks).toString()));
452
472
  if (!parsed.success) return send(400, { error: parsed.error.message });
473
+ // NO `.bak` HERE, and that is a decision, not an omission (final
474
+ // review, Important 5). This write is safe without one only because
475
+ // it ROUND-TRIPS: whatever the editor loaded, it saves back, plus
476
+ // the change the user just made. §137 briefly broke that property —
477
+ // `migrateLoadedDoc` stripped the caption edits the migration could
478
+ // not place before `edits.load` (which also clears undo), so the
479
+ // first save after opening a legacy project deleted them
480
+ // permanently. The fix is in `migrateLoadedDoc`, which now keeps
481
+ // them, rather than here.
482
+ //
483
+ // Adding produce's `.bak` to this handler was the other option and
484
+ // is actively worse: `overrides.json.bak` is single-generation and
485
+ // SHARED with produce's write, so a routine ⌘S would spend the one
486
+ // the user's pre-cut save is sitting in — which on the §137 field
487
+ // workdir is the only artefact their deleted split half can ever be
488
+ // recovered from (`legacySplitId`). That is the review's own
489
+ // Critical 2 reintroduced through the editor.
490
+ //
453
491
  // Atomic: the producer may read this file at any moment, and a
454
492
  // half-written document would be worse than a stale one.
455
493
  const tmp = `${overridesPath()}.tmp`;
@@ -0,0 +1,77 @@
1
+ import { readFile, rename, writeFile } from "node:fs/promises";
2
+ import type { OverrideDoc } from "@ossclip/core";
3
+
4
+ /**
5
+ * The one sanctioned `overrides.json` write (PLAN 2026-08-04 Task 4), and the
6
+ * rule about when it may spend the `.bak`.
7
+ *
8
+ * Lifted out of `produce.ts` so the backup rule is testable at all: nothing in
9
+ * the repo invokes `produce()` (it needs ffmpeg, a transcript, a workdir and a
10
+ * render), and "the previous copy is still on disk, byte for byte" is a claim
11
+ * about a FILE — there is no pure form of it. `produce.ts` keeps the decision
12
+ * of WHETHER to write, the ordering relative to `render-props.json`, and the
13
+ * `console.log`.
14
+ */
15
+
16
+ /**
17
+ * Replace `overrides.json`, optionally refreshing `overrides.json.bak` first.
18
+ *
19
+ * REFRESHING THE BACKUP IS NOT PART OF WRITING (final review round 2, Critical
20
+ * 2 residual). The `.bak` is single-generation, so every refresh SPENDS
21
+ * whatever it held, and the two writers have very different claims on it:
22
+ *
23
+ * - A CUT re-anchoring rewrites absolute output-second VALUES all over the
24
+ * doc — split times, pins, framing — from a frame the pipeline just
25
+ * recomputed. That is unreadable in a diff and irreversible by hand, so the
26
+ * copy it replaces is worth keeping and this is the write the `.bak` exists
27
+ * for.
28
+ * - A CAPTION-KEY migration differs from the copy on disk in caption KEYS
29
+ * only, every one of which the run just printed by name, and (since
30
+ * `captionEditsToKeep`) it deletes nothing. There is nothing in the old
31
+ * copy worth recovering — while the `.bak` it would overwrite may be the
32
+ * user's last PRE-CUT save, which on the §137 field workdir is the only
33
+ * artefact holding `splits: [0.6]` and so the only route back to the split
34
+ * half they deleted (`legacySplitId` can no longer derive `600` from a
35
+ * re-anchored `splits: [0]`).
36
+ *
37
+ * The first cut of the §137 fix refreshed unconditionally, which meant the
38
+ * branch's own marquee scenario — three of that user's four retypes recovered
39
+ * — destroyed the evidence for the other half of the same bug. Gating the
40
+ * WRITE on work done (`produce.ts`) only removed the zero-repair case; this is
41
+ * the rest of it.
42
+ *
43
+ * Atomic via tmp+rename either way, matching the edit server's own
44
+ * `PUT /overrides` handler: the producer or a live editor session may read
45
+ * this file at any moment, and a half-written document would be worse than a
46
+ * stale one.
47
+ */
48
+ export async function writeOverrideDoc(
49
+ overridesPath: string,
50
+ doc: OverrideDoc,
51
+ opts: { refreshBackup: boolean },
52
+ ): Promise<void> {
53
+ if (opts.refreshBackup) {
54
+ try {
55
+ const raw = await readFile(overridesPath, "utf8");
56
+ await writeFile(`${overridesPath}.bak`, raw);
57
+ } catch {
58
+ // Nothing on disk to back up (first cut ever applied here) — fine.
59
+ }
60
+ }
61
+ const tmp = `${overridesPath}.tmp`;
62
+ await writeFile(tmp, JSON.stringify(doc, null, 2));
63
+ await rename(tmp, overridesPath);
64
+ }
65
+
66
+ /**
67
+ * What the run says about that write. Pure, and separate from the write for
68
+ * the house reason — but also because the two halves of this sentence are the
69
+ * two halves of the decision above, and a line that claims a backup nobody
70
+ * took is how a user finds out too late (final review round 2).
71
+ */
72
+ export function overridesWriteLine(cutChanged: boolean): string {
73
+ return cutChanged
74
+ ? "▸ overrides.json re-anchored to the new cut and saved (previous copy kept as .bak)"
75
+ : "▸ overrides.json re-anchored to source-time caption keys and saved " +
76
+ "(overrides.json.bak left alone — it may be an older, pre-cut copy worth more than this one)";
77
+ }
package/src/produce.ts CHANGED
@@ -15,7 +15,6 @@ import {
15
15
  applyOverrides,
16
16
  applyRepairs,
17
17
  assembleScenes,
18
- applyCaptionEdits,
19
18
  buildCaptionLines,
20
19
  buildCutlist,
21
20
  buildZoomPlan,
@@ -105,6 +104,8 @@ import {
105
104
  workdirBaseName,
106
105
  } from "./stranded-overrides";
107
106
  import { editHint } from "./interactive/edit-hint";
107
+ import { reconcileCaptionEdits } from "./caption-report";
108
+ import { overridesWriteLine, writeOverrideDoc } from "./overrides-write";
108
109
  import { recordedProduceArgs } from "./replay-argv";
109
110
  import { renderCover, renderProduction } from "@ossclip/renderer";
110
111
  import {
@@ -1595,7 +1596,8 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1595
1596
  clipStarts: map.spans.map((s) => s.outIn),
1596
1597
  });
1597
1598
  // User splits (R16 §61) — after the fill so takes split like scenes, and
1598
- // before the final override pass so edits on the `id@ms` halves land. A
1599
+ // before the final override pass so edits on the `id@<split id>` halves land
1600
+ // (the suffix is the split's own minted id, §137, not its time). A
1599
1601
  // split whose ROOT was a graphic scene already happened once inside
1600
1602
  // `splitThenDropHidden` above (PLAN 2026-08-04 Task 1) — re-running it here
1601
1603
  // is a no-op for that scene (the split point sits exactly on the joint
@@ -1607,7 +1609,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1607
1609
  }
1608
1610
  const { cues: mergedCues, orphans: rawOrphans } = applyOverrides(split, overrideDoc);
1609
1611
  // Halves of a TAKE the user deleted after splitting: a take id only exists
1610
- // once the fill above runs, so its `id@ms` half couldn't have been seen by
1612
+ // once the fill above runs, so its `id@<split id>` half couldn't have been seen by
1611
1613
  // `splitThenDropHidden` earlier (that pass only ever saw graphic scenes).
1612
1614
  // Scene halves were already caught above; this is a no-op for them. Same
1613
1615
  // order as the editor's live memo.
@@ -1931,22 +1933,36 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
1931
1933
  // caption output for zero visual reason (PLAN Task A4.4).
1932
1934
  breakpoints: graphicCues.flatMap((c) => [c.startSec, c.endSec]),
1933
1935
  });
1934
- // The user's retyped caption words (editor, PLAN 2026-07-29 Task 7 scope
1935
- // (a)). Guarded per word: a stale edit — the pipeline re-derived a
1936
- // different word at that position — is dropped LOUDLY, never applied to
1937
- // the wrong word and never silently forgotten.
1938
- const { lines: captionLines, dropped: staleCaptionEdits } = applyCaptionEdits(
1939
- baseCaptionLines,
1940
- overrideDoc.captions,
1941
- );
1942
- const liveCaptionEdits = Object.keys(overrideDoc.captions).length - staleCaptionEdits.length;
1943
- if (liveCaptionEdits > 0) console.log(`▸ ${liveCaptionEdits} caption word(s) retyped by the editor`);
1944
- for (const d of staleCaptionEdits) {
1945
- console.log(
1946
- ` ⚠ caption edit at word ${d.index} dropped: expected "${d.expected}" there, ` +
1947
- `the transcript now has "${d.found}"`,
1948
- );
1949
- }
1936
+ // §137 (Task 6 review, Critical 1): the caption half of a run — migrate the
1937
+ // doc's keys, apply what applies, and account for the rest — is one pure
1938
+ // pass in `caption-report.ts`, and this is its I/O.
1939
+ //
1940
+ // MIGRATION RUNS HERE, not only in the editor. The editor's is in memory and
1941
+ // `edits.load` leaves the doc CLEAN, so `onRender` (which saves only when
1942
+ // dirty) sends a user who opened an old project, saw every retype come back
1943
+ // on screen, and clicked Render straight into this function with the
1944
+ // untouched legacy doc — and `applyCaptionEdits` matched nothing, so the
1945
+ // render shipped without a single retype after showing a state strictly more
1946
+ // convincing than the truth. None of the reasoning that keeps this call out
1947
+ // of `edit.ts` applies: `buildCaptionLines` just stamped a real `srcStart`
1948
+ // on every word above (`captions.ts:128`), so there is nothing to backfill
1949
+ // and no repair rule to duplicate.
1950
+ //
1951
+ // THE MIGRATED DOC IS WRITTEN BACK — the decision, stated: through the one
1952
+ // sanctioned `overrides.json` write further down, with its `.bak` and atomic
1953
+ // rename, never a second write here. A legacy key's resolvability DECAYS (it
1954
+ // is found by the word it names, so the next re-plan that rewrites that word
1955
+ // loses it for good), and this is the only durable repair in the product —
1956
+ // the editor's evaporates, as Critical 1 showed. The edits it could NOT
1957
+ // place stay in the doc regardless (`captionEditsToKeep`): they are printed
1958
+ // by name below, and a run that cannot anchor one today is not permission to
1959
+ // delete it — the next run, against a different cut, may place it (final
1960
+ // review, Critical 1).
1961
+ const captionWork = reconcileCaptionEdits(overrideDoc, baseCaptionLines);
1962
+ overrideDoc = captionWork.doc;
1963
+ const captionLines = captionWork.lines;
1964
+ const captionKeysReanchored = captionWork.reanchored;
1965
+ for (const line of captionWork.log) console.log(line);
1950
1966
 
1951
1967
  // Micro zoom punches (FINDINGS §15) reversing at real phrase breaks (§18).
1952
1968
  // Breaths are source-time; TimeMap has no span mapper, so both ends go
@@ -2185,23 +2201,32 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
2185
2201
  // next run's `priorMap` then sees drift and re-anchors again, the same
2186
2202
  // recovery path finding 3 already has to support — never a false "nothing
2187
2203
  // changed" that quietly corrupts positions.
2188
- if (cutResult.changed) {
2189
- // Keep a `.bak` of whatever was on disk first — the same safety net
2190
- // `saveConfigPatch` keeps for a config file it's about to replace —
2191
- // before overwriting the user's own data. Atomic write via tmp+rename,
2192
- // matching the edit server's own `PUT /overrides` handler: the producer
2193
- // or a live editor session may read this file at any moment, and a
2194
- // half-written document would be worse than a stale one.
2195
- try {
2196
- const raw = await readFile(overridesPath, "utf8");
2197
- await writeFile(`${overridesPath}.bak`, raw);
2198
- } catch {
2199
- // Nothing on disk to back up (first cut ever applied here) — fine.
2200
- }
2201
- const tmp = `${overridesPath}.tmp`;
2202
- await writeFile(tmp, JSON.stringify(overrideDoc, null, 2));
2203
- await rename(tmp, overridesPath);
2204
- console.log("▸ overrides.json re-anchored to the new cut and saved (previous copy kept as .bak)");
2204
+ // `cutResult.changed` OR a §137 caption-key migration that actually MOVED an
2205
+ // edit — the second is why this is no longer a bare cut check. Both are
2206
+ // re-anchorings of the user's doc to something the pipeline just recomputed,
2207
+ // and both go through the one sanctioned write; a separate write for the
2208
+ // migration would be a SECOND sanctioned write, which the comment above
2209
+ // exists to prevent.
2210
+ //
2211
+ // "ACTUALLY MOVED" is load-bearing and was not there at first (final review,
2212
+ // Critical 2). Gated on "the migration reported something" instead, this
2213
+ // fires on a run that repaired NOTHING — and since a caption migration is
2214
+ // independent of the cut, it fires on runs where the pre-§137 gate wrote
2215
+ // nothing at all.
2216
+ //
2217
+ // THEY DO NOT SHARE THE `.bak`, THOUGH, and that is the rest of the same
2218
+ // finding (final review round 2). `refreshBackup: cutResult.changed`, never
2219
+ // the gate: on the field workdir the caption migration re-anchors THREE
2220
+ // edits, so the gate legitimately fires while `cutResult.changed` is false —
2221
+ // and an unconditional refresh would then copy the already-damaged
2222
+ // `overrides.json` over `overrides.json.bak`, which is that user's only
2223
+ // pre-cut save and the only artefact their deleted split half can still be
2224
+ // recovered from (`legacySplitId`). Repairing the captions would destroy the
2225
+ // evidence for the split. `writeOverrideDoc` carries the full argument for
2226
+ // why a caption-only write has nothing worth backing up.
2227
+ if (cutResult.changed || captionKeysReanchored) {
2228
+ await writeOverrideDoc(overridesPath, overrideDoc, { refreshBackup: cutResult.changed });
2229
+ console.log(overridesWriteLine(cutResult.changed));
2205
2230
  }
2206
2231
 
2207
2232
  if (!opts.render) {
@@ -1 +0,0 @@
1
- .ossclip-scroll-list{scrollbar-width:thin;scrollbar-color:#3a3a44 transparent}.ossclip-scroll-list::-webkit-scrollbar{width:8px}.ossclip-scroll-list::-webkit-scrollbar-track{background:transparent}.ossclip-scroll-list::-webkit-scrollbar-thumb{background:#3a3a44;border-radius:4px}