ossclip 0.1.24 → 0.1.26
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-Bx2VQLP8.css +1 -0
- package/editor-dist/assets/index-DVI51_2u.js +163 -0
- package/editor-dist/index.html +2 -2
- package/package.json +4 -4
- package/src/analyze.ts +17 -1
- package/src/caption-report.ts +141 -3
- package/src/doctor.ts +7 -5
- package/src/edit.ts +571 -3
- package/src/interactive/ask-input.ts +11 -3
- package/src/interactive/pick-save-path.ts +214 -0
- package/src/interactive/produce-argv.ts +39 -2
- package/src/interactive/produce-wizard.ts +236 -35
- package/src/interactive/thumbnail-approve.ts +217 -0
- package/src/open.ts +64 -8
- package/src/paths.ts +88 -0
- package/src/portrait-override.ts +86 -0
- package/src/produce.ts +1971 -161
- package/src/program.ts +142 -6
- package/src/replay-argv.ts +75 -0
- package/src/setup/manifest.ts +93 -4
- package/src/setup/plan.ts +17 -6
- package/src/setup/setup.ts +28 -7
- package/src/thumbnail-panel.ts +167 -0
- package/src/ui/animation.ts +10 -0
- package/editor-dist/assets/index-ChRVBVLj.css +0 -1
- package/editor-dist/assets/index-MLGz89mM.js +0 -163
package/src/program.ts
CHANGED
|
@@ -7,8 +7,9 @@ import { CleanupLevelSchema, SceneComponentIdSchema } from "@ossclip/core";
|
|
|
7
7
|
import { STUDIO_ENTRY } from "@ossclip/renderer";
|
|
8
8
|
import { loadEnvFiles } from "./env";
|
|
9
9
|
import { ExportFormatSchema, runAnalyze } from "./analyze";
|
|
10
|
+
import { expandHome } from "./paths";
|
|
10
11
|
import { phaseBucketProps } from "./phase-timing";
|
|
11
|
-
import { produce } from "./produce";
|
|
12
|
+
import { dictionaryFlag, jumpCutsFlag, produce } from "./produce";
|
|
12
13
|
// The one interactive import that is STATIC rather than `await import()`: the
|
|
13
14
|
// `resetInputSource()` run boundary in `buildProgram` has to run synchronously
|
|
14
15
|
// while the program is being built, and `buildProgram` cannot await. The graph
|
|
@@ -38,6 +39,27 @@ import {
|
|
|
38
39
|
// order in `defaultProviderName`, which decides which model runs.
|
|
39
40
|
const envFiles = loadEnvFiles();
|
|
40
41
|
|
|
42
|
+
/**
|
|
43
|
+
* `--concurrency <n>` → a positive whole number of browser tabs (§93a: reject
|
|
44
|
+
* rather than coerce, the `--clip` idiom). A typo'd `--concurrency 4x` must
|
|
45
|
+
* not become NaN and reach Remotion as "however many you like" — the flag
|
|
46
|
+
* exists precisely because the automatic count killed a browser (2026-08-19
|
|
47
|
+
* field case; `resolveRenderConcurrency` has it). `Number`, not `parseInt`,
|
|
48
|
+
* so "4.5" and "" are errors rather than a silent 4 and a silent 0.
|
|
49
|
+
*
|
|
50
|
+
* Exported so the rejection matrix is testable without commander's exit
|
|
51
|
+
* behaviour in the way.
|
|
52
|
+
*/
|
|
53
|
+
export function concurrencyFlag(v: string): number {
|
|
54
|
+
const n = Number(v);
|
|
55
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
56
|
+
throw new InvalidArgumentError(
|
|
57
|
+
`--concurrency wants a positive whole number of browser tabs, got "${v}"`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return n;
|
|
61
|
+
}
|
|
62
|
+
|
|
41
63
|
/**
|
|
42
64
|
* Every command this CLI has, built onto a fresh instance.
|
|
43
65
|
*
|
|
@@ -160,6 +182,11 @@ export function buildProgram(): Command {
|
|
|
160
182
|
modelDir: cfg.modelDir,
|
|
161
183
|
input: path,
|
|
162
184
|
watermark: cfg.watermark,
|
|
185
|
+
// The youtube follow-ups skip what the config already supplies
|
|
186
|
+
// (youtubeFollowups) — same reason speaker prefills above.
|
|
187
|
+
audience: cfg.audience,
|
|
188
|
+
portrait: cfg.portrait,
|
|
189
|
+
thumbnailBrief: cfg.thumbnailBrief,
|
|
163
190
|
});
|
|
164
191
|
console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
|
|
165
192
|
setReplayArgv(argv); // §129
|
|
@@ -195,6 +222,9 @@ export function buildProgram(): Command {
|
|
|
195
222
|
speaker: cfg.speaker,
|
|
196
223
|
modelDir: cfg.modelDir,
|
|
197
224
|
watermark: cfg.watermark,
|
|
225
|
+
audience: cfg.audience,
|
|
226
|
+
portrait: cfg.portrait,
|
|
227
|
+
thumbnailBrief: cfg.thumbnailBrief,
|
|
198
228
|
});
|
|
199
229
|
console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
|
|
200
230
|
setReplayArgv(argv); // §129
|
|
@@ -286,6 +316,17 @@ export function buildProgram(): Command {
|
|
|
286
316
|
"--whisper-language <code>",
|
|
287
317
|
"transcription language code for a multilingual model, e.g. ur | de | auto (whisper defaults to en)",
|
|
288
318
|
)
|
|
319
|
+
// COMMA-SEPARATED in one value, not variadic: a variadic option swallows
|
|
320
|
+
// the optional positional [input] whenever the flag precedes the path,
|
|
321
|
+
// and commander offers no way to give the positional priority.
|
|
322
|
+
.option(
|
|
323
|
+
"--dictionary <terms>",
|
|
324
|
+
'comma-separated terms of art the speaker uses, e.g. "JSON, ossclip, Genkit" — ' +
|
|
325
|
+
"biases transcription toward these spellings, vouches them for repair, and " +
|
|
326
|
+
"canonicalizes their casing in captions. Replaces the config's dictionary for " +
|
|
327
|
+
"this run. Needs a whisper-cli new enough to know --prompt (older builds " +
|
|
328
|
+
"reject it with their own error)",
|
|
329
|
+
)
|
|
289
330
|
.option(
|
|
290
331
|
"--force-component <id>",
|
|
291
332
|
"debug: render every graphic with this component (e.g. FlowDiagram) to exercise it on real copy",
|
|
@@ -308,8 +349,9 @@ export function buildProgram(): Command {
|
|
|
308
349
|
)
|
|
309
350
|
.option(
|
|
310
351
|
"--collapse-retakes",
|
|
311
|
-
"
|
|
312
|
-
"
|
|
352
|
+
"legacy no-op: retake collapsing runs automatically with --blooper-marker " +
|
|
353
|
+
"(bloopers and retakes go hand-in-hand — no marker, no retake cuts). " +
|
|
354
|
+
"Kept parseable so recorded command.json replays don't error",
|
|
313
355
|
false,
|
|
314
356
|
)
|
|
315
357
|
// Declared as the same tri-state pair as --open-editor/--no-open-editor:
|
|
@@ -323,6 +365,35 @@ export function buildProgram(): Command {
|
|
|
323
365
|
)
|
|
324
366
|
.option("--no-watermark", "no wordmark, even when the config turns it on")
|
|
325
367
|
// Same tri-state shape as --watermark above (positive declared first so
|
|
368
|
+
// commander's default stays undefined = "not typed"): the config's
|
|
369
|
+
// `youtube` key supplies the default (resolveYoutube), and a typed
|
|
370
|
+
// --no-youtube still beats a config-on.
|
|
371
|
+
.option(
|
|
372
|
+
"--youtube",
|
|
373
|
+
"write a YouTube pack beside the video: SEO title options, description, hashtags " +
|
|
374
|
+
"and comma-separated tags (<out>.youtube.md), plus an AI thumbnail " +
|
|
375
|
+
"(set it once with youtube: true in ~/.ossclip/config.json)",
|
|
376
|
+
)
|
|
377
|
+
.option("--no-youtube", "no YouTube pack, even when the config turns it on")
|
|
378
|
+
.option(
|
|
379
|
+
"--portrait <path>",
|
|
380
|
+
"your portrait photo, the likeness reference for the --youtube AI thumbnail " +
|
|
381
|
+
"(default: `portrait` in ~/.ossclip/config.json; without one the frame-grab " +
|
|
382
|
+
"cover stands)",
|
|
383
|
+
)
|
|
384
|
+
.option(
|
|
385
|
+
"--audience <text>",
|
|
386
|
+
'who watches the channel, e.g. "junior web devs learning AI tooling" — steers ' +
|
|
387
|
+
"the --youtube pack's titles/tags and the AI thumbnail's concept " +
|
|
388
|
+
"(default: `audience` in ~/.ossclip/config.json)",
|
|
389
|
+
)
|
|
390
|
+
.option(
|
|
391
|
+
"--thumbnail-brief <text>",
|
|
392
|
+
"a standing instruction the AI thumbnail concept must honor, e.g. " +
|
|
393
|
+
'"always show the terminal, never stock imagery" ' +
|
|
394
|
+
"(default: `thumbnailBrief` in ~/.ossclip/config.json)",
|
|
395
|
+
)
|
|
396
|
+
// Same tri-state shape as --watermark above (positive declared first so
|
|
326
397
|
// commander's default stays undefined = "not typed"), though captions
|
|
327
398
|
// have no config key to fill the gap: the tri-state exists so
|
|
328
399
|
// command.json can pin the resolved flag state for replay determinism
|
|
@@ -336,6 +407,24 @@ export function buildProgram(): Command {
|
|
|
336
407
|
"--no-captions",
|
|
337
408
|
"no burned-in captions. The CTA keyword styling rides the caption track, so it goes too",
|
|
338
409
|
)
|
|
410
|
+
// A tri-state like --watermark/--captions above, but on TWO commander
|
|
411
|
+
// keys instead of one: the positive is spelled --add-jump-cuts (bare
|
|
412
|
+
// "--jump-cuts" would read as adding CUTS, not the concealing zooms), so
|
|
413
|
+
// commander cannot fold the pair onto one key — --no-jump-cuts creates
|
|
414
|
+
// `jumpCuts` defaulting TRUE, --add-jump-cuts lands on `addJumpCuts`,
|
|
415
|
+
// and the action below reunites them via jumpCutsFlag, reading
|
|
416
|
+
// getOptionValueSource to tell a typed --no-jump-cuts from the default.
|
|
417
|
+
.option(
|
|
418
|
+
"--add-jump-cuts",
|
|
419
|
+
"force the subtle punch-in zooms that conceal jump cuts (already the default). " +
|
|
420
|
+
"The face-only guard still applies — a screen share is never punched, because " +
|
|
421
|
+
"the zoom slides its content — this only beats a config that turns them off",
|
|
422
|
+
)
|
|
423
|
+
.option(
|
|
424
|
+
"--no-jump-cuts",
|
|
425
|
+
"no punch-in zooms at cut boundaries. Narrower than --no-zoom, which kills ALL " +
|
|
426
|
+
"camera motion (the idle push included), not just the cut punch-in",
|
|
427
|
+
)
|
|
339
428
|
.option("--no-cover", "skip the cover image written beside the video")
|
|
340
429
|
.option(
|
|
341
430
|
"--no-zoom",
|
|
@@ -350,6 +439,20 @@ export function buildProgram(): Command {
|
|
|
350
439
|
)
|
|
351
440
|
.option("--editor-port <n>", "port for the editor started by --open-editor",
|
|
352
441
|
(v) => Number.parseInt(v, 10), 5174)
|
|
442
|
+
// No default: undefined = "not typed" is what lets the config's
|
|
443
|
+
// renderConcurrency (and then the cpus-2 guess) supply the value —
|
|
444
|
+
// resolveRenderConcurrency owns the precedence. Recorded runs need nothing
|
|
445
|
+
// special to replay it: command.json stores the argv verbatim
|
|
446
|
+
// (recordedProduceArgs), so a typed --concurrency is already in there, and
|
|
447
|
+
// the editor's Render replays it through THIS parse.
|
|
448
|
+
.option(
|
|
449
|
+
"--concurrency <n>",
|
|
450
|
+
"how many browser tabs render frames in parallel (default: CPU cores - 2, " +
|
|
451
|
+
"floor 2). Turn it DOWN if the render logs 'The browser crashed while " +
|
|
452
|
+
"rendering frame N' — that is the whole browser running out of memory, " +
|
|
453
|
+
"not one frame failing",
|
|
454
|
+
concurrencyFlag,
|
|
455
|
+
)
|
|
353
456
|
.action(async (input: string | undefined, opts, command: Command) => {
|
|
354
457
|
if (input === undefined) {
|
|
355
458
|
// commander 12's parseAsync does not reset option state between calls,
|
|
@@ -377,6 +480,9 @@ export function buildProgram(): Command {
|
|
|
377
480
|
speaker: cfg.speaker,
|
|
378
481
|
modelDir: cfg.modelDir,
|
|
379
482
|
watermark: cfg.watermark,
|
|
483
|
+
audience: cfg.audience,
|
|
484
|
+
portrait: cfg.portrait,
|
|
485
|
+
thumbnailBrief: cfg.thumbnailBrief,
|
|
380
486
|
});
|
|
381
487
|
console.log(`\n▸ running:\n ${renderCommand(argv)}\n`);
|
|
382
488
|
// Re-entering the SAME parse the flags take: the zod checks below run
|
|
@@ -413,6 +519,15 @@ export function buildProgram(): Command {
|
|
|
413
519
|
opts.whisperLanguage !== undefined
|
|
414
520
|
? z.string().trim().min(1, "--whisper-language needs a code, e.g. ur").parse(opts.whisperLanguage)
|
|
415
521
|
: undefined;
|
|
522
|
+
// --add-jump-cuts / --no-jump-cuts land on DIFFERENT commander keys
|
|
523
|
+
// (see the option declarations for why the pair can't share one);
|
|
524
|
+
// jumpCutsFlag reunites them into the tri-state ProduceOptions
|
|
525
|
+
// carries, and throws on the contradiction of typing both — the same
|
|
526
|
+
// loud-error posture as every parse above.
|
|
527
|
+
const jumpCuts = jumpCutsFlag(
|
|
528
|
+
opts.addJumpCuts,
|
|
529
|
+
command.getOptionValueSource("jumpCuts") === "cli",
|
|
530
|
+
);
|
|
416
531
|
// Wall clock around produce() only — the editor offer below can sit at
|
|
417
532
|
// an interactive prompt for as long as the user thinks, and think-time
|
|
418
533
|
// would poison the duration metric (FINDINGS §134).
|
|
@@ -439,6 +554,9 @@ export function buildProgram(): Command {
|
|
|
439
554
|
repair: opts.repair,
|
|
440
555
|
whisperModel: opts.whisperModel,
|
|
441
556
|
whisperLanguage,
|
|
557
|
+
// Split/trim/drop-empties (dictionaryFlag) — undefined stays
|
|
558
|
+
// undefined so the config's dictionary can supply the default.
|
|
559
|
+
dictionary: dictionaryFlag(opts.dictionary),
|
|
442
560
|
forceComponent,
|
|
443
561
|
// commander gives `--no-cover` as cover:false and `--cover <path>` as a
|
|
444
562
|
// string on the same key.
|
|
@@ -450,13 +568,29 @@ export function buildProgram(): Command {
|
|
|
450
568
|
zoom: opts.zoom,
|
|
451
569
|
// undefined = "not typed", so produce can let the config decide.
|
|
452
570
|
watermark: opts.watermark,
|
|
571
|
+
// The same tri-state contract as watermark, resolved by
|
|
572
|
+
// resolveYoutube at the use site; --portrait rides along untyped =
|
|
573
|
+
// undefined so the config's path can supply it.
|
|
574
|
+
youtube: opts.youtube,
|
|
575
|
+
portrait: opts.portrait,
|
|
576
|
+
// Typed-beats-config strings like --portrait: untyped = undefined
|
|
577
|
+
// lets the config's `audience`/`thumbnailBrief` supply them; the
|
|
578
|
+
// `typeof === "string"` validation lives at the use site.
|
|
579
|
+
audience: opts.audience,
|
|
580
|
+
thumbnailBrief: opts.thumbnailBrief,
|
|
453
581
|
// undefined = "not typed" here too — the default (ON) is applied at
|
|
454
582
|
// the pin site, not coerced in transit.
|
|
455
583
|
captions: opts.captions,
|
|
584
|
+
// The reunited tri-state (jumpCutsFlag above): undefined = "not
|
|
585
|
+
// typed" = auto, the face-only default.
|
|
586
|
+
jumpCuts,
|
|
456
587
|
cover: opts.cover !== false,
|
|
457
588
|
coverPath: typeof opts.cover === "string" ? opts.cover : undefined,
|
|
458
589
|
clip: opts.clip,
|
|
459
590
|
clipWindow: opts.clipWindow,
|
|
591
|
+
// Validated by concurrencyFlag at parse time; undefined = "not
|
|
592
|
+
// typed", which is what lets the config supply it.
|
|
593
|
+
concurrency: opts.concurrency,
|
|
460
594
|
});
|
|
461
595
|
// Counts, buckets and names only — the duration crosses the wire as a
|
|
462
596
|
// bucket, and nothing here can carry a path (assertSafeProps enforces
|
|
@@ -576,7 +710,7 @@ export function buildProgram(): Command {
|
|
|
576
710
|
)
|
|
577
711
|
.option(
|
|
578
712
|
"--collapse-retakes",
|
|
579
|
-
"
|
|
713
|
+
"legacy no-op: retake marking runs automatically with --blooper-marker",
|
|
580
714
|
false,
|
|
581
715
|
)
|
|
582
716
|
.option("--sort <order>", "folder input: clip order, name | mtime", "name")
|
|
@@ -630,8 +764,10 @@ export function buildProgram(): Command {
|
|
|
630
764
|
.argument("<renderProps>", "path to a work dir's render-props.json")
|
|
631
765
|
.option("--video-dir <dir>", "directory containing the source video (public dir)")
|
|
632
766
|
.action(async (renderProps: string, opts) => {
|
|
633
|
-
|
|
634
|
-
|
|
767
|
+
// expandHome before resolve on both user-typed paths (2026-08-16 rule,
|
|
768
|
+
// paths.ts) — a `~/` here must not resolve against cwd.
|
|
769
|
+
const propsPath = resolve(expandHome(renderProps));
|
|
770
|
+
const publicDir = opts.videoDir ? resolve(expandHome(opts.videoDir)) : dirname(propsPath);
|
|
635
771
|
// Resolve Remotion's CLI through module resolution instead of spawning
|
|
636
772
|
// `pnpm` — a global `npm i -g ossclip` has no pnpm and no workspace, and
|
|
637
773
|
// Windows would need the .cmd shim. `@remotion/cli` is a dependency of
|
package/src/replay-argv.ts
CHANGED
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
* the direct path byte-identical to what it always wrote.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
// Type-only, so no runtime edge back into produce.ts (which imports this
|
|
21
|
+
// module): the tri-state's vocabulary belongs to its resolver.
|
|
22
|
+
import type { JumpCutsMode } from "./produce";
|
|
23
|
+
|
|
20
24
|
let stashed: string[] | null = null;
|
|
21
25
|
|
|
22
26
|
/**
|
|
@@ -53,6 +57,16 @@ export function recordedProduceArgs(pins: {
|
|
|
53
57
|
clipWindow?: string;
|
|
54
58
|
watermark?: boolean;
|
|
55
59
|
captions?: boolean;
|
|
60
|
+
jumpCuts?: JumpCutsMode;
|
|
61
|
+
/** The RESOLVED dictionary terms — pinned only when non-empty. */
|
|
62
|
+
dictionary?: string[];
|
|
63
|
+
youtube?: boolean;
|
|
64
|
+
/** The RESOLVED portrait path — a path, never a secret. */
|
|
65
|
+
portrait?: string;
|
|
66
|
+
/** The RESOLVED audience text — pinned only when non-empty. */
|
|
67
|
+
audience?: string;
|
|
68
|
+
/** The RESOLVED thumbnail brief — pinned only when non-empty. */
|
|
69
|
+
thumbnailBrief?: string;
|
|
56
70
|
}): string[] {
|
|
57
71
|
const args = consumeReplayArgv() ?? process.argv.slice(2);
|
|
58
72
|
if (pins.llm !== undefined && !args.includes("--llm")) {
|
|
@@ -89,5 +103,66 @@ export function recordedProduceArgs(pins: {
|
|
|
89
103
|
if (pins.captions !== undefined && !args.includes("--captions") && !args.includes("--no-captions")) {
|
|
90
104
|
args.push(pins.captions ? "--captions" : "--no-captions");
|
|
91
105
|
}
|
|
106
|
+
// The jump-cuts pin covers the two TYPED states only — force spells
|
|
107
|
+
// --add-jump-cuts, off spells --no-jump-cuts, and either typed flag
|
|
108
|
+
// settles the tri-state, so the includes-guard checks BOTH spellings
|
|
109
|
+
// before appending either. "auto" stays UNPINNED, which is the captions
|
|
110
|
+
// rationale run in reverse: there is no flag that SPELLS auto to pin
|
|
111
|
+
// with, and with no config input today an argv carrying neither flag
|
|
112
|
+
// replays as auto identically everywhere. The captions comment's warning
|
|
113
|
+
// still applies — the day a jumpCuts config key lands, auto records made
|
|
114
|
+
// after it must pin their resolved on/off like the watermark's, and the
|
|
115
|
+
// old unpinned auto records are the accepted cost of a flag pair that
|
|
116
|
+
// reserves both spellings for the typed states.
|
|
117
|
+
if (
|
|
118
|
+
pins.jumpCuts !== undefined &&
|
|
119
|
+
pins.jumpCuts !== "auto" &&
|
|
120
|
+
!args.includes("--add-jump-cuts") &&
|
|
121
|
+
!args.includes("--no-jump-cuts")
|
|
122
|
+
) {
|
|
123
|
+
args.push(pins.jumpCuts === "force" ? "--add-jump-cuts" : "--no-jump-cuts");
|
|
124
|
+
}
|
|
125
|
+
// The dictionary pin (review finding, F4 follow-up): the resolved terms may
|
|
126
|
+
// have come from ~/.ossclip/config.json, and the dictionary feeds the
|
|
127
|
+
// whisper prompt, the repair vouched set and caption casing — so an
|
|
128
|
+
// unpinned record replays a DIFFERENT transcript the moment that config is
|
|
129
|
+
// edited. Comma-joined into one value, the exact spelling `--dictionary`
|
|
130
|
+
// takes (dictionaryFlag re-splits and trims on replay). Empty stays
|
|
131
|
+
// unpinned: there is no flag spelling for "no terms", and `--dictionary ""`
|
|
132
|
+
// would split to [] anyway — an argv without the flag replays as "config
|
|
133
|
+
// decides", the accepted cost mirroring jump-cuts' unpinnable auto.
|
|
134
|
+
if (pins.dictionary !== undefined && pins.dictionary.length > 0 && !args.includes("--dictionary")) {
|
|
135
|
+
args.push("--dictionary", pins.dictionary.join(", "));
|
|
136
|
+
}
|
|
137
|
+
// The youtube pin — the watermark's rationale verbatim: its effective
|
|
138
|
+
// default is config-dependent (`youtube: true` in ~/.ossclip/config.json),
|
|
139
|
+
// so every record carries the RESOLVED state in BOTH directions, or a
|
|
140
|
+
// later config edit silently changes what the editor's Render writes
|
|
141
|
+
// beside the replayed video.
|
|
142
|
+
if (pins.youtube !== undefined && !args.includes("--youtube") && !args.includes("--no-youtube")) {
|
|
143
|
+
args.push(pins.youtube ? "--youtube" : "--no-youtube");
|
|
144
|
+
}
|
|
145
|
+
// The portrait pin: the resolved PATH (never a secret — the API key stays
|
|
146
|
+
// in the environment), so a replay renders the thumbnail from the same
|
|
147
|
+
// face the run did even after the config's `portrait` moves.
|
|
148
|
+
if (pins.portrait !== undefined && !args.includes("--portrait")) {
|
|
149
|
+
args.push("--portrait", pins.portrait);
|
|
150
|
+
}
|
|
151
|
+
// Audience and thumbnail-brief pins, the portrait's rationale exactly: the
|
|
152
|
+
// resolved values may have come from ~/.ossclip/config.json, and both steer
|
|
153
|
+
// LLM prompts (the youtube pack, the thumbnail concept) — an unpinned
|
|
154
|
+
// record would replay different metadata after a config edit. Empty stays
|
|
155
|
+
// unpinned, the dictionary's rule: there is no flag spelling for "no
|
|
156
|
+
// steer", and an argv without the flag replays as "config decides".
|
|
157
|
+
if (pins.audience !== undefined && pins.audience.length > 0 && !args.includes("--audience")) {
|
|
158
|
+
args.push("--audience", pins.audience);
|
|
159
|
+
}
|
|
160
|
+
if (
|
|
161
|
+
pins.thumbnailBrief !== undefined &&
|
|
162
|
+
pins.thumbnailBrief.length > 0 &&
|
|
163
|
+
!args.includes("--thumbnail-brief")
|
|
164
|
+
) {
|
|
165
|
+
args.push("--thumbnail-brief", pins.thumbnailBrief);
|
|
166
|
+
}
|
|
92
167
|
return args;
|
|
93
168
|
}
|
package/src/setup/manifest.ts
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
* platform×arch resolves to either an asset or an explicit manual hint.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
17
|
+
import { z } from "zod/v4";
|
|
18
|
+
|
|
16
19
|
export interface BinaryAsset {
|
|
17
20
|
url: string;
|
|
18
21
|
sha256: string;
|
|
@@ -135,10 +138,24 @@ export function whisperAsset(platform: NodeJS.Platform, arch: string): BinaryAss
|
|
|
135
138
|
* models/README.md — upstream publishes SHA-1, so that's what we verify;
|
|
136
139
|
* it's an integrity check against truncated downloads, not a security
|
|
137
140
|
* boundary (the download is already pinned to a host and path over HTTPS).
|
|
141
|
+
*
|
|
142
|
+
* Curated fine-tunes ride the same table: `url` points at their own host
|
|
143
|
+
* (ggerganov's mirror only carries the stock models — the old hardcoded URL
|
|
144
|
+
* 404'd for every custom name, and the suggested `curl -L` then saved the
|
|
145
|
+
* 404 HTML as a fake model), and `language` records what the fine-tune
|
|
146
|
+
* decodes so produce can imply `-l` when nothing else sets one (an Urdu
|
|
147
|
+
* fine-tune without `-l ur` silently decodes English garbage — Urdu field
|
|
148
|
+
* test 2026-08-05).
|
|
138
149
|
*/
|
|
139
150
|
export interface ModelInfo {
|
|
140
|
-
sizeMB
|
|
141
|
-
sha1
|
|
151
|
+
sizeMB?: number;
|
|
152
|
+
sha1?: string;
|
|
153
|
+
/** Direct download URL for models the ggerganov mirror doesn't host. */
|
|
154
|
+
url?: string;
|
|
155
|
+
/** The language the fine-tune decodes — implied `-l` when neither flag nor config sets one. */
|
|
156
|
+
language?: string;
|
|
157
|
+
/** Provenance, printed by setup at download time and shown as the wizard hint. */
|
|
158
|
+
note?: string;
|
|
142
159
|
}
|
|
143
160
|
|
|
144
161
|
export const MODELS: Record<string, ModelInfo> = {
|
|
@@ -146,10 +163,82 @@ export const MODELS: Record<string, ModelInfo> = {
|
|
|
146
163
|
"base.en": { sizeMB: 142, sha1: "137c40403d78fd54d454da0f9bd998f78703390c" },
|
|
147
164
|
"small.en": { sizeMB: 466, sha1: "db8a495a91d927739e50b3fc1cc4c6b8f6c2d022" },
|
|
148
165
|
"medium.en": { sizeMB: 1536, sha1: "8c30f0e44ce9560643ebd10bbe50cd20eafd3723" },
|
|
166
|
+
// URL pending the author's upload (2026-08-17) — sha1 added when the file
|
|
167
|
+
// is published; setup already warns-and-continues without a checksum.
|
|
168
|
+
"medium-urdu": {
|
|
169
|
+
sizeMB: 1463,
|
|
170
|
+
// sha1 computed 2026-08-17 from the author's converted file BEFORE the HF
|
|
171
|
+
// upload — same bytes, so the pin is valid the moment the file publishes,
|
|
172
|
+
// and a corrupted/tampered mirror download fails the checksum loudly.
|
|
173
|
+
sha1: "59769d590f62eeeb3bc3f5b82ce8c03b6e96831e",
|
|
174
|
+
language: "ur",
|
|
175
|
+
note: "community Urdu fine-tune (Abdul145/whisper-medium-urdu-custom, Apache-2.0), converted to GGML",
|
|
176
|
+
url: "https://huggingface.co/CodeWithAhsan/whisper-medium-urdu-ggml/resolve/main/ggml-medium-urdu.bin",
|
|
177
|
+
},
|
|
149
178
|
};
|
|
150
179
|
|
|
151
|
-
|
|
152
|
-
|
|
180
|
+
/**
|
|
181
|
+
* A model pick reduced to its bare name: basename, minus the optional ggml-
|
|
182
|
+
* prefix and .bin suffix. Exists because classifying on the raw value
|
|
183
|
+
* misreads paths — an absolute /x/ggml-small.en.bin ends in ".bin", so the
|
|
184
|
+
* wizard's `.endsWith(".en")` language heuristic prefilled `auto` for an
|
|
185
|
+
* ENGLISH model (review fix, Urdu field test 2026-08-05) — and the MODELS
|
|
186
|
+
* lookups below must find `medium-urdu` inside either spelling.
|
|
187
|
+
*/
|
|
188
|
+
export function bareWhisperModelName(nameOrPath: string): string {
|
|
189
|
+
const base = basename(nameOrPath);
|
|
190
|
+
const m = /^(?:ggml-)?(.+?)(?:\.bin)?$/.exec(base);
|
|
191
|
+
return m?.[1] ?? base;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The language a model pick implies when the user set none: the curated
|
|
196
|
+
* table's `language`, keyed on the bare name so `--whisper-model medium-urdu`
|
|
197
|
+
* and an absolute /x/ggml-medium-urdu.bin both resolve it. Undefined for
|
|
198
|
+
* stock models and unknown fine-tunes — whisper's own en default stands.
|
|
199
|
+
*/
|
|
200
|
+
export function modelImpliedLanguage(nameOrPath: string): string | undefined {
|
|
201
|
+
return MODELS[bareWhisperModelName(nameOrPath)]?.language;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* THE model-download URL — the single source produce's missing-model error,
|
|
206
|
+
* doctor's fix line, and setup's download all read (they used to hold string
|
|
207
|
+
* dupes of the ggerganov URL, which 404'd for any custom name). Precedence:
|
|
208
|
+
* the config's `modelSources` entry (a user's own fine-tune is one config
|
|
209
|
+
* line) > the curated table's `url` > the ggerganov default mirror.
|
|
210
|
+
*/
|
|
211
|
+
export function modelUrl(name: string, sources?: Record<string, string>): string {
|
|
212
|
+
return (
|
|
213
|
+
sources?.[name] ??
|
|
214
|
+
MODELS[name]?.url ??
|
|
215
|
+
`https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-${name}.bin`
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The one model-path resolution rule, extracted from its three duplicated
|
|
221
|
+
* sites (produce's transcription step, doctor's model check, setup's plan):
|
|
222
|
+
* an absolute model is a file path used verbatim, a bare name lives in
|
|
223
|
+
* modelDir as ggml-<name>.bin.
|
|
224
|
+
*/
|
|
225
|
+
export function whisperModelPath(model: string, modelDir: string): string {
|
|
226
|
+
return isAbsolute(model) ? model : join(modelDir, `ggml-${model}.bin`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Consumer-side vetting for the config's `modelSources` key — the
|
|
231
|
+
* `validDictionary` posture (produce.ts) applied to a record: the value
|
|
232
|
+
* comes from a hand-editable JSON file loadConfig doesn't zod-parse, so a
|
|
233
|
+
* non-object, a non-string URL, or a URL that trims to nothing means the
|
|
234
|
+
* whole key is ignored (`undefined`) and the call site warns once.
|
|
235
|
+
* All-or-nothing on purpose: half a typo'd map would download some models
|
|
236
|
+
* from a source the user never reviewed.
|
|
237
|
+
*/
|
|
238
|
+
export function validModelSources(value: unknown): Record<string, string> | undefined {
|
|
239
|
+
const parsed = z.record(z.string(), z.string().trim().min(1)).safeParse(value);
|
|
240
|
+
if (!parsed.success || Object.keys(parsed.data).length === 0) return undefined;
|
|
241
|
+
return parsed.data;
|
|
153
242
|
}
|
|
154
243
|
|
|
155
244
|
/** The exact build recipe printed when no prebuilt fits — one copy, not four. */
|
package/src/setup/plan.ts
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
import { isAbsolute, join } from "node:path";
|
|
2
2
|
import type { OssclipConfig } from "@ossclip/core";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type BinaryAsset,
|
|
5
|
+
MODELS,
|
|
6
|
+
ffmpegAsset,
|
|
7
|
+
modelUrl,
|
|
8
|
+
validModelSources,
|
|
9
|
+
whisperAsset,
|
|
10
|
+
whisperModelPath,
|
|
11
|
+
} from "./manifest";
|
|
4
12
|
|
|
5
13
|
/**
|
|
6
14
|
* The planning half of `ossclip setup` — pure over injected probes, like
|
|
@@ -137,11 +145,11 @@ export async function planSetup(
|
|
|
137
145
|
}
|
|
138
146
|
}
|
|
139
147
|
|
|
140
|
-
// The model: same resolution produce and doctor use
|
|
141
|
-
//
|
|
142
|
-
//
|
|
148
|
+
// The model: same resolution produce and doctor use (whisperModelPath).
|
|
149
|
+
// `--force` never re-downloads a present model; a corrupt one is deleted
|
|
150
|
+
// by hand.
|
|
143
151
|
const model = opts.model;
|
|
144
|
-
const modelPath =
|
|
152
|
+
const modelPath = whisperModelPath(model, cfg.modelDir);
|
|
145
153
|
const known = MODELS[model];
|
|
146
154
|
if (p.exists(modelPath)) {
|
|
147
155
|
steps.push({ kind: "model", status: "satisfied", detail: modelPath });
|
|
@@ -156,7 +164,10 @@ export async function planSetup(
|
|
|
156
164
|
steps.push({
|
|
157
165
|
kind: "model",
|
|
158
166
|
status: "download",
|
|
159
|
-
|
|
167
|
+
// The config's modelSources beats the curated/default hosts here for
|
|
168
|
+
// the same reason it does at download time — the plan must name the
|
|
169
|
+
// URL setup will actually fetch.
|
|
170
|
+
detail: `${modelUrl(model, validModelSources(cfg.modelSources))} → ${modelPath}`,
|
|
160
171
|
sizeMB: known?.sizeMB,
|
|
161
172
|
});
|
|
162
173
|
}
|
package/src/setup/setup.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { existsSync, mkdirSync, rmSync } from "node:fs";
|
|
3
|
-
import { basename,
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
5
|
import { CONFIG_DIR, loadConfig, saveConfigPatch, type OssclipConfig } from "@ossclip/core";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
MODELS,
|
|
8
|
+
WHISPER_BUILD_HINT,
|
|
9
|
+
modelUrl,
|
|
10
|
+
validModelSources,
|
|
11
|
+
whisperModelPath,
|
|
12
|
+
type BinaryAsset,
|
|
13
|
+
} from "./manifest";
|
|
7
14
|
import { formatPlan, managedBinDir, planSetup, type SetupProbes, type SetupStep } from "./plan";
|
|
8
15
|
import { download, progressLine } from "./download";
|
|
9
16
|
import { extractArchive, findFile, markExecutable } from "./extract";
|
|
@@ -130,17 +137,31 @@ export async function setup(
|
|
|
130
137
|
break;
|
|
131
138
|
case "model":
|
|
132
139
|
if (step.status === "download") {
|
|
133
|
-
const modelPath =
|
|
134
|
-
? model
|
|
135
|
-
: join(cfg.modelDir, `ggml-${model}.bin`);
|
|
140
|
+
const modelPath = whisperModelPath(model, cfg.modelDir);
|
|
136
141
|
const info = MODELS[model];
|
|
142
|
+
// Provenance out loud for a curated fine-tune — the user is about
|
|
143
|
+
// to fetch a community model, and the note says whose.
|
|
144
|
+
if (info?.note) console.log(`▸ ${model}: ${info.note}`);
|
|
137
145
|
if (!info) {
|
|
138
146
|
console.log(
|
|
139
147
|
`▸ ${model} isn't in the pinned table — downloading without a checksum.`,
|
|
140
148
|
);
|
|
149
|
+
} else if (!info.sha1) {
|
|
150
|
+
// A curated entry can predate its published checksum (the URL
|
|
151
|
+
// is pinned before the upstream upload settles) — disclose the
|
|
152
|
+
// unverified download the same way an off-table name does.
|
|
153
|
+
console.log(`▸ ${model} has no pinned checksum yet — downloading without one.`);
|
|
141
154
|
}
|
|
142
|
-
|
|
143
|
-
|
|
155
|
+
const sources = validModelSources(cfg.modelSources);
|
|
156
|
+
if (cfg.modelSources !== undefined && sources === undefined) {
|
|
157
|
+
console.log(
|
|
158
|
+
"⚠ config modelSources ignored — expected an object of name → URL strings",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
console.log(
|
|
162
|
+
`▸ downloading ggml-${model}.bin${info?.sizeMB ? ` (~${info.sizeMB} MB)` : ""}…`,
|
|
163
|
+
);
|
|
164
|
+
await download(modelUrl(model, sources), modelPath, {
|
|
144
165
|
sha1: info?.sha1,
|
|
145
166
|
onProgress: progressLine(`ggml-${model}.bin`),
|
|
146
167
|
});
|