ossclip 0.1.34 → 0.1.35

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/src/edit.ts CHANGED
@@ -18,6 +18,11 @@ import {
18
18
  ThumbnailConceptApprovedSchema,
19
19
  ThumbnailConceptSchema,
20
20
  appendUsageRun,
21
+ // The SFX plan route's word-space derivation (Phase 4) — the repaired
22
+ // transcript rebuilt from production.json's own stored pair, which is the
23
+ // index space `production.sfx.placements[].word` counts in.
24
+ applyRepairs,
25
+ ProductionSfxSchema,
21
26
  approvedOverlayText,
22
27
  buildThumbnailPrompt,
23
28
  captionCap,
@@ -43,6 +48,14 @@ import {
43
48
  // only when a regenerate actually runs.
44
49
  generateThumbnailImage,
45
50
  loadConfig,
51
+ // The SFX palette's one source of sounds (2026-08-29): the same loader
52
+ // produce plans against, so the dropdown can never offer a sound a render
53
+ // would not find.
54
+ loadSfxLibrary,
55
+ // …and the config gate that decides whether the bundled pack is part of it.
56
+ // Resolved here too, not just in produce: an editor that offered stock sounds
57
+ // the config excludes would let a user pick one the next render drops.
58
+ resolveSfxBundledPack,
46
59
  outInsideInputFolderMessage,
47
60
  outPathInsideInput,
48
61
  PORTRAIT_MIME_TYPES,
@@ -68,6 +81,10 @@ import {
68
81
  // server startup — open.ts is node:child_process + node:path and pure command
69
82
  // building, with nothing to defer.
70
83
  import { loadEnvFiles } from "./env";
84
+ // The identity endpoint's one spelling, shared with the CLI-side probe in
85
+ // edit-port.ts (which imports only the TYPE of this module's server, so there
86
+ // is no cycle and nothing interactive rides in here).
87
+ import { EDIT_HEALTH_PATH, editHealthBody } from "./edit-health";
71
88
  import { revealInFileManager } from "./open";
72
89
  import { REVIEWED_SCENES_BASENAME, renderReplayArgs } from "./render-replay-args";
73
90
  // The recorded-invocation reads live in cover.ts (2026-08-19): `ossclip
@@ -135,6 +152,29 @@ export function resolveEditorPageDir(): string | null {
135
152
  return candidates[0] ?? null;
136
153
  }
137
154
 
155
+ /**
156
+ * This package's version for `/api/health`, read from the manifest exactly as
157
+ * `--version` does (R22 §113: never a literal, or it reports the number a
158
+ * developer typed). Memoized because it sits on a request path, and
159
+ * `undefined` when unreadable — the health body's `version` is optional
160
+ * precisely so an odd install still identifies itself as ossclip.
161
+ */
162
+ let cachedVersion: string | undefined | null = null;
163
+ function packageVersion(): string | undefined {
164
+ if (cachedVersion === null) {
165
+ try {
166
+ cachedVersion = (
167
+ JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
168
+ version: string;
169
+ }
170
+ ).version;
171
+ } catch {
172
+ cachedVersion = undefined;
173
+ }
174
+ }
175
+ return cachedVersion;
176
+ }
177
+
138
178
  /**
139
179
  * The config keys `/api/retranscribe-range` needs. Spelled structurally
140
180
  * rather than as `OssclipConfig` so the `loadCfg` seam keeps accepting a test
@@ -271,6 +311,12 @@ const MIME: Record<string, string> = {
271
311
  // the type is stated while the change is one line rather than a debugging
272
312
  // session.
273
313
  ".wav": "audio/wav",
314
+ // The sound-effect preview (`/api/sfx/audio`) and the staged `sfx/` copies
315
+ // the workdir serves over `/media/`: the starter pack is mono mp3, and an
316
+ // `<audio>` element handed an octet-stream is exactly the sniffing-dependent
317
+ // preview the `.jpg` note below refuses to rely on. Anything else a user
318
+ // pack ships still falls back to octet-stream rather than being refused.
319
+ ".mp3": "audio/mpeg",
274
320
  // The `--cover-in-video` overlay: produce stages the cover into the workdir
275
321
  // as well as the render's public dir, and the Player fetches it through
276
322
  // `/media/`. Browsers do sniff an image served as octet-stream, but a
@@ -386,6 +432,10 @@ export async function startEditServer(
386
432
  /** The caption-regenerate spend report prices through the same table
387
433
  * produce does — absent in a stub, the defaults apply. */
388
434
  pricing?: Record<string, ModelPrice>;
435
+ /** Whether the bundled starter pack feeds the SFX routes' library —
436
+ * `unknown` because it is file-only and typed at the consumer
437
+ * (`resolveSfxBundledPack`), the `audience` rule. */
438
+ sfxBundledPack?: unknown;
389
439
  } & RetranscribeConfig;
390
440
  /** Env seam for the publish endpoints — tests inject their own so the
391
441
  * runner's real OSSCLIP_POSTIZ_API_KEY (or its absence) never decides a
@@ -421,6 +471,16 @@ export async function startEditServer(
421
471
  */
422
472
  sliceAudio?: typeof extractAudioSpan;
423
473
  runWhisper?: typeof runWhisper;
474
+ /**
475
+ * The sound library the SFX routes serve (`loadCfg`'s rule applied to the
476
+ * pack loader): tests inject a hand-written library over a tmp dir, so the
477
+ * suite never depends on the bundled pack riding a given runner's checkout
478
+ * — nor on whatever the developer happens to have in ~/.ossclip/sfx, which
479
+ * the real loader merges in. It is called with the resolved `includeBundled`
480
+ * (see `sfxLibrary` below), so a test can assert the config gate reached the
481
+ * loader rather than only that the routes serve what they were handed.
482
+ */
483
+ loadSfx?: typeof loadSfxLibrary;
424
484
  } = {},
425
485
  ): Promise<EditServer> {
426
486
  // MUTABLE since R17 §83: the server can start with no project (the page
@@ -631,6 +691,27 @@ export async function startEditServer(
631
691
  const cfg = (opts.loadCfg ?? loadConfig)();
632
692
  return { ffmpegPath: cfg.ffmpegPath ?? "ffmpeg", ffprobePath: cfg.ffprobePath ?? "ffprobe" };
633
693
  };
694
+ /**
695
+ * The sound library the SFX routes serve, loaded through the SAME config gate
696
+ * produce's sfx step uses (`sfxBundledPack` → `resolveSfxBundledPack`). One
697
+ * helper rather than the resolution inline at each route, because /library
698
+ * and /audio must agree: a menu offering `pop` while /audio 404s it — or
699
+ * worse, while the next render drops it — is the exact mismatch the gate
700
+ * exists to avoid.
701
+ *
702
+ * Read per request, like every other `loadCfg` consumer here: the server
703
+ * outlives config edits (R17 §83's mutable-workdir posture), and a user who
704
+ * flips the key gets it on the next refresh instead of on a restart.
705
+ *
706
+ * The malformed-value warning is dropped on purpose — this is a request path
707
+ * with no console the user is watching, and produce prints it on the run that
708
+ * actually places effects. The loader's own issues DO reach the panel, which
709
+ * is where a `~/.ossclip/sfx` author sees them.
710
+ */
711
+ const sfxLibrary = (): ReturnType<typeof loadSfxLibrary> =>
712
+ (opts.loadSfx ?? loadSfxLibrary)({
713
+ includeBundled: resolveSfxBundledPack((opts.loadCfg ?? loadConfig)().sfxBundledPack).include,
714
+ });
634
715
  const approvedPackPath = (): string => join(workdir!, YOUTUBE_APPROVED_BASENAME);
635
716
  /** The pack the panel shows: the approved file first (the user's
636
717
  * decision), else the newest valid `youtube-<key>.json` cache (what the
@@ -689,6 +770,18 @@ export async function startEditServer(
689
770
  try {
690
771
  const url = new URL(req.url ?? "/", "http://localhost");
691
772
 
773
+ if (url.pathname === EDIT_HEALTH_PATH && req.method === "GET") {
774
+ // Who is on this port (edit-health.ts owns the contract). This is
775
+ // what makes a second `ossclip edit` on the same project an ATTACH
776
+ // instead of an EADDRINUSE stack, and it answers with the CURRENT
777
+ // workdir — mutable since R17 §83, so a server whose project was
778
+ // switched from the page identifies as the project it is serving now.
779
+ return send(
780
+ 200,
781
+ editHealthBody({ version: packageVersion(), workdir, pid: process.pid }),
782
+ );
783
+ }
784
+
692
785
  if (url.pathname === "/api/production" && req.method === "GET") {
693
786
  if (!workdir) {
694
787
  // Not an error — the picker state (R17 §83). Recents ride along
@@ -2395,6 +2488,170 @@ export async function startEditServer(
2395
2488
  }
2396
2489
  }
2397
2490
 
2491
+ if (url.pathname === "/api/sfx/plan" && req.method === "GET") {
2492
+ // The MODEL's placement plan plus the word space it is written in
2493
+ // (Phase 4). Two values, one route, because they are useless apart:
2494
+ // a placement is `{soundId, word}` and `word` is an INDEX, so the
2495
+ // editor can only draw a marker — or write one — against the exact
2496
+ // array produce counted.
2497
+ //
2498
+ // And that array is NOT `transcript.json`, which is what
2499
+ // /api/transcript serves: produce writes that file straight off
2500
+ // whisper (produce.ts, `writeFile(transcriptCache, …)`) and only
2501
+ // THEN applies repairs and the `--clip` slice, planning sound
2502
+ // effects against the result. `applyRepairs` splices, so the two
2503
+ // index spaces need not line up at all — clip.ts's own note on
2504
+ // slicing says it outright ("repairs may change word counts, so raw
2505
+ // and repaired index spaces need not line up"). An editor drawing
2506
+ // this lane off transcript.json would place every marker on the
2507
+ // wrong word the moment one repair changed a word count, and its
2508
+ // drags would WRITE those wrong indices into overrides.json, where
2509
+ // produce reads them in the other space. So the derivation happens
2510
+ // HERE, once, on the server that has the pieces.
2511
+ //
2512
+ // The derivation is `ProductionSchema.repairs`' own stated contract:
2513
+ // `applyRepairs(transcript, repairs.filter(r => r.applied))`
2514
+ // reconstructs exactly what was rendered. No clip windowing on top:
2515
+ // production.json's `transcript` is ALREADY the sliced raw one and
2516
+ // its `repairs` were re-indexed with it (produce.ts's `--clip`
2517
+ // block: `rawTranscript = rawSlice.transcript` / `repairs =
2518
+ // sliceRepairs(…)`), so a second slice here would shift every index
2519
+ // by the clip's offset — the very bug this route exists to avoid.
2520
+ //
2521
+ // Its own route rather than a field on /api/production: that endpoint
2522
+ // is the page's hot load path and reads render-props.json, and this
2523
+ // needs production.json plus a repair replay. Lenient throughout —
2524
+ // any missing or corrupt piece answers `null` and the editor hides
2525
+ // the lane, exactly the /api/cleanup posture. A sound-effect lane is
2526
+ // never worth a 500.
2527
+ if (!workdir) return send(409, { error: "no workdir open" });
2528
+ let production: Record<string, unknown> | null = null;
2529
+ try {
2530
+ production = JSON.parse(
2531
+ await readFile(join(workdir, "production.json"), "utf8"),
2532
+ ) as Record<string, unknown>;
2533
+ } catch {
2534
+ return send(200, { sfx: null, words: null });
2535
+ }
2536
+ // Parsed, never cast (CLAUDE.md) — production.json is a file a user
2537
+ // can hand-edit, and the editor has no zod of its own, so the parse
2538
+ // that guards this payload has to be this one. A `sfx` field that
2539
+ // fails it is the same as none: no lane.
2540
+ const parsedSfx = ProductionSfxSchema.safeParse(production.sfx);
2541
+ const parsedTranscript = TranscriptSchema.safeParse(production.transcript);
2542
+ let words: Array<{ text: string; start: number; end: number }> | null = null;
2543
+ if (parsedTranscript.success) {
2544
+ const stored = Array.isArray(production.repairs)
2545
+ ? (production.repairs as Array<Record<string, unknown>>)
2546
+ : [];
2547
+ const applied = stored.filter((r) => r.applied === true);
2548
+ if (applied.length === 0) {
2549
+ words = parsedTranscript.data.words;
2550
+ } else {
2551
+ // The dictionary rides along for the reason produce's own repair
2552
+ // REPLAY passes it (its cached-repairs block): a
2553
+ // dictionary-vouched correction clears the phonetic gate only
2554
+ // when the vouched set is present, and `applyRepairs` re-decides
2555
+ // every proposal it is handed rather than trusting the stored
2556
+ // verdict.
2557
+ // `dictionary` is `unknown` on the config (file-only, validated
2558
+ // at the consumer — config.ts's posture), narrowed here the same
2559
+ // way `retranscribeSettings` narrows it a few hundred lines up.
2560
+ const rawDict = (opts.loadCfg ?? loadConfig)().dictionary;
2561
+ const dictionary =
2562
+ Array.isArray(rawDict) && rawDict.every((t) => typeof t === "string")
2563
+ ? (rawDict as string[])
2564
+ : undefined;
2565
+ const decided = applyRepairs(
2566
+ parsedTranscript.data,
2567
+ applied.map((r) => ({
2568
+ startWord: Number(r.startWord),
2569
+ endWord: Number(r.endWord),
2570
+ heard: String(r.heard),
2571
+ correction: String(r.correction),
2572
+ })),
2573
+ { dictionary },
2574
+ );
2575
+ // A repair that produce APPLIED but this replay refuses means the
2576
+ // reconstruction is not the array the placements were counted
2577
+ // against — and a refused splice can change the word count, so
2578
+ // every index after it would be off by one with nothing to say
2579
+ // so. Refuse the whole word list rather than serve a plausible
2580
+ // wrong one (§137's never-misapply rule): the lane hides, and no
2581
+ // gesture can write an index into the wrong space.
2582
+ words = decided.applied.every((r) => r.applied)
2583
+ ? decided.transcript.words
2584
+ : null;
2585
+ }
2586
+ }
2587
+ return send(200, {
2588
+ sfx: parsedSfx.success ? parsedSfx.data : null,
2589
+ // METADATA only, like the library route: text and SOURCE seconds,
2590
+ // which is all the client needs to map an index through its own
2591
+ // live TimeMap.
2592
+ words:
2593
+ words === null
2594
+ ? null
2595
+ : words.map((w) => ({ text: w.text, start: w.start, end: w.end })),
2596
+ });
2597
+ }
2598
+
2599
+ if (url.pathname === "/api/sfx/library" && req.method === "GET") {
2600
+ // The sound palette: what the swap dropdown offers and what a
2601
+ // click-to-preview can play. NO workdir guard, unlike its siblings —
2602
+ // the library is machine-global (every pack in ~/.ossclip/sfx, plus
2603
+ // the bundled one unless `sfxBundledPack` excludes it), so it has
2604
+ // nothing to do with which project is open, and the panel can render
2605
+ // its menu before one is.
2606
+ const library = sfxLibrary();
2607
+ return send(200, {
2608
+ // METADATA only: `absPath` stays server-side. The client addresses
2609
+ // a sound by id through /api/sfx/audio, which is what keeps the
2610
+ // filesystem out of a value the page could ever hand back (the
2611
+ // audio route's path rule is the other half of the same decision).
2612
+ sounds: library.sounds.map((s) => ({
2613
+ id: s.id,
2614
+ whenToUse: s.whenToUse,
2615
+ tags: s.tags,
2616
+ gain: s.gain,
2617
+ ...(s.durationSec !== undefined ? { durationSec: s.durationSec } : {}),
2618
+ packName: s.packName,
2619
+ })),
2620
+ // A user pack with a typo is a thing to SHOW, not to swallow: the
2621
+ // loader already degraded rather than throwing, and the panel is
2622
+ // the only surface a `~/.ossclip/sfx` author ever sees.
2623
+ issues: library.issues,
2624
+ });
2625
+ }
2626
+
2627
+ if (url.pathname === "/api/sfx/audio" && req.method === "GET") {
2628
+ // Click-to-preview. The path comes from the LOADED LIBRARY, never
2629
+ // from the client: the query carries an id, the id is looked up, and
2630
+ // the file that answers is whatever `loadSfxLibrary` resolved for it.
2631
+ // A traversal attempt is therefore not a path to reject but an id
2632
+ // nothing answers to — a plain 404, the same as any other unknown id
2633
+ // (and `SfxSoundSchema.id` is a slug, so no id can ever spell a path
2634
+ // in the first place).
2635
+ const id = url.searchParams.get("id") ?? "";
2636
+ const sound = sfxLibrary().sounds.find((s) => s.id === id);
2637
+ // Existence is re-checked here for `resolveSfxCues`' reason: a pack
2638
+ // deleted since the library was read must be a 404, not a 500 out of
2639
+ // `statSync` inside `sendFile`.
2640
+ if (sound === undefined || !existsSync(sound.absPath)) {
2641
+ return send(404, { error: `no sound "${id}" in the library` });
2642
+ }
2643
+ // No range support: these are sub-second files an `<audio>` element
2644
+ // plays whole, and a preview has nothing to seek through.
2645
+ sendFile(
2646
+ req,
2647
+ res,
2648
+ sound.absPath,
2649
+ MIME[extname(sound.absPath).toLowerCase()] ?? "application/octet-stream",
2650
+ false,
2651
+ );
2652
+ return;
2653
+ }
2654
+
2398
2655
  if (url.pathname.startsWith("/media/")) {
2399
2656
  if (!workdir) return send(409, { error: "no workdir open" });
2400
2657
  const file = join(workdir, decodeURIComponent(url.pathname.slice("/media/".length)));
@@ -2420,7 +2677,22 @@ export async function startEditServer(
2420
2677
  })();
2421
2678
  });
2422
2679
 
2423
- await new Promise<void>((r) => server.listen(opts.port ?? 5174, "127.0.0.1", r));
2680
+ // REJECT on a failed bind, don't crash. Without the error listener an
2681
+ // EADDRINUSE is an unhandled 'error' event on the server object, which is
2682
+ // fatal to the whole process — that raw Node stack (plus the package
2683
+ // manager's ELIFECYCLE after it) is exactly what a busy 5174 printed at
2684
+ // users, and `openEditServer` (edit-port.ts) cannot offer to attach to the
2685
+ // editor already running there unless the failure comes back as a rejection
2686
+ // it can catch. The listener is removed on success so a LATER runtime error
2687
+ // (a client resetting a connection) keeps whatever handling http gives it.
2688
+ await new Promise<void>((res, rej) => {
2689
+ const onError = (err: Error): void => rej(err);
2690
+ server.once("error", onError);
2691
+ server.listen(opts.port ?? 5174, "127.0.0.1", () => {
2692
+ server.off("error", onError);
2693
+ res();
2694
+ });
2695
+ });
2424
2696
  const addr = server.address();
2425
2697
  const port = typeof addr === "object" && addr ? addr.port : (opts.port ?? 5174);
2426
2698
  return {
@@ -11,7 +11,17 @@ import { renderCommand } from "./render";
11
11
  */
12
12
  export async function offerEditor(
13
13
  result: ProduceResult,
14
- opts: { flag: boolean | undefined; port: number },
14
+ opts: {
15
+ flag: boolean | undefined;
16
+ port: number;
17
+ /**
18
+ * Whether the user TYPED `--editor-port`. Commander's 5174 default is not
19
+ * a choice anybody made, so the common case must be free to attach or step
20
+ * around a busy port; only a typed port is defended (edit-port.ts's
21
+ * `pinned`, and the `--port` half of the same rule in program.ts).
22
+ */
23
+ portPinned: boolean;
24
+ },
15
25
  ): Promise<void> {
16
26
  const pref: OpenEditorPref = loadConfig().openEditorAfterProduce ?? "ask";
17
27
  const decision = decideOpenEditor({
@@ -64,8 +74,23 @@ export async function offerEditor(
64
74
  );
65
75
  return;
66
76
  }
67
- const server = await startEditServer(result.workdir, { port: opts.port, pageDir });
68
- console.log(`▸ editor at ${server.url}`);
77
+ // The same busy-port ladder `ossclip edit` runs (edit-port.ts): a produce
78
+ // finishing into an already-open editor on THIS project attaches to it
79
+ // instead of dying on EADDRINUSE — which here would throw away the run's
80
+ // whole summary behind a stack trace. `liveEditPortDeps` reads the real
81
+ // `isInteractive()`, so the three-way prompt is available exactly where this
82
+ // offer already asks questions, and absent in a piped run.
83
+ const { openEditServer, liveEditPortDeps } = await import("../edit-port");
84
+ const opened = await openEditServer(
85
+ result.workdir,
86
+ { port: opts.port, pinned: opts.portPinned },
87
+ liveEditPortDeps((port) => startEditServer(result.workdir, { port, pageDir })),
88
+ );
89
+ if (opened.kind === "cancelled") return;
90
+ const url = opened.kind === "attached" ? opened.url : opened.server.url;
91
+ // The attach path already printed "▸ already open at …"; a second line
92
+ // underneath it would read as a second server.
93
+ if (opened.kind === "started") console.log(`▸ editor at ${url}`);
69
94
  const { openInBrowser } = await import("../open");
70
- openInBrowser(server.url);
95
+ openInBrowser(url);
71
96
  }
@@ -1,4 +1,4 @@
1
- import type { ProviderName } from "@ossclip/core";
1
+ import type { ProviderName, SfxLevel } from "@ossclip/core";
2
2
 
3
3
  /**
4
4
  * Wizard answers → the argv a user could have typed.
@@ -35,6 +35,16 @@ export interface ProduceExtras {
35
35
  * it exists to beat a future config-off, and emitting it here would
36
36
  * restate the default. */
37
37
  jumpCuts?: boolean;
38
+ /** Sound effects, the watermark's polarity with a level attached: PRESENT
39
+ * means on (the wizard only ever turns it ON — off is the default, there is
40
+ * no `--no-sfx` spelling to mirror, and a config-on user who wants silence
41
+ * for one run edits the config's `sfx` key), and the value is whatever the
42
+ * level follow-up answered. Typed as core's `SfxLevel`, not an inline
43
+ * union, for `llm`'s reason: a level added to `SfxLevelSchema` must not be
44
+ * silently unofferable here. Only reachable under graphics — sound effects
45
+ * are placed against the producer's beat sheet, so `extrasFor` gates the
46
+ * entry the way it gates `--clip`. */
47
+ sfx?: SfxLevel;
38
48
  /** The YouTube pack (Y2): the wizard only ever turns it ON — off is the
39
49
  * default, and a config-on user who wants it off for one run types
40
50
  * `--no-youtube`, flags-only like `--no-watermark`. */
@@ -116,6 +126,13 @@ export function produceArgv(a: ProduceAnswers): string[] {
116
126
  // negative spelling — auto must stay an ABSENT flag, or the taught
117
127
  // command line restates a default.
118
128
  if (e.jumpCuts === false) argv.push("--no-jump-cuts");
129
+ // One flag, never both: `--sfx-level` already implies `--sfx` (program.ts's
130
+ // `sfxFlag`), which is why replay-argv pins a level WITHOUT the switch too —
131
+ // emitting the pair would teach a flag that changes nothing. And `normal` is
132
+ // the CLI's own default, so naming it would restate a default per the
133
+ // elision rule above: a normal-level run's whole sound design is `--sfx`.
134
+ if (e.sfx === "normal") argv.push("--sfx");
135
+ else if (e.sfx !== undefined) argv.push("--sfx-level", e.sfx);
119
136
  // Watermark's shape: only the ON tick emits (off is the default, elided),
120
137
  // and the portrait only rides along with a value — the wizard already
121
138
  // dropped empty answers.
@@ -12,8 +12,9 @@ import { assertInteractive, confirm, intro, multiselect, select, text, unwrap }
12
12
  /**
13
13
  * The produce wizard. Forty-one flags (plus the positional input path)
14
14
  * sorted into three tiers: six prompts asked directly — the input path, plus
15
- * five flags (--out, --cleanup, --aspect, --produce, --intent) — eleven
16
- * behind one "anything else?" multiselect, and the remaining stay flags-only:
15
+ * five flags (--out, --cleanup, --aspect, --produce, --intent) — twelve
16
+ * behind one "anything else?" multiselect (--sfx being the twelfth, with
17
+ * --sfx-level as its follow-up prompt rather than an entry of its own), and the remaining stay flags-only:
17
18
  * debug/internal surfaces, replay-only fields, --no-watermark (the
18
19
  * multiselect only turns the credit ON; off is already the default),
19
20
  * --no-youtube (the same shape: the pack entry only turns it ON),
@@ -48,6 +49,11 @@ const EXTRAS = [
48
49
  { value: "sourceIsEdited", label: "Source already has burned-in text", hint: "--source-is-edited" },
49
50
  { value: "captionsOff", label: "Turn the burned-in captions off", hint: "--no-captions" },
50
51
  { value: "jumpCutsOff", label: "No punch-in zooms at cuts", hint: "--no-jump-cuts" },
52
+ // Watermark/youtube polarity: OFF is the default and the entry only ever
53
+ // turns it ON — `--no-sfx` does not exist (program.ts declares `--sfx`
54
+ // alone so the config's `sfx` key can still supply it), so there is no OFF
55
+ // spelling to mirror in a second entry.
56
+ { value: "sfx", label: "Add sound effects (whoosh, ding…)", hint: "--sfx" },
51
57
  { value: "watermark", label: 'Credit the tool with a small "made with ossclip"', hint: "--watermark" },
52
58
  // The hint says the approval part out loud (thumbnail UX, 2026-08-16):
53
59
  // ticking this adds an interactive stop before the render, and a surprise
@@ -69,6 +75,15 @@ const EXTRAS = [
69
75
  * graphics is already on. Exported and kept pure so this can be asserted
70
76
  * without a TTY.
71
77
  *
78
+ * The sfx entry is gated on the same precedent (2026-08-29). Sound effects
79
+ * are placed against the producer's beat sheet, so a `--sfx` run without
80
+ * `--produce` reaches produce.ts's own "sound effects are placed against the
81
+ * producer's beat sheet — add --produce" warning and renders silent (a
82
+ * previous run's plan in the workdir is the only thing that saves it, which
83
+ * is not something a menu can promise). A guaranteed-inert menu item is the
84
+ * same broken offer as the clip one, softer only in that it warns instead of
85
+ * throwing — so it is only ever listed once graphics is already on.
86
+ *
72
87
  * `watermarkFromConfig` (review, minor a): on a config-on machine the
73
88
  * watermark entry sits UNCHECKED while the credit will render anyway —
74
89
  * unchecked is "don't emit the flag", not "off", and the multiselect has no
@@ -81,7 +96,9 @@ export function extrasFor(
81
96
  graphics: boolean,
82
97
  opts: { watermarkFromConfig?: boolean } = {},
83
98
  ): { value: (typeof EXTRAS)[number]["value"]; label: string; hint: string }[] {
84
- const list = graphics ? [...EXTRAS] : EXTRAS.filter((e) => e.value !== "graphicsClip");
99
+ const list = graphics
100
+ ? [...EXTRAS]
101
+ : EXTRAS.filter((e) => e.value !== "graphicsClip" && e.value !== "sfx");
85
102
  if (opts.watermarkFromConfig !== true) return [...list];
86
103
  return list.map((e) =>
87
104
  e.value === "watermark"
@@ -346,6 +363,26 @@ export async function produceWizard(
346
363
  // Same OFF-switch shape (the punch defaults ON, face-only): a tick maps
347
364
  // to `jumpCuts: false` and produceArgv emits `--no-jump-cuts`.
348
365
  if (chosen.includes("jumpCutsOff")) extras.jumpCuts = false;
366
+ if (chosen.includes("sfx")) {
367
+ // Follow-up under the same extra, like --clip's seconds prompt: the level
368
+ // only means anything once effects are on. `normal` is preselected
369
+ // because it is the CLI's own default — and picking it still emits the
370
+ // bare `--sfx` rather than `--sfx-level normal`, produceArgv's
371
+ // default-elision rule. The hints say what the level actually CHANGES
372
+ // (density, and whether the meme-tagged sounds are eligible at all),
373
+ // because "subtle/normal/meme" alone reads as a volume knob.
374
+ extras.sfx = unwrap(
375
+ await select({
376
+ message: "How much sound design?",
377
+ initialValue: "normal",
378
+ options: [
379
+ { value: "subtle", label: "subtle", hint: "a couple of accents a minute" },
380
+ { value: "normal", label: "normal", hint: "recommended" },
381
+ { value: "meme", label: "meme", hint: "denser, and meme-tagged sounds become eligible" },
382
+ ],
383
+ }),
384
+ ) as ProduceExtras["sfx"];
385
+ }
349
386
  if (chosen.includes("watermark")) extras.watermark = true;
350
387
  if (chosen.includes("youtube")) {
351
388
  extras.youtube = true;