ossclip 0.1.3 → 0.1.5
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/editor-dist/assets/{index-B5H_dKAW.js → index-DZhkQB1i.js} +24 -24
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/doctor.ts +31 -9
- package/src/index.ts +56 -4
- package/src/open.ts +30 -0
- package/src/produce.ts +121 -14
- package/src/setup/download.ts +122 -0
- package/src/setup/extract.ts +91 -0
- package/src/setup/manifest.ts +159 -0
- package/src/setup/plan.ts +211 -0
- package/src/setup/provider.ts +51 -0
- package/src/setup/setup.ts +233 -0
package/editor-dist/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
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-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-DZhkQB1i.js"></script>
|
|
8
8
|
</head>
|
|
9
9
|
<body>
|
|
10
10
|
<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.5",
|
|
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",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"commander": "^12.1.0",
|
|
36
36
|
"tsx": "^4.19.0",
|
|
37
37
|
"zod": "^3.25.76",
|
|
38
|
-
"@ossclip/
|
|
39
|
-
"@ossclip/
|
|
40
|
-
"@ossclip/renderer": "0.1.
|
|
38
|
+
"@ossclip/core": "0.1.5",
|
|
39
|
+
"@ossclip/scenes": "0.1.5",
|
|
40
|
+
"@ossclip/renderer": "0.1.5"
|
|
41
41
|
},
|
|
42
42
|
"homepage": "https://github.com/AhsanAyaz/ossclip#readme",
|
|
43
43
|
"bugs": {
|
package/src/doctor.ts
CHANGED
|
@@ -10,6 +10,11 @@ import type { OssclipConfig } from "@ossclip/core";
|
|
|
10
10
|
* every one that fails silently becomes a GitHub issue instead of a
|
|
11
11
|
* one-line fix the tool could have printed itself.
|
|
12
12
|
*
|
|
13
|
+
* Since `ossclip setup` exists, doctor and setup are two halves of one
|
|
14
|
+
* contract: setup is the fix doctor prints first, and doctor is the
|
|
15
|
+
* verification setup ends with. The manual command stays on every fix line
|
|
16
|
+
* for people who'd rather own their toolchain.
|
|
17
|
+
*
|
|
13
18
|
* Checks are pure over injected probes so the table is unit-testable; the
|
|
14
19
|
* CLI wires the real spawn/existsSync in. The provider check MUST run after
|
|
15
20
|
* `loadEnvFiles` (R16 §77) or a key living in `.env` reports a false
|
|
@@ -36,9 +41,18 @@ export interface DoctorProbes {
|
|
|
36
41
|
editorPageDir: string | null;
|
|
37
42
|
}
|
|
38
43
|
|
|
39
|
-
/** brew on mac, apt
|
|
40
|
-
const installHint = (
|
|
41
|
-
platform
|
|
44
|
+
/** brew on mac, apt on linux, winget/prebuilt on windows — generic covers the rest. */
|
|
45
|
+
const installHint = (
|
|
46
|
+
platform: NodeJS.Platform,
|
|
47
|
+
brew: string,
|
|
48
|
+
apt: string,
|
|
49
|
+
win: string,
|
|
50
|
+
generic: string,
|
|
51
|
+
): string =>
|
|
52
|
+
platform === "darwin" ? brew : platform === "linux" ? apt : platform === "win32" ? win : generic;
|
|
53
|
+
|
|
54
|
+
/** Every installable prerequisite leads with the one-command ramp. */
|
|
55
|
+
const viaSetup = (manual: string): string => "run `ossclip setup` — or manually: " + manual;
|
|
42
56
|
|
|
43
57
|
export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<DoctorCheck[]> {
|
|
44
58
|
const checks: DoctorCheck[] = [];
|
|
@@ -60,13 +74,15 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
|
|
|
60
74
|
...(ffmpegOk
|
|
61
75
|
? {}
|
|
62
76
|
: {
|
|
63
|
-
fix:
|
|
77
|
+
fix: viaSetup(
|
|
64
78
|
installHint(
|
|
65
79
|
p.platform,
|
|
66
80
|
"brew install ffmpeg",
|
|
67
81
|
"sudo apt install ffmpeg",
|
|
82
|
+
"winget install ffmpeg",
|
|
68
83
|
"install ffmpeg from https://ffmpeg.org",
|
|
69
84
|
) + " — or point OSSCLIP_FFMPEG (or config.json ffmpegPath) at the binary",
|
|
85
|
+
),
|
|
70
86
|
}),
|
|
71
87
|
});
|
|
72
88
|
|
|
@@ -78,13 +94,15 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
|
|
|
78
94
|
...(ffprobeOk
|
|
79
95
|
? {}
|
|
80
96
|
: {
|
|
81
|
-
fix:
|
|
97
|
+
fix: viaSetup(
|
|
82
98
|
installHint(
|
|
83
99
|
p.platform,
|
|
84
100
|
"brew install ffmpeg (provides ffprobe)",
|
|
85
101
|
"sudo apt install ffmpeg (provides ffprobe)",
|
|
102
|
+
"winget install ffmpeg (provides ffprobe)",
|
|
86
103
|
"ffprobe ships with ffmpeg — https://ffmpeg.org",
|
|
87
104
|
) + " — or set OSSCLIP_FFPROBE",
|
|
105
|
+
),
|
|
88
106
|
}),
|
|
89
107
|
});
|
|
90
108
|
|
|
@@ -96,13 +114,15 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
|
|
|
96
114
|
...(whisperOk
|
|
97
115
|
? {}
|
|
98
116
|
: {
|
|
99
|
-
fix:
|
|
117
|
+
fix: viaSetup(
|
|
100
118
|
installHint(
|
|
101
119
|
p.platform,
|
|
102
120
|
"brew install whisper-cpp",
|
|
103
|
-
"
|
|
121
|
+
"download a prebuilt from https://github.com/ggml-org/whisper.cpp/releases",
|
|
122
|
+
"download whisper-blas-bin-x64.zip from https://github.com/ggml-org/whisper.cpp/releases",
|
|
104
123
|
"build whisper.cpp from source: https://github.com/ggml-org/whisper.cpp",
|
|
105
124
|
) + " — or point OSSCLIP_WHISPER at your whisper-cli",
|
|
125
|
+
),
|
|
106
126
|
}),
|
|
107
127
|
});
|
|
108
128
|
|
|
@@ -117,9 +137,10 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
|
|
|
117
137
|
...(modelOk
|
|
118
138
|
? {}
|
|
119
139
|
: {
|
|
120
|
-
fix:
|
|
140
|
+
fix: viaSetup(
|
|
121
141
|
`mkdir -p ${cfg.modelDir} && curl -L -o ${modelPath} ` +
|
|
122
|
-
|
|
142
|
+
`https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${cfg.model}.bin`,
|
|
143
|
+
),
|
|
123
144
|
}),
|
|
124
145
|
});
|
|
125
146
|
|
|
@@ -141,6 +162,7 @@ export async function runDoctor(cfg: OssclipConfig, p: DoctorProbes): Promise<Do
|
|
|
141
162
|
? {}
|
|
142
163
|
: {
|
|
143
164
|
fix:
|
|
165
|
+
"run `ossclip setup` (it can save a key for you), or " +
|
|
144
166
|
"export ANTHROPIC_API_KEY or GEMINI_API_KEY (a .env file works — see README), " +
|
|
145
167
|
"or install Claude Code (https://claude.com/claude-code) and log in",
|
|
146
168
|
}),
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
|
-
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
5
|
import { Command, InvalidArgumentError } from "commander";
|
|
6
6
|
import { z } from "zod/v4";
|
|
7
7
|
import { CleanupLevelSchema, SceneComponentIdSchema } from "@ossclip/core";
|
|
@@ -110,6 +110,11 @@ program
|
|
|
110
110
|
"--source-is-edited",
|
|
111
111
|
"the source is already an edited reel with burned-in text — keep ossclip's graphics off it without waiting on detection",
|
|
112
112
|
)
|
|
113
|
+
.option(
|
|
114
|
+
"--blooper-marker <word>",
|
|
115
|
+
"cut the flubbed take whenever you say this word out loud (e.g. blooper): " +
|
|
116
|
+
"removal runs back to the start of the sentence it spoiled. Off unless given",
|
|
117
|
+
)
|
|
113
118
|
.option("--no-cover", "skip the cover image written beside the video")
|
|
114
119
|
.option("--cover <path>", "cover image output path (default: <out>.cover.jpg)")
|
|
115
120
|
.action(async (input: string, opts) => {
|
|
@@ -148,6 +153,7 @@ program
|
|
|
148
153
|
// commander gives `--no-cover` as cover:false and `--cover <path>` as a
|
|
149
154
|
// string on the same key.
|
|
150
155
|
sourceIsEdited: opts.sourceIsEdited === true,
|
|
156
|
+
blooperMarker: opts.blooperMarker,
|
|
151
157
|
sourceFit,
|
|
152
158
|
cover: opts.cover !== false,
|
|
153
159
|
coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
|
|
@@ -186,11 +192,38 @@ program
|
|
|
186
192
|
.action(async (renderProps: string, opts) => {
|
|
187
193
|
const propsPath = resolve(renderProps);
|
|
188
194
|
const publicDir = opts.videoDir ? resolve(opts.videoDir) : dirname(propsPath);
|
|
195
|
+
// Resolve Remotion's CLI through module resolution instead of spawning
|
|
196
|
+
// `pnpm` — a global `npm i -g ossclip` has no pnpm and no workspace, and
|
|
197
|
+
// Windows would need the .cmd shim. `@remotion/cli` is a dependency of
|
|
198
|
+
// @ossclip/renderer, so resolving from THERE works in both a clone and a
|
|
199
|
+
// published install, on every OS, run via the node that's running us.
|
|
200
|
+
const { createRequire } = await import("node:module");
|
|
201
|
+
let remotionCliJs: string;
|
|
202
|
+
try {
|
|
203
|
+
const require = createRequire(import.meta.url);
|
|
204
|
+
const rendererDir = dirname(require.resolve("@ossclip/renderer/package.json"));
|
|
205
|
+
const fromRenderer = createRequire(join(rendererDir, "package.json"));
|
|
206
|
+
const cliPkgPath = fromRenderer.resolve("@remotion/cli/package.json");
|
|
207
|
+
const cliPkg = JSON.parse(readFileSync(cliPkgPath, "utf8")) as {
|
|
208
|
+
bin: string | Record<string, string>;
|
|
209
|
+
};
|
|
210
|
+
const binRel = typeof cliPkg.bin === "string" ? cliPkg.bin : cliPkg.bin.remotion;
|
|
211
|
+
if (!binRel) throw new Error("no remotion bin entry");
|
|
212
|
+
remotionCliJs = join(dirname(cliPkgPath), binRel);
|
|
213
|
+
} catch {
|
|
214
|
+
throw new Error(
|
|
215
|
+
"couldn't resolve @remotion/cli — in a clone, run `pnpm install` first",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
189
218
|
const child = spawn(
|
|
190
|
-
|
|
191
|
-
[
|
|
219
|
+
process.execPath,
|
|
220
|
+
[remotionCliJs, "studio", STUDIO_ENTRY, `--props=${propsPath}`, `--public-dir=${publicDir}`],
|
|
192
221
|
{ stdio: "inherit" },
|
|
193
222
|
);
|
|
223
|
+
child.on("error", (e) => {
|
|
224
|
+
console.error(`✗ failed to start Remotion Studio: ${e.message}`);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
});
|
|
194
227
|
child.on("exit", (code) => process.exit(code ?? 0));
|
|
195
228
|
});
|
|
196
229
|
|
|
@@ -218,7 +251,26 @@ program
|
|
|
218
251
|
}
|
|
219
252
|
const server = await startEditServer(workdir, { port: opts.port, pageDir });
|
|
220
253
|
console.log(`▸ editor at ${server.url}`);
|
|
221
|
-
if (opts.open)
|
|
254
|
+
if (opts.open) {
|
|
255
|
+
const { openInBrowser } = await import("./open");
|
|
256
|
+
openInBrowser(server.url);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
program
|
|
261
|
+
.command("setup")
|
|
262
|
+
.description(
|
|
263
|
+
"install everything ossclip needs (ffmpeg, whisper.cpp, the transcription model) " +
|
|
264
|
+
"into ~/.ossclip — the one-command onboarding on macOS, Linux, and Windows",
|
|
265
|
+
)
|
|
266
|
+
.option("--model <name>", "transcription model to download (default: config, i.e. small.en)")
|
|
267
|
+
.option("--skip-llm", "don't ask about an LLM provider (only --produce needs one)", false)
|
|
268
|
+
.option("--force", "re-download the pieces setup manages, even if present", false)
|
|
269
|
+
.option("-y, --yes", "no questions — accept the plan and skip the provider prompt", false)
|
|
270
|
+
.action(async (opts) => {
|
|
271
|
+
if (envFiles.length > 0) console.log(`▸ env: ${envFiles.join(", ")}`);
|
|
272
|
+
const { setup } = await import("./setup/setup");
|
|
273
|
+
await setup({ model: opts.model, skipLlm: opts.skipLlm, force: opts.force, yes: opts.yes });
|
|
222
274
|
});
|
|
223
275
|
|
|
224
276
|
program
|
package/src/open.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Open a URL in the default browser, per platform. The old code spawned
|
|
5
|
+
* macOS's `open` unconditionally — on Linux and Windows that ENOENT became
|
|
6
|
+
* an unhandled 'error' event and took the whole edit server down with it.
|
|
7
|
+
*
|
|
8
|
+
* Failure here is not an error: a headless box or WSL without a browser is
|
|
9
|
+
* a normal place to run `ossclip edit` — print the URL and move on.
|
|
10
|
+
*/
|
|
11
|
+
export function openCommand(
|
|
12
|
+
url: string,
|
|
13
|
+
platform: NodeJS.Platform,
|
|
14
|
+
): { bin: string; args: string[] } {
|
|
15
|
+
if (platform === "darwin") return { bin: "open", args: [url] };
|
|
16
|
+
if (platform === "win32") {
|
|
17
|
+
// `start` is a cmd built-in, not an executable; the empty string is
|
|
18
|
+
// start's window-title slot so the URL isn't eaten as the title.
|
|
19
|
+
return { bin: "cmd", args: ["/c", "start", "", url] };
|
|
20
|
+
}
|
|
21
|
+
return { bin: "xdg-open", args: [url] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function openInBrowser(url: string, platform: NodeJS.Platform = process.platform): void {
|
|
25
|
+
const { bin, args } = openCommand(url, platform);
|
|
26
|
+
const child = spawn(bin, args, { stdio: "ignore", detached: false });
|
|
27
|
+
child.on("error", () => {
|
|
28
|
+
console.log(`▸ couldn't open a browser here — open ${url} yourself`);
|
|
29
|
+
});
|
|
30
|
+
}
|
package/src/produce.ts
CHANGED
|
@@ -37,6 +37,9 @@ import {
|
|
|
37
37
|
splitCues,
|
|
38
38
|
landscapeLayout,
|
|
39
39
|
formatCutReport,
|
|
40
|
+
formatGraphicsAccounting,
|
|
41
|
+
findBloopSpans,
|
|
42
|
+
formatBloopSpan,
|
|
40
43
|
formatUsageLine,
|
|
41
44
|
formatUsageReport,
|
|
42
45
|
loadConfig,
|
|
@@ -73,6 +76,7 @@ import {
|
|
|
73
76
|
sliceTranscript,
|
|
74
77
|
type Analysis,
|
|
75
78
|
type AppliedRepair,
|
|
79
|
+
type BeatsValidationIssue,
|
|
76
80
|
type CleanupLevel,
|
|
77
81
|
type ClipWindow,
|
|
78
82
|
type LlmProvider,
|
|
@@ -125,6 +129,11 @@ export interface ProduceOptions {
|
|
|
125
129
|
coverPath?: string;
|
|
126
130
|
/** Treat the source as an already-edited reel with burned-in graphics. */
|
|
127
131
|
sourceIsEdited?: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Spoken blooper marker (R27 §122) — `--blooper-marker blooper`. Saying it
|
|
134
|
+
* on camera cuts the attempt it spoiled, back to that sentence's start.
|
|
135
|
+
*/
|
|
136
|
+
blooperMarker?: string;
|
|
128
137
|
/**
|
|
129
138
|
* How the source meets the vertical frame. `cover` (default) crops it to
|
|
130
139
|
* fill; `contain` shows the WHOLE frame inset against the backdrop, which is
|
|
@@ -206,8 +215,8 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
|
|
|
206
215
|
throw new Error("--clip-window is recorded by --clip runs for replay — pass --clip too.");
|
|
207
216
|
}
|
|
208
217
|
|
|
209
|
-
await preflight(cfg.ffmpegPath, "
|
|
210
|
-
await preflight(cfg.ffprobePath, "
|
|
218
|
+
await preflight(cfg.ffmpegPath, "Run `ossclip setup`, install ffmpeg yourself (brew/apt/winget), or set OSSCLIP_FFMPEG.");
|
|
219
|
+
await preflight(cfg.ffprobePath, "Run `ossclip setup`, install ffmpeg (provides ffprobe), or set OSSCLIP_FFPROBE.");
|
|
211
220
|
|
|
212
221
|
// The output frame — every rect downstream is a fraction of THIS, and the
|
|
213
222
|
// stage geometry now takes it as an argument rather than assuming portrait.
|
|
@@ -278,14 +287,15 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
|
|
|
278
287
|
} else {
|
|
279
288
|
await preflight(
|
|
280
289
|
cfg.whisperPath,
|
|
281
|
-
"
|
|
290
|
+
"Run `ossclip setup`, install whisper.cpp yourself (https://github.com/ggml-org/whisper.cpp), or set OSSCLIP_WHISPER.",
|
|
282
291
|
);
|
|
283
292
|
const model = opts.whisperModel ?? cfg.model;
|
|
284
293
|
const modelPath = isAbsolute(model) ? model : join(cfg.modelDir, `ggml-${model}.bin`);
|
|
285
294
|
if (!existsSync(modelPath)) {
|
|
286
295
|
throw new Error(
|
|
287
296
|
`whisper model not found at ${modelPath}.\n` +
|
|
288
|
-
`
|
|
297
|
+
`Run \`ossclip setup${model === cfg.model ? "" : ` --model ${model}`}\` to download it — or manually:\n` +
|
|
298
|
+
` curl -L -o ${modelPath} https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${model}.bin`,
|
|
289
299
|
);
|
|
290
300
|
}
|
|
291
301
|
console.log(`▸ transcribing (${basename(modelPath)})…`);
|
|
@@ -310,11 +320,25 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
|
|
|
310
320
|
// `let`, not `const` (R19 §93): a clip run re-derives all three from the
|
|
311
321
|
// transcript sliced to the chosen window, further down.
|
|
312
322
|
let analysis: Analysis = analyze(transcript, silences, sourceProbe.duration, levels);
|
|
323
|
+
// Spoken blooper markers (R27 §122). Detected on the RAW transcript, before
|
|
324
|
+
// repair — the repair pass reads a bare "blooper." as an oddity and has
|
|
325
|
+
// already been observed proposing "break loop." for it. Detecting first
|
|
326
|
+
// means the marker cannot be rewritten out from under the detector.
|
|
327
|
+
let bloops = opts.blooperMarker ? findBloopSpans(transcript, opts.blooperMarker) : [];
|
|
328
|
+
if (opts.blooperMarker) {
|
|
329
|
+
console.log(
|
|
330
|
+
bloops.length > 0
|
|
331
|
+
? `▸ blooper marker "${opts.blooperMarker}": ${bloops.length} take(s) cut`
|
|
332
|
+
: `▸ blooper marker "${opts.blooperMarker}": never said — nothing cut`,
|
|
333
|
+
);
|
|
334
|
+
for (const b of bloops) console.log(` ▸ ${formatBloopSpan(transcript, b)}`);
|
|
335
|
+
}
|
|
313
336
|
let cutlist: Segment[] = buildCutlist({
|
|
314
337
|
transcript,
|
|
315
338
|
analysis,
|
|
316
339
|
duration: sourceProbe.duration,
|
|
317
340
|
level: opts.cleanup,
|
|
341
|
+
bloops,
|
|
318
342
|
});
|
|
319
343
|
let map = new TimeMap(cutlist);
|
|
320
344
|
|
|
@@ -446,6 +470,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
|
|
|
446
470
|
let scenes: Scene[] = [];
|
|
447
471
|
/** Editorial output kept for the cover (§31): hook + its thumbnail form. */
|
|
448
472
|
let beatSheet: { hook: string; coverText?: string } | undefined;
|
|
473
|
+
/** The graphics accounting line for report.txt (§118b), and the beat-sheet
|
|
474
|
+
* issues that explain it. Cached alongside the beat sheet so a cached
|
|
475
|
+
* re-run's report keeps the accounting instead of erasing it (§78). */
|
|
476
|
+
let graphicsLine: string | undefined;
|
|
477
|
+
let beatIssues: BeatsValidationIssue[] = [];
|
|
449
478
|
/** Who planned this run (R16 §78) — stamped into production.json below. */
|
|
450
479
|
let producerStamp: Production["producer"];
|
|
451
480
|
/** The resolved `--clip` window (R19 §93) — set only on a clip run; feeds
|
|
@@ -543,12 +572,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
|
|
|
543
572
|
rawTranscript = rawSlice.transcript;
|
|
544
573
|
transcript = sliceTranscript(transcript, clipWindow);
|
|
545
574
|
analysis = analyze(rawTranscript, silences, sourceProbe.duration, levels);
|
|
575
|
+
// Re-detect on the SLICE: word indices moved, so the spans found against
|
|
576
|
+
// the full take no longer address the same words.
|
|
577
|
+
bloops = opts.blooperMarker ? findBloopSpans(rawTranscript, opts.blooperMarker) : [];
|
|
546
578
|
cutlist = boundCutlistToWindow(
|
|
547
579
|
buildCutlist({
|
|
548
580
|
transcript: rawTranscript,
|
|
549
581
|
analysis,
|
|
550
582
|
duration: sourceProbe.duration,
|
|
551
583
|
level: opts.cleanup,
|
|
584
|
+
bloops,
|
|
552
585
|
}),
|
|
553
586
|
clipWindow,
|
|
554
587
|
sourceProbe.duration,
|
|
@@ -599,13 +632,33 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
|
|
|
599
632
|
for (const issue of clipFresh.beatIssues) {
|
|
600
633
|
console.log(` ⚠ moment ${issue.moment}: ${issue.issue}`);
|
|
601
634
|
}
|
|
635
|
+
beatIssues = clipFresh.beatIssues;
|
|
636
|
+
// `transcript` is the slice here — the space the accounting was made in.
|
|
637
|
+
graphicsLine = formatGraphicsAccounting(
|
|
638
|
+
clipFresh.graphics.delivered,
|
|
639
|
+
clipFresh.graphics.asked,
|
|
640
|
+
transcript,
|
|
641
|
+
);
|
|
602
642
|
await writeFile(sceneCache, JSON.stringify(scenes, null, 2));
|
|
603
|
-
await writeFile(
|
|
643
|
+
await writeFile(
|
|
644
|
+
beatCache,
|
|
645
|
+
JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
|
|
646
|
+
);
|
|
604
647
|
} else if (existsSync(sceneCache)) {
|
|
605
648
|
scenes = z.array(SceneSchema).parse(JSON.parse(await readFile(sceneCache, "utf8")));
|
|
606
649
|
console.log(`▸ scenes cached (${scenes.length})`);
|
|
607
650
|
if (existsSync(beatCache)) {
|
|
608
|
-
|
|
651
|
+
// Pre-§118b caches carry no accounting — the report then simply
|
|
652
|
+
// omits the graphics section rather than guessing one.
|
|
653
|
+
const cached = JSON.parse(await readFile(beatCache, "utf8")) as {
|
|
654
|
+
hook: string;
|
|
655
|
+
coverText?: string;
|
|
656
|
+
graphics?: string;
|
|
657
|
+
issues?: BeatsValidationIssue[];
|
|
658
|
+
};
|
|
659
|
+
beatSheet = { hook: cached.hook, coverText: cached.coverText };
|
|
660
|
+
graphicsLine = cached.graphics;
|
|
661
|
+
beatIssues = cached.issues ?? [];
|
|
609
662
|
}
|
|
610
663
|
} else {
|
|
611
664
|
console.log(`▸ producing scenes (${providerName})…`);
|
|
@@ -629,11 +682,20 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
|
|
|
629
682
|
for (const issue of result.beatIssues) {
|
|
630
683
|
console.log(` ⚠ moment ${issue.moment}: ${issue.issue}`);
|
|
631
684
|
}
|
|
685
|
+
beatIssues = result.beatIssues;
|
|
686
|
+
graphicsLine = formatGraphicsAccounting(
|
|
687
|
+
result.graphics.delivered,
|
|
688
|
+
result.graphics.asked,
|
|
689
|
+
transcript,
|
|
690
|
+
);
|
|
632
691
|
// Cache props only — overrides are user-owned and live in overrides.json,
|
|
633
692
|
// never in production.json (that file is derived and every `produce`
|
|
634
693
|
// run overwrites it, per the merge rule in `overrides.ts`).
|
|
635
694
|
await writeFile(sceneCache, JSON.stringify(scenes, null, 2));
|
|
636
|
-
await writeFile(
|
|
695
|
+
await writeFile(
|
|
696
|
+
beatCache,
|
|
697
|
+
JSON.stringify({ ...beatSheet, graphics: graphicsLine, issues: beatIssues }, null, 2),
|
|
698
|
+
);
|
|
637
699
|
}
|
|
638
700
|
}
|
|
639
701
|
|
|
@@ -828,12 +890,23 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
|
|
|
828
890
|
// existing one — cropping through the source's title and then restating it
|
|
829
891
|
// underneath. Graphics move to a clear slot or are skipped; captions never
|
|
830
892
|
// are, they just relocate.
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
893
|
+
//
|
|
894
|
+
// Behind --source-is-edited since R27 §120. The detector cannot tell burned-in
|
|
895
|
+
// GRAPHICS from text that is simply in the room, and on a raw take at a desk
|
|
896
|
+
// it read the background monitors as 45 bands of "source text". The cost is
|
|
897
|
+
// not cosmetic: every graphic was then moved and SHRUNK to a free band —
|
|
898
|
+
// a BulletList pinned to its 36px font floor, a FlowDiagram's slot halved
|
|
899
|
+
// (0.54 → 0.27, type 71 → 35), and a ScreenshotFrame slid onto the speaker's
|
|
900
|
+
// face. Routing around a hazard only pays when there is a hazard, and only
|
|
901
|
+
// the user knows whether their source is already edited.
|
|
902
|
+
const sourceText = opts.sourceIsEdited
|
|
903
|
+
? await scanSourceText(tools, analysisInput, analysisProbe.duration, {
|
|
904
|
+
cacheDir: work,
|
|
905
|
+
assumeEdited: true,
|
|
906
|
+
cropVf: analysisCropVf,
|
|
907
|
+
cacheTag,
|
|
908
|
+
})
|
|
909
|
+
: { regions: [], assumed: false, framesSampled: 0 };
|
|
837
910
|
if (sourceText.regions.length > 0) {
|
|
838
911
|
console.log(
|
|
839
912
|
sourceText.assumed
|
|
@@ -965,7 +1038,21 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
|
|
|
965
1038
|
|
|
966
1039
|
// Grounding post-check (FINDINGS §14a): flags label tokens the take never
|
|
967
1040
|
// says — a hallucinated hook label is visible here without watching the video.
|
|
968
|
-
|
|
1041
|
+
//
|
|
1042
|
+
// Checked against the copy that will actually RENDER, overrides included
|
|
1043
|
+
// (R27 §124). It used to read the producer's raw scenes, so it was wrong in
|
|
1044
|
+
// both directions: it kept reporting invented copy the user had already
|
|
1045
|
+
// fixed by hand — the warning outlived the defect, which teaches people to
|
|
1046
|
+
// ignore warnings — and it never looked at copy the user typed themselves.
|
|
1047
|
+
// `checkGrounding` already merges a scene's `overrides` slot; nothing was
|
|
1048
|
+
// filling it from `overrides.json`.
|
|
1049
|
+
const scenesAsRendered = scenes.map((s) => {
|
|
1050
|
+
const edit = overrideDoc.scenes[s.id]?.props;
|
|
1051
|
+
return edit && Object.keys(edit).length > 0
|
|
1052
|
+
? { ...s, overrides: { ...s.overrides, ...edit } }
|
|
1053
|
+
: s;
|
|
1054
|
+
});
|
|
1055
|
+
const groundingIssues = checkGrounding(scenesAsRendered, transcript, opts.speaker ?? cfg.speaker);
|
|
969
1056
|
for (const g of groundingIssues) {
|
|
970
1057
|
console.log(` ⚠ grounding: ${g.component} ${g.sceneId} ${g.field} "${g.token}" — not in the take`);
|
|
971
1058
|
}
|
|
@@ -1050,6 +1137,14 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
|
|
|
1050
1137
|
`${((dur / sourceProbe.duration) * 100).toFixed(0)}% of the take)\n` +
|
|
1051
1138
|
` reason: ${clipWindow.reason}\n`;
|
|
1052
1139
|
}
|
|
1140
|
+
// §122: a cut that takes whole sentences owes the user the words it took —
|
|
1141
|
+
// the timestamps above say WHERE, not what was lost.
|
|
1142
|
+
if (bloops.length > 0) {
|
|
1143
|
+
report +=
|
|
1144
|
+
`\nbloopers cut (you said "${opts.blooperMarker}" — FINDINGS §122):\n` +
|
|
1145
|
+
bloops.map((b) => ` ${formatBloopSpan(rawTranscript, b)}`).join("\n") +
|
|
1146
|
+
"\n";
|
|
1147
|
+
}
|
|
1053
1148
|
const landed = repairs.filter((r) => r.applied);
|
|
1054
1149
|
if (landed.length > 0) {
|
|
1055
1150
|
report +=
|
|
@@ -1072,6 +1167,18 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<v
|
|
|
1072
1167
|
.join("\n") +
|
|
1073
1168
|
"\n";
|
|
1074
1169
|
}
|
|
1170
|
+
// §118b: the graphics count justified in the artefact, like every cut is —
|
|
1171
|
+
// delivered vs asked and why, then the scheduler's own account of what it
|
|
1172
|
+
// demoted. The shortfall issue repeats the accounting line, so it is the
|
|
1173
|
+
// one issue not reprinted here.
|
|
1174
|
+
if (graphicsLine) {
|
|
1175
|
+
report +=
|
|
1176
|
+
`\n${graphicsLine} (FINDINGS §118)\n` +
|
|
1177
|
+
beatIssues
|
|
1178
|
+
.filter((i) => !i.issue.startsWith("graphics:"))
|
|
1179
|
+
.map((i) => ` ⚠ moment ${i.moment}: ${i.issue}\n`)
|
|
1180
|
+
.join("");
|
|
1181
|
+
}
|
|
1075
1182
|
if (provider) {
|
|
1076
1183
|
report += formatUsageReport(provider.usage, cfg.pricing);
|
|
1077
1184
|
// A cached run has no usage block to print, and used to leave the report
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream, createWriteStream, mkdirSync, renameSync, statSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Download with resume + integrity check — the model is a 466 MB file and
|
|
7
|
+
* "my wifi dropped at 91%" must not mean starting over.
|
|
8
|
+
*
|
|
9
|
+
* Writes `<dest>.part`, resumes with a Range header when a partial exists
|
|
10
|
+
* (GitHub and Hugging Face both honor ranges; a server that answers 200
|
|
11
|
+
* instead of 206 restarts cleanly), verifies the hash of the COMPLETE file
|
|
12
|
+
* before renaming into place — a `.part` never becomes a `dest` unverified.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface DownloadOptions {
|
|
16
|
+
/** hex digest; algorithm chosen by which field is set */
|
|
17
|
+
sha256?: string;
|
|
18
|
+
sha1?: string;
|
|
19
|
+
onProgress?: (doneBytes: number, totalBytes: number | null) => void;
|
|
20
|
+
/** injected in tests */
|
|
21
|
+
fetchImpl?: typeof fetch;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function download(url: string, dest: string, opts: DownloadOptions = {}): Promise<void> {
|
|
25
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
26
|
+
const part = `${dest}.part`;
|
|
27
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
28
|
+
|
|
29
|
+
let offset = 0;
|
|
30
|
+
try {
|
|
31
|
+
offset = statSync(part).size;
|
|
32
|
+
} catch {
|
|
33
|
+
// no partial — fresh download
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const headers: Record<string, string> = {};
|
|
37
|
+
if (offset > 0) headers.Range = `bytes=${offset}-`;
|
|
38
|
+
|
|
39
|
+
const res = await fetchImpl(url, { headers, redirect: "follow" });
|
|
40
|
+
if (res.status === 200) {
|
|
41
|
+
offset = 0; // server ignored the range — start over
|
|
42
|
+
} else if (res.status !== 206) {
|
|
43
|
+
throw new Error(`download failed: HTTP ${res.status} for ${url}`);
|
|
44
|
+
}
|
|
45
|
+
if (!res.body) throw new Error(`download failed: empty body for ${url}`);
|
|
46
|
+
|
|
47
|
+
const lengthHeader = res.headers.get("content-length");
|
|
48
|
+
const total = lengthHeader ? offset + Number.parseInt(lengthHeader, 10) : null;
|
|
49
|
+
|
|
50
|
+
const out = createWriteStream(part, offset > 0 ? { flags: "a" } : {});
|
|
51
|
+
let done = offset;
|
|
52
|
+
const reader = res.body.getReader();
|
|
53
|
+
try {
|
|
54
|
+
for (;;) {
|
|
55
|
+
const { value, done: finished } = await reader.read();
|
|
56
|
+
if (finished) break;
|
|
57
|
+
if (value) {
|
|
58
|
+
done += value.length;
|
|
59
|
+
if (!out.write(value)) {
|
|
60
|
+
await new Promise<void>((r) => out.once("drain", () => r()));
|
|
61
|
+
}
|
|
62
|
+
opts.onProgress?.(done, total);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
await new Promise<void>((r) => out.end(() => r()));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const algo = opts.sha256 ? "sha256" : opts.sha1 ? "sha1" : null;
|
|
70
|
+
const expected = opts.sha256 ?? opts.sha1;
|
|
71
|
+
if (algo && expected) {
|
|
72
|
+
const actual = await hashFile(part, algo);
|
|
73
|
+
if (actual !== expected.toLowerCase()) {
|
|
74
|
+
// A corrupt partial would resume corrupt forever — a mismatch removes it.
|
|
75
|
+
const { rmSync } = await import("node:fs");
|
|
76
|
+
rmSync(part, { force: true });
|
|
77
|
+
throw new Error(
|
|
78
|
+
`checksum mismatch for ${url}\n expected ${algo} ${expected}\n got ${actual}\n` +
|
|
79
|
+
"The partial download was removed — re-run to try again.",
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
renameSync(part, dest);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function hashFile(path: string, algo: "sha256" | "sha1"): Promise<string> {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
const hash = createHash(algo);
|
|
89
|
+
createReadStream(path)
|
|
90
|
+
.on("data", (chunk) => hash.update(chunk))
|
|
91
|
+
.on("error", reject)
|
|
92
|
+
.on("end", () => resolve(hash.digest("hex")));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Single rewritten stderr line: `▸ ggml-small.en.bin 312/466 MB 67%`.
|
|
98
|
+
* Piped output (CI logs) gets a plain line every 10% instead — `\r` doesn't
|
|
99
|
+
* rewrite in a captured log, it accumulates.
|
|
100
|
+
*/
|
|
101
|
+
export function progressLine(label: string): (done: number, total: number | null) => void {
|
|
102
|
+
const tty = process.stderr.isTTY === true;
|
|
103
|
+
const stepPct = tty ? 1 : 10;
|
|
104
|
+
let lastPct = -1;
|
|
105
|
+
let lastMB = -1;
|
|
106
|
+
return (done, total) => {
|
|
107
|
+
const doneMB = Math.round(done / 1e6);
|
|
108
|
+
if (total === null) {
|
|
109
|
+
if (doneMB !== lastMB && doneMB % 25 === 0) {
|
|
110
|
+
lastMB = doneMB;
|
|
111
|
+
process.stderr.write(`${tty ? "\r" : ""}▸ ${label} ${doneMB} MB${tty ? "" : "\n"}`);
|
|
112
|
+
}
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const pct = Math.floor((done / total) * 100);
|
|
116
|
+
if (pct === lastPct || pct % stepPct !== 0) return;
|
|
117
|
+
lastPct = pct;
|
|
118
|
+
const totalMB = Math.round(total / 1e6);
|
|
119
|
+
const line = `▸ ${label} ${doneMB}/${totalMB} MB ${pct}%`;
|
|
120
|
+
process.stderr.write(tty ? `\r${line}${pct === 100 ? "\n" : ""}` : `${line}\n`);
|
|
121
|
+
};
|
|
122
|
+
}
|