ossclip 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/README.md +3 -0
- package/editor-dist/assets/index-B44I-xE4.js +163 -0
- package/editor-dist/assets/index-ChRVBVLj.css +1 -0
- package/editor-dist/index.html +2 -2
- package/package.json +4 -4
- package/src/analyse.ts +101 -0
- package/src/caption-report.ts +217 -0
- package/src/edit.ts +38 -0
- package/src/overrides-write.ts +77 -0
- package/src/phase-timing.ts +140 -0
- package/src/produce.ts +145 -88
- package/src/program.ts +79 -0
- package/editor-dist/assets/index-C8IPo60X.css +0 -1
- package/editor-dist/assets/index-CdwzWN3j.js +0 -162
|
@@ -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}
|
package/editor-dist/index.html
CHANGED
|
@@ -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-
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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.
|
|
3
|
+
"version": "0.1.20",
|
|
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/
|
|
40
|
-
"@ossclip/renderer": "0.1.
|
|
41
|
-
"@ossclip/
|
|
39
|
+
"@ossclip/scenes": "0.1.20",
|
|
40
|
+
"@ossclip/renderer": "0.1.20",
|
|
41
|
+
"@ossclip/core": "0.1.20"
|
|
42
42
|
},
|
|
43
43
|
"homepage": "https://github.com/AhsanAyaz/ossclip#readme",
|
|
44
44
|
"bugs": {
|
package/src/analyse.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { z } from "zod/v4";
|
|
4
|
+
import {
|
|
5
|
+
ProductionSchema,
|
|
6
|
+
buildFcpxmlMarkers,
|
|
7
|
+
type CleanupLevel,
|
|
8
|
+
} from "@ossclip/core";
|
|
9
|
+
import { produce } from "./produce";
|
|
10
|
+
import type { PhaseTimings } from "./phase-timing";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `ossclip analyse` (next-directions §2–3; design doc
|
|
14
|
+
* 2026-08-12-analyse-fcpxml-export-design.md): the analyser without the
|
|
15
|
+
* renderer. §140 measured the render at 85% of wall time on two machines —
|
|
16
|
+
* this command is the product of skipping it: the same pipeline up to the
|
|
17
|
+
* cut report (no LLM, no Remotion), plus an export file an editor's own NLE
|
|
18
|
+
* can review. Markers, not applied cuts, by design — see the exporter.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* zod enum, not a string: a typo'd `--format fcpxmll` must error naming the
|
|
23
|
+
* choice, never fall back to a default that silently writes the wrong file
|
|
24
|
+
* (CLAUDE.md's `--source-fit containn` rule, verbatim).
|
|
25
|
+
*/
|
|
26
|
+
export const ExportFormatSchema = z.enum(["fcpxml"]);
|
|
27
|
+
export type ExportFormat = z.infer<typeof ExportFormatSchema>;
|
|
28
|
+
|
|
29
|
+
/** Same shape as produce's `defaultOutPath`: beside the input, new extension. */
|
|
30
|
+
export function defaultExportPath(input: string, format: ExportFormat): string {
|
|
31
|
+
return input.replace(/(\.[^.]+)?$/, `.${format}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AnalyseOptions {
|
|
35
|
+
cleanup: CleanupLevel;
|
|
36
|
+
format: ExportFormat;
|
|
37
|
+
out?: string;
|
|
38
|
+
transcript?: string;
|
|
39
|
+
workdir?: string;
|
|
40
|
+
noiseDb?: number;
|
|
41
|
+
whisperModel?: string;
|
|
42
|
+
whisperLanguage?: string;
|
|
43
|
+
blooperMarker?: string;
|
|
44
|
+
collapseRetakes?: boolean;
|
|
45
|
+
sort?: "name" | "mtime";
|
|
46
|
+
sortExplicit?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AnalyseResult {
|
|
50
|
+
workdir: string;
|
|
51
|
+
outPath: string;
|
|
52
|
+
markerCount: number;
|
|
53
|
+
sourceDurationSec: number;
|
|
54
|
+
phaseTimings: PhaseTimings;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The I/O glue: run the existing no-render pipeline, read back the
|
|
59
|
+
* `production.json` it wrote, hand it to the pure exporter, write the file.
|
|
60
|
+
* The read-back goes through `ProductionSchema.parse` even though this very
|
|
61
|
+
* run just wrote it — the file is user-visible and hand-editable, and a
|
|
62
|
+
* truncated or tweaked one must error here, not export garbage markers.
|
|
63
|
+
*/
|
|
64
|
+
export async function runAnalyse(
|
|
65
|
+
inputArg: string,
|
|
66
|
+
opts: AnalyseOptions,
|
|
67
|
+
): Promise<AnalyseResult> {
|
|
68
|
+
const result = await produce(inputArg, {
|
|
69
|
+
cleanup: opts.cleanup,
|
|
70
|
+
transcript: opts.transcript,
|
|
71
|
+
render: false,
|
|
72
|
+
mezzanine: false,
|
|
73
|
+
workdir: opts.workdir,
|
|
74
|
+
noiseDb: opts.noiseDb,
|
|
75
|
+
whisperModel: opts.whisperModel,
|
|
76
|
+
whisperLanguage: opts.whisperLanguage,
|
|
77
|
+
blooperMarker: opts.blooperMarker,
|
|
78
|
+
collapseRetakes: opts.collapseRetakes,
|
|
79
|
+
sort: opts.sort,
|
|
80
|
+
sortExplicit: opts.sortExplicit,
|
|
81
|
+
cover: false,
|
|
82
|
+
});
|
|
83
|
+
const production = ProductionSchema.parse(
|
|
84
|
+
JSON.parse(await readFile(join(result.workdir, "production.json"), "utf8")),
|
|
85
|
+
);
|
|
86
|
+
const xml = buildFcpxmlMarkers(production);
|
|
87
|
+
const markerCount = (production.cutlist ?? []).filter((s) => s.kind === "remove").length;
|
|
88
|
+
const outPath = resolve(opts.out ?? defaultExportPath(resolve(inputArg), opts.format));
|
|
89
|
+
await writeFile(outPath, xml);
|
|
90
|
+
console.log(
|
|
91
|
+
`✓ ${opts.format} → ${outPath} (${markerCount} marker${markerCount === 1 ? "" : "s"} — ` +
|
|
92
|
+
"import into Resolve/Premiere and review before cutting)",
|
|
93
|
+
);
|
|
94
|
+
return {
|
|
95
|
+
workdir: result.workdir,
|
|
96
|
+
outPath,
|
|
97
|
+
markerCount,
|
|
98
|
+
sourceDurationSec: result.sourceDurationSec,
|
|
99
|
+
phaseTimings: result.phaseTimings,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-phase timing for `produce` (FINDINGS §140). "3 hours" was
|
|
3
|
+
* unattributed: `produce_completed` carried one `duration_ms` for the whole
|
|
4
|
+
* run, so nothing could say WHICH of the four candidate phases — whisper,
|
|
5
|
+
* LLM planning, the Remotion render, ffmpeg concat/normalize — the time went
|
|
6
|
+
* to, and each has a completely different fix if it dominates. Everything in
|
|
7
|
+
* this file is pure (clock injected, no I/O), per the house split; produce()
|
|
8
|
+
* owns the wrapping and program.ts owns the telemetry event.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The four attributed phases, exactly the candidates from the §140 table.
|
|
13
|
+
* Everything ELSE produce does (audio extraction, silence/level analysis,
|
|
14
|
+
* content-rect and face sampling, the mezzanine) lands in the log line's
|
|
15
|
+
* `other` remainder rather than a fifth phase — the remainder is PRINTED, so
|
|
16
|
+
* if it ever dominates a real run it accuses itself and earns a phase then.
|
|
17
|
+
*/
|
|
18
|
+
export type ProducePhase = "transcribe" | "llm" | "render" | "ffmpeg";
|
|
19
|
+
|
|
20
|
+
/** Milliseconds per phase; a phase that never ran is ABSENT, never 0. */
|
|
21
|
+
export type PhaseTimings = Partial<Record<ProducePhase, number>>;
|
|
22
|
+
|
|
23
|
+
/** Pipeline order — the log line reads like the run did. */
|
|
24
|
+
const PHASE_ORDER: ProducePhase[] = ["transcribe", "llm", "render", "ffmpeg"];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The log labels name the TOOL, not the internal phase id, because the §140
|
|
28
|
+
* table (and any hardware decision made from it) is about the tools:
|
|
29
|
+
* "whisper 12m" tells the user what to swap; "transcribe 12m" makes them ask.
|
|
30
|
+
*/
|
|
31
|
+
const PHASE_LABELS: Record<ProducePhase, string> = {
|
|
32
|
+
transcribe: "whisper",
|
|
33
|
+
llm: "llm",
|
|
34
|
+
render: "render",
|
|
35
|
+
ffmpeg: "ffmpeg",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export class PhaseTimer {
|
|
39
|
+
private readonly ms: PhaseTimings = {};
|
|
40
|
+
private readonly startedAt: number;
|
|
41
|
+
|
|
42
|
+
constructor(private readonly now: () => number = () => performance.now()) {
|
|
43
|
+
this.startedAt = this.now();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Accumulates — the llm phase is repair + window selection + scenes, and
|
|
48
|
+
* the ffmpeg phase is concat + loudnorm, so a phase is a SUM of calls, not
|
|
49
|
+
* one interval. Recorded in a `finally` so time spent is time recorded even
|
|
50
|
+
* when the phase throws: the throw aborts the run either way, but a future
|
|
51
|
+
* `produce_failed` that wants to say where the time went must not find the
|
|
52
|
+
* books cooked.
|
|
53
|
+
*/
|
|
54
|
+
async time<T>(phase: ProducePhase, fn: () => Promise<T>): Promise<T> {
|
|
55
|
+
const t0 = this.now();
|
|
56
|
+
try {
|
|
57
|
+
return await fn();
|
|
58
|
+
} finally {
|
|
59
|
+
this.ms[phase] = (this.ms[phase] ?? 0) + (this.now() - t0);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
timings(): PhaseTimings {
|
|
64
|
+
return { ...this.ms };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Wall clock since construction — the phases never sum to it; `other` is the gap. */
|
|
68
|
+
totalMs(): number {
|
|
69
|
+
return this.now() - this.startedAt;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Three scales, matched to what a human checks at each: one decimal under a
|
|
75
|
+
* minute (an 8.2s llm phase must not flatten to "8s" when comparing runs),
|
|
76
|
+
* zero-padded seconds under an hour ("1m03s", so it can't be misread as
|
|
77
|
+
* 1m30s), minutes only above it.
|
|
78
|
+
*/
|
|
79
|
+
export function formatPhaseDuration(ms: number): string {
|
|
80
|
+
const sec = ms / 1000;
|
|
81
|
+
if (sec < 60) return `${sec.toFixed(1)}s`;
|
|
82
|
+
if (sec < 3600) {
|
|
83
|
+
return `${Math.floor(sec / 60)}m${String(Math.floor(sec % 60)).padStart(2, "0")}s`;
|
|
84
|
+
}
|
|
85
|
+
return `${Math.floor(sec / 3600)}h${String(Math.floor((sec % 3600) / 60)).padStart(2, "0")}m`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The run log's one-line breakdown, in the ▸ voice, real seconds — this is
|
|
90
|
+
* the user's own machine, so unlike telemetry there is nothing to bucket.
|
|
91
|
+
* Absent phases are omitted (a cached transcript is not a 0.0s whisper run),
|
|
92
|
+
* the remainder is printed as `other` so unattributed time stays visible,
|
|
93
|
+
* and a run with no measured phases prints only the total — an `other` at
|
|
94
|
+
* 100% would just restate it. The remainder clamps at zero: the phases and
|
|
95
|
+
* the total read the clock at different instants, and a -0.0s from that skew
|
|
96
|
+
* would read as a bug in the very line meant to build trust in the numbers.
|
|
97
|
+
*/
|
|
98
|
+
export function formatPhaseLine(timings: PhaseTimings, totalMs: number): string {
|
|
99
|
+
const measured = PHASE_ORDER.filter((p) => timings[p] !== undefined);
|
|
100
|
+
const total = `▸ time: total ${formatPhaseDuration(totalMs)}`;
|
|
101
|
+
if (measured.length === 0) return total;
|
|
102
|
+
const parts = measured.map((p) => `${PHASE_LABELS[p]} ${formatPhaseDuration(timings[p]!)}`);
|
|
103
|
+
const other = totalMs - measured.reduce((sum, p) => sum + timings[p]!, 0);
|
|
104
|
+
if (other > 0) parts.push(`other ${formatPhaseDuration(other)}`);
|
|
105
|
+
return `${total} — ${parts.join(" · ")}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Same idea as telemetry.ts's `durationBucket` — the exact seconds never
|
|
110
|
+
* leave the machine — but with sub-minute resolution, because the question
|
|
111
|
+
* this answers ("which phase dominates?") has phases that legitimately live
|
|
112
|
+
* in seconds: an llm plan at 8s and a render at 40 minutes both being ">1m"
|
|
113
|
+
* would erase the very comparison §140 exists to make.
|
|
114
|
+
*/
|
|
115
|
+
export function phaseDurationBucket(
|
|
116
|
+
seconds: number,
|
|
117
|
+
): "<10s" | "10-60s" | "1-5m" | "5-15m" | ">15m" {
|
|
118
|
+
if (seconds < 10) return "<10s";
|
|
119
|
+
if (seconds <= 60) return "10-60s";
|
|
120
|
+
if (seconds <= 300) return "1-5m";
|
|
121
|
+
if (seconds <= 900) return "5-15m";
|
|
122
|
+
return ">15m";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The `produce_completed` props for the phases that ran: `<phase>_bucket`,
|
|
127
|
+
* bucketed, never raw milliseconds (§134 floor — a raw per-phase duration is
|
|
128
|
+
* even closer to fingerprinting a specific take than the total the floor
|
|
129
|
+
* already buckets). Keys are pinned against `assertSafeProps` in
|
|
130
|
+
* phase-timing.test.ts, since a spread into `telemetry.record` is invisible
|
|
131
|
+
* to telemetry.test.ts's source-text drift check.
|
|
132
|
+
*/
|
|
133
|
+
export function phaseBucketProps(timings: PhaseTimings): Record<string, string> {
|
|
134
|
+
const props: Record<string, string> = {};
|
|
135
|
+
for (const p of PHASE_ORDER) {
|
|
136
|
+
const ms = timings[p];
|
|
137
|
+
if (ms !== undefined) props[`${p}_bucket`] = phaseDurationBucket(ms / 1000);
|
|
138
|
+
}
|
|
139
|
+
return props;
|
|
140
|
+
}
|