ossclip 0.1.34 → 0.1.36

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
@@ -4,7 +4,7 @@ import { createReadStream, existsSync, readFileSync, statSync } from "node:fs";
4
4
  import { copyFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
5
5
  import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
6
6
  import { homedir } from "node:os";
7
- import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { z } from "zod/v4";
10
10
  import {
@@ -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,22 @@ 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,
59
+ // The Color panel's .cube menu (2026-08-30): the same loader produce's LUT
60
+ // bake resolves against, the loadSfxLibrary rule for grades.
61
+ loadLutLibrary,
62
+ // …and the validator every grade layer goes through — the /api/luts payload
63
+ // carries the CONFIG grade so the panel can label its "Default" entry, and
64
+ // a malformed config value must read as "no default" there exactly as it
65
+ // reads in produce (`resolveProductionColorGrade` falls through to off).
66
+ resolveColorGrade,
46
67
  outInsideInputFolderMessage,
47
68
  outPathInsideInput,
48
69
  PORTRAIT_MIME_TYPES,
@@ -68,6 +89,10 @@ import {
68
89
  // server startup — open.ts is node:child_process + node:path and pure command
69
90
  // building, with nothing to defer.
70
91
  import { loadEnvFiles } from "./env";
92
+ // The identity endpoint's one spelling, shared with the CLI-side probe in
93
+ // edit-port.ts (which imports only the TYPE of this module's server, so there
94
+ // is no cycle and nothing interactive rides in here).
95
+ import { EDIT_HEALTH_PATH, editHealthBody } from "./edit-health";
71
96
  import { revealInFileManager } from "./open";
72
97
  import { REVIEWED_SCENES_BASENAME, renderReplayArgs } from "./render-replay-args";
73
98
  // The recorded-invocation reads live in cover.ts (2026-08-19): `ossclip
@@ -102,6 +127,7 @@ import { lastFlagValue, thumbnailPanelState } from "./thumbnail-panel";
102
127
  import { captionRegenProvider } from "./caption-regen-panel";
103
128
  import { binOnPath } from "./llm-detect";
104
129
  import {
130
+ YOUTUBE_PRIVACIES,
105
131
  attachDeliveryMedia,
106
132
  buildPublishPosts,
107
133
  publishConfigured,
@@ -135,6 +161,29 @@ export function resolveEditorPageDir(): string | null {
135
161
  return candidates[0] ?? null;
136
162
  }
137
163
 
164
+ /**
165
+ * This package's version for `/api/health`, read from the manifest exactly as
166
+ * `--version` does (R22 §113: never a literal, or it reports the number a
167
+ * developer typed). Memoized because it sits on a request path, and
168
+ * `undefined` when unreadable — the health body's `version` is optional
169
+ * precisely so an odd install still identifies itself as ossclip.
170
+ */
171
+ let cachedVersion: string | undefined | null = null;
172
+ function packageVersion(): string | undefined {
173
+ if (cachedVersion === null) {
174
+ try {
175
+ cachedVersion = (
176
+ JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
177
+ version: string;
178
+ }
179
+ ).version;
180
+ } catch {
181
+ cachedVersion = undefined;
182
+ }
183
+ }
184
+ return cachedVersion;
185
+ }
186
+
138
187
  /**
139
188
  * The config keys `/api/retranscribe-range` needs. Spelled structurally
140
189
  * rather than as `OssclipConfig` so the `loadCfg` seam keeps accepting a test
@@ -271,6 +320,12 @@ const MIME: Record<string, string> = {
271
320
  // the type is stated while the change is one line rather than a debugging
272
321
  // session.
273
322
  ".wav": "audio/wav",
323
+ // The sound-effect preview (`/api/sfx/audio`) and the staged `sfx/` copies
324
+ // the workdir serves over `/media/`: the starter pack is mono mp3, and an
325
+ // `<audio>` element handed an octet-stream is exactly the sniffing-dependent
326
+ // preview the `.jpg` note below refuses to rely on. Anything else a user
327
+ // pack ships still falls back to octet-stream rather than being refused.
328
+ ".mp3": "audio/mpeg",
274
329
  // The `--cover-in-video` overlay: produce stages the cover into the workdir
275
330
  // as well as the render's public dir, and the Player fetches it through
276
331
  // `/media/`. Browsers do sniff an image served as octet-stream, but a
@@ -386,6 +441,14 @@ export async function startEditServer(
386
441
  /** The caption-regenerate spend report prices through the same table
387
442
  * produce does — absent in a stub, the defaults apply. */
388
443
  pricing?: Record<string, ModelPrice>;
444
+ /** Whether the bundled starter pack feeds the SFX routes' library —
445
+ * `unknown` because it is file-only and typed at the consumer
446
+ * (`resolveSfxBundledPack`), the `audience` rule. */
447
+ sfxBundledPack?: unknown;
448
+ /** The config-level default grade the Color panel's "Default" entry
449
+ * names — `unknown` because it is file-only and typed at the consumer
450
+ * (`resolveColorGrade`), the `sfxBundledPack` rule. */
451
+ colorGrade?: unknown;
389
452
  } & RetranscribeConfig;
390
453
  /** Env seam for the publish endpoints — tests inject their own so the
391
454
  * runner's real OSSCLIP_POSTIZ_API_KEY (or its absence) never decides a
@@ -421,6 +484,20 @@ export async function startEditServer(
421
484
  */
422
485
  sliceAudio?: typeof extractAudioSpan;
423
486
  runWhisper?: typeof runWhisper;
487
+ /**
488
+ * The sound library the SFX routes serve (`loadCfg`'s rule applied to the
489
+ * pack loader): tests inject a hand-written library over a tmp dir, so the
490
+ * suite never depends on the bundled pack riding a given runner's checkout
491
+ * — nor on whatever the developer happens to have in ~/.ossclip/sfx, which
492
+ * the real loader merges in. It is called with the resolved `includeBundled`
493
+ * (see `sfxLibrary` below), so a test can assert the config gate reached the
494
+ * loader rather than only that the routes serve what they were handed.
495
+ */
496
+ loadSfx?: typeof loadSfxLibrary;
497
+ /** The LUT library `/api/luts` serves — the `loadSfx` seam for grades:
498
+ * tests inject a hand-written library instead of depending on whatever
499
+ * the developer keeps in ~/.ossclip/luts. */
500
+ loadLuts?: typeof loadLutLibrary;
424
501
  } = {},
425
502
  ): Promise<EditServer> {
426
503
  // MUTABLE since R17 §83: the server can start with no project (the page
@@ -631,6 +708,27 @@ export async function startEditServer(
631
708
  const cfg = (opts.loadCfg ?? loadConfig)();
632
709
  return { ffmpegPath: cfg.ffmpegPath ?? "ffmpeg", ffprobePath: cfg.ffprobePath ?? "ffprobe" };
633
710
  };
711
+ /**
712
+ * The sound library the SFX routes serve, loaded through the SAME config gate
713
+ * produce's sfx step uses (`sfxBundledPack` → `resolveSfxBundledPack`). One
714
+ * helper rather than the resolution inline at each route, because /library
715
+ * and /audio must agree: a menu offering `pop` while /audio 404s it — or
716
+ * worse, while the next render drops it — is the exact mismatch the gate
717
+ * exists to avoid.
718
+ *
719
+ * Read per request, like every other `loadCfg` consumer here: the server
720
+ * outlives config edits (R17 §83's mutable-workdir posture), and a user who
721
+ * flips the key gets it on the next refresh instead of on a restart.
722
+ *
723
+ * The malformed-value warning is dropped on purpose — this is a request path
724
+ * with no console the user is watching, and produce prints it on the run that
725
+ * actually places effects. The loader's own issues DO reach the panel, which
726
+ * is where a `~/.ossclip/sfx` author sees them.
727
+ */
728
+ const sfxLibrary = (): ReturnType<typeof loadSfxLibrary> =>
729
+ (opts.loadSfx ?? loadSfxLibrary)({
730
+ includeBundled: resolveSfxBundledPack((opts.loadCfg ?? loadConfig)().sfxBundledPack).include,
731
+ });
634
732
  const approvedPackPath = (): string => join(workdir!, YOUTUBE_APPROVED_BASENAME);
635
733
  /** The pack the panel shows: the approved file first (the user's
636
734
  * decision), else the newest valid `youtube-<key>.json` cache (what the
@@ -689,6 +787,18 @@ export async function startEditServer(
689
787
  try {
690
788
  const url = new URL(req.url ?? "/", "http://localhost");
691
789
 
790
+ if (url.pathname === EDIT_HEALTH_PATH && req.method === "GET") {
791
+ // Who is on this port (edit-health.ts owns the contract). This is
792
+ // what makes a second `ossclip edit` on the same project an ATTACH
793
+ // instead of an EADDRINUSE stack, and it answers with the CURRENT
794
+ // workdir — mutable since R17 §83, so a server whose project was
795
+ // switched from the page identifies as the project it is serving now.
796
+ return send(
797
+ 200,
798
+ editHealthBody({ version: packageVersion(), workdir, pid: process.pid }),
799
+ );
800
+ }
801
+
692
802
  if (url.pathname === "/api/production" && req.method === "GET") {
693
803
  if (!workdir) {
694
804
  // Not an error — the picker state (R17 §83). Recents ride along
@@ -1878,6 +1988,14 @@ export async function startEditServer(
1878
1988
  // What uploads (the CLI's --delivery): auto (default) builds
1879
1989
  // the cached delivery encode, master sends the untouched render.
1880
1990
  delivery: z.enum(["auto", "master"]).optional(),
1991
+ // YouTube's privacy status (the CLI's --youtube-privacy),
1992
+ // spelled ONCE — the flag's own value list, so the panel and
1993
+ // the CLI can never accept different words. Absent leaves
1994
+ // buildPostsPayload's safe private default alone: until this
1995
+ // rode along, every panel publish landed private with no way
1996
+ // to say otherwise, and two videos the user believed were
1997
+ // published sat private on the channel (2026-08-29).
1998
+ youtubePrivacy: z.enum(YOUTUBE_PRIVACIES).optional(),
1881
1999
  })
1882
2000
  .safeParse(JSON.parse(Buffer.concat(chunks).toString() || "{}"));
1883
2001
  if (!parsed.success) return send(400, { error: parsed.error.message });
@@ -1984,7 +2102,14 @@ export async function startEditServer(
1984
2102
  }
1985
2103
  capGroups = sizeCapGroups(picked);
1986
2104
  }
1987
- const posts = buildPublishPosts(pack, picked).map((p) => ({
2105
+ // Same options object the CLI's publish path passes — undefined
2106
+ // stays undefined so buildPostsPayload's safe private default is
2107
+ // still the ONE place that decides an absent privacy.
2108
+ const posts = buildPublishPosts(pack, picked, {
2109
+ ...(parsed.data.youtubePrivacy !== undefined
2110
+ ? { youtubePrivacy: parsed.data.youtubePrivacy }
2111
+ : {}),
2112
+ }).map((p) => ({
1988
2113
  ...p,
1989
2114
  caption: parsed.data.captions?.[p.target.id] ?? p.caption,
1990
2115
  }));
@@ -2395,6 +2520,203 @@ export async function startEditServer(
2395
2520
  }
2396
2521
  }
2397
2522
 
2523
+ if (url.pathname === "/api/sfx/plan" && req.method === "GET") {
2524
+ // The MODEL's placement plan plus the word space it is written in
2525
+ // (Phase 4). Two values, one route, because they are useless apart:
2526
+ // a placement is `{soundId, word}` and `word` is an INDEX, so the
2527
+ // editor can only draw a marker — or write one — against the exact
2528
+ // array produce counted.
2529
+ //
2530
+ // And that array is NOT `transcript.json`, which is what
2531
+ // /api/transcript serves: produce writes that file straight off
2532
+ // whisper (produce.ts, `writeFile(transcriptCache, …)`) and only
2533
+ // THEN applies repairs and the `--clip` slice, planning sound
2534
+ // effects against the result. `applyRepairs` splices, so the two
2535
+ // index spaces need not line up at all — clip.ts's own note on
2536
+ // slicing says it outright ("repairs may change word counts, so raw
2537
+ // and repaired index spaces need not line up"). An editor drawing
2538
+ // this lane off transcript.json would place every marker on the
2539
+ // wrong word the moment one repair changed a word count, and its
2540
+ // drags would WRITE those wrong indices into overrides.json, where
2541
+ // produce reads them in the other space. So the derivation happens
2542
+ // HERE, once, on the server that has the pieces.
2543
+ //
2544
+ // The derivation is `ProductionSchema.repairs`' own stated contract:
2545
+ // `applyRepairs(transcript, repairs.filter(r => r.applied))`
2546
+ // reconstructs exactly what was rendered. No clip windowing on top:
2547
+ // production.json's `transcript` is ALREADY the sliced raw one and
2548
+ // its `repairs` were re-indexed with it (produce.ts's `--clip`
2549
+ // block: `rawTranscript = rawSlice.transcript` / `repairs =
2550
+ // sliceRepairs(…)`), so a second slice here would shift every index
2551
+ // by the clip's offset — the very bug this route exists to avoid.
2552
+ //
2553
+ // Its own route rather than a field on /api/production: that endpoint
2554
+ // is the page's hot load path and reads render-props.json, and this
2555
+ // needs production.json plus a repair replay. Lenient throughout —
2556
+ // any missing or corrupt piece answers `null` and the editor hides
2557
+ // the lane, exactly the /api/cleanup posture. A sound-effect lane is
2558
+ // never worth a 500.
2559
+ if (!workdir) return send(409, { error: "no workdir open" });
2560
+ let production: Record<string, unknown> | null = null;
2561
+ try {
2562
+ production = JSON.parse(
2563
+ await readFile(join(workdir, "production.json"), "utf8"),
2564
+ ) as Record<string, unknown>;
2565
+ } catch {
2566
+ return send(200, { sfx: null, words: null });
2567
+ }
2568
+ // Parsed, never cast (CLAUDE.md) — production.json is a file a user
2569
+ // can hand-edit, and the editor has no zod of its own, so the parse
2570
+ // that guards this payload has to be this one. A `sfx` field that
2571
+ // fails it is the same as none: no lane.
2572
+ const parsedSfx = ProductionSfxSchema.safeParse(production.sfx);
2573
+ const parsedTranscript = TranscriptSchema.safeParse(production.transcript);
2574
+ let words: Array<{ text: string; start: number; end: number }> | null = null;
2575
+ if (parsedTranscript.success) {
2576
+ const stored = Array.isArray(production.repairs)
2577
+ ? (production.repairs as Array<Record<string, unknown>>)
2578
+ : [];
2579
+ const applied = stored.filter((r) => r.applied === true);
2580
+ if (applied.length === 0) {
2581
+ words = parsedTranscript.data.words;
2582
+ } else {
2583
+ // The dictionary rides along for the reason produce's own repair
2584
+ // REPLAY passes it (its cached-repairs block): a
2585
+ // dictionary-vouched correction clears the phonetic gate only
2586
+ // when the vouched set is present, and `applyRepairs` re-decides
2587
+ // every proposal it is handed rather than trusting the stored
2588
+ // verdict.
2589
+ // `dictionary` is `unknown` on the config (file-only, validated
2590
+ // at the consumer — config.ts's posture), narrowed here the same
2591
+ // way `retranscribeSettings` narrows it a few hundred lines up.
2592
+ const rawDict = (opts.loadCfg ?? loadConfig)().dictionary;
2593
+ const dictionary =
2594
+ Array.isArray(rawDict) && rawDict.every((t) => typeof t === "string")
2595
+ ? (rawDict as string[])
2596
+ : undefined;
2597
+ const decided = applyRepairs(
2598
+ parsedTranscript.data,
2599
+ applied.map((r) => ({
2600
+ startWord: Number(r.startWord),
2601
+ endWord: Number(r.endWord),
2602
+ heard: String(r.heard),
2603
+ correction: String(r.correction),
2604
+ })),
2605
+ { dictionary },
2606
+ );
2607
+ // A repair that produce APPLIED but this replay refuses means the
2608
+ // reconstruction is not the array the placements were counted
2609
+ // against — and a refused splice can change the word count, so
2610
+ // every index after it would be off by one with nothing to say
2611
+ // so. Refuse the whole word list rather than serve a plausible
2612
+ // wrong one (§137's never-misapply rule): the lane hides, and no
2613
+ // gesture can write an index into the wrong space.
2614
+ words = decided.applied.every((r) => r.applied)
2615
+ ? decided.transcript.words
2616
+ : null;
2617
+ }
2618
+ }
2619
+ return send(200, {
2620
+ sfx: parsedSfx.success ? parsedSfx.data : null,
2621
+ // METADATA only, like the library route: text and SOURCE seconds,
2622
+ // which is all the client needs to map an index through its own
2623
+ // live TimeMap.
2624
+ words:
2625
+ words === null
2626
+ ? null
2627
+ : words.map((w) => ({ text: w.text, start: w.start, end: w.end })),
2628
+ });
2629
+ }
2630
+
2631
+ if (url.pathname === "/api/sfx/library" && req.method === "GET") {
2632
+ // The sound palette: what the swap dropdown offers and what a
2633
+ // click-to-preview can play. NO workdir guard, unlike its siblings —
2634
+ // the library is machine-global (every pack in ~/.ossclip/sfx, plus
2635
+ // the bundled one unless `sfxBundledPack` excludes it), so it has
2636
+ // nothing to do with which project is open, and the panel can render
2637
+ // its menu before one is.
2638
+ const library = sfxLibrary();
2639
+ return send(200, {
2640
+ // METADATA only: `absPath` stays server-side. The client addresses
2641
+ // a sound by id through /api/sfx/audio, which is what keeps the
2642
+ // filesystem out of a value the page could ever hand back (the
2643
+ // audio route's path rule is the other half of the same decision).
2644
+ sounds: library.sounds.map((s) => ({
2645
+ id: s.id,
2646
+ whenToUse: s.whenToUse,
2647
+ tags: s.tags,
2648
+ gain: s.gain,
2649
+ ...(s.durationSec !== undefined ? { durationSec: s.durationSec } : {}),
2650
+ packName: s.packName,
2651
+ })),
2652
+ // A user pack with a typo is a thing to SHOW, not to swallow: the
2653
+ // loader already degraded rather than throwing, and the panel is
2654
+ // the only surface a `~/.ossclip/sfx` author ever sees.
2655
+ issues: library.issues,
2656
+ });
2657
+ }
2658
+
2659
+ if (url.pathname === "/api/luts" && req.method === "GET") {
2660
+ // The Color panel's .cube menu plus the config-level default grade.
2661
+ // NO workdir guard, /api/sfx/library's rule: both halves are
2662
+ // machine-global (~/.ossclip/luts and config.json), so the panel can
2663
+ // build its dropdown before a project is open. Read per request like
2664
+ // every other loadCfg consumer — a LUT dropped while the editor is
2665
+ // up appears on the next refresh, not on a restart.
2666
+ const library = (opts.loadLuts ?? loadLutLibrary)();
2667
+ // The config grade rides along VALIDATED, not raw: a malformed
2668
+ // config value is what produce ignores (`resolveProductionColorGrade`
2669
+ // warns and proceeds without it), so a "Default (…)" entry built
2670
+ // from it would offer an inherit that renders as nothing. Null means
2671
+ // the panel shows no Default entry, and the warning is dropped for
2672
+ // the sfxLibrary helper's reason — no console here, produce prints
2673
+ // it on the run that grades.
2674
+ const configGrade = resolveColorGrade(
2675
+ (opts.loadCfg ?? loadConfig)().colorGrade,
2676
+ "config",
2677
+ ).grade;
2678
+ return send(200, {
2679
+ // METADATA only, the sfx library rule: the absolute `path` stays
2680
+ // server-side. `file` (the basename, extension and all) is what an
2681
+ // editor-written override must carry — `ColorGrade.lut` documents
2682
+ // the basename, produce joins it against ~/.ossclip/luts verbatim,
2683
+ // and a stem-only id would drop the `.CUBE` an exporter spelled.
2684
+ items: library.items.map((l) => ({ id: l.id, title: l.title, file: basename(l.path) })),
2685
+ // A ~/.ossclip/luts author's only surface, like the sfx panel:
2686
+ // the loader degraded instead of throwing, so show the reason.
2687
+ issues: library.issues,
2688
+ configGrade: configGrade ?? null,
2689
+ });
2690
+ }
2691
+
2692
+ if (url.pathname === "/api/sfx/audio" && req.method === "GET") {
2693
+ // Click-to-preview. The path comes from the LOADED LIBRARY, never
2694
+ // from the client: the query carries an id, the id is looked up, and
2695
+ // the file that answers is whatever `loadSfxLibrary` resolved for it.
2696
+ // A traversal attempt is therefore not a path to reject but an id
2697
+ // nothing answers to — a plain 404, the same as any other unknown id
2698
+ // (and `SfxSoundSchema.id` is a slug, so no id can ever spell a path
2699
+ // in the first place).
2700
+ const id = url.searchParams.get("id") ?? "";
2701
+ const sound = sfxLibrary().sounds.find((s) => s.id === id);
2702
+ // Existence is re-checked here for `resolveSfxCues`' reason: a pack
2703
+ // deleted since the library was read must be a 404, not a 500 out of
2704
+ // `statSync` inside `sendFile`.
2705
+ if (sound === undefined || !existsSync(sound.absPath)) {
2706
+ return send(404, { error: `no sound "${id}" in the library` });
2707
+ }
2708
+ // No range support: these are sub-second files an `<audio>` element
2709
+ // plays whole, and a preview has nothing to seek through.
2710
+ sendFile(
2711
+ req,
2712
+ res,
2713
+ sound.absPath,
2714
+ MIME[extname(sound.absPath).toLowerCase()] ?? "application/octet-stream",
2715
+ false,
2716
+ );
2717
+ return;
2718
+ }
2719
+
2398
2720
  if (url.pathname.startsWith("/media/")) {
2399
2721
  if (!workdir) return send(409, { error: "no workdir open" });
2400
2722
  const file = join(workdir, decodeURIComponent(url.pathname.slice("/media/".length)));
@@ -2420,7 +2742,22 @@ export async function startEditServer(
2420
2742
  })();
2421
2743
  });
2422
2744
 
2423
- await new Promise<void>((r) => server.listen(opts.port ?? 5174, "127.0.0.1", r));
2745
+ // REJECT on a failed bind, don't crash. Without the error listener an
2746
+ // EADDRINUSE is an unhandled 'error' event on the server object, which is
2747
+ // fatal to the whole process — that raw Node stack (plus the package
2748
+ // manager's ELIFECYCLE after it) is exactly what a busy 5174 printed at
2749
+ // users, and `openEditServer` (edit-port.ts) cannot offer to attach to the
2750
+ // editor already running there unless the failure comes back as a rejection
2751
+ // it can catch. The listener is removed on success so a LATER runtime error
2752
+ // (a client resetting a connection) keeps whatever handling http gives it.
2753
+ await new Promise<void>((res, rej) => {
2754
+ const onError = (err: Error): void => rej(err);
2755
+ server.once("error", onError);
2756
+ server.listen(opts.port ?? 5174, "127.0.0.1", () => {
2757
+ server.off("error", onError);
2758
+ res();
2759
+ });
2760
+ });
2424
2761
  const addr = server.address();
2425
2762
  const port = typeof addr === "object" && addr ? addr.port : (opts.port ?? 5174);
2426
2763
  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.
@@ -10,13 +10,20 @@ import { produceArgv, type ProduceAnswers, type ProduceExtras } from "./produce-
10
10
  import { assertInteractive, confirm, intro, multiselect, select, text, unwrap } from "./prompts";
11
11
 
12
12
  /**
13
- * The produce wizard. Forty-one flags (plus the positional input path)
13
+ * The produce wizard. Forty-three 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),
21
+ * --color-grade (2026-08-30, the --resolution shape: a channel's look is a
22
+ * durable machine preference, set once as `colorGrade` in
23
+ * ~/.ossclip/config.json rather than re-picked per wizard run — and an
24
+ * honest prompt would need to enumerate ~/.ossclip/luts and preview five
25
+ * presets, a design nobody has made; --no-color-grade then mirrors
26
+ * --no-watermark's tier for the same off-is-default reason),
20
27
  * --captions (the mirror case: ON is already the default, so the
21
28
  * multiselect entry is the OFF switch and the positive flag exists only for
22
29
  * replay pinning), --add-jump-cuts (same mirror: auto already punches, the
@@ -48,6 +55,11 @@ const EXTRAS = [
48
55
  { value: "sourceIsEdited", label: "Source already has burned-in text", hint: "--source-is-edited" },
49
56
  { value: "captionsOff", label: "Turn the burned-in captions off", hint: "--no-captions" },
50
57
  { value: "jumpCutsOff", label: "No punch-in zooms at cuts", hint: "--no-jump-cuts" },
58
+ // Watermark/youtube polarity: OFF is the default and the entry only ever
59
+ // turns it ON — `--no-sfx` does not exist (program.ts declares `--sfx`
60
+ // alone so the config's `sfx` key can still supply it), so there is no OFF
61
+ // spelling to mirror in a second entry.
62
+ { value: "sfx", label: "Add sound effects (whoosh, ding…)", hint: "--sfx" },
51
63
  { value: "watermark", label: 'Credit the tool with a small "made with ossclip"', hint: "--watermark" },
52
64
  // The hint says the approval part out loud (thumbnail UX, 2026-08-16):
53
65
  // ticking this adds an interactive stop before the render, and a surprise
@@ -69,6 +81,15 @@ const EXTRAS = [
69
81
  * graphics is already on. Exported and kept pure so this can be asserted
70
82
  * without a TTY.
71
83
  *
84
+ * The sfx entry is gated on the same precedent (2026-08-29). Sound effects
85
+ * are placed against the producer's beat sheet, so a `--sfx` run without
86
+ * `--produce` reaches produce.ts's own "sound effects are placed against the
87
+ * producer's beat sheet — add --produce" warning and renders silent (a
88
+ * previous run's plan in the workdir is the only thing that saves it, which
89
+ * is not something a menu can promise). A guaranteed-inert menu item is the
90
+ * same broken offer as the clip one, softer only in that it warns instead of
91
+ * throwing — so it is only ever listed once graphics is already on.
92
+ *
72
93
  * `watermarkFromConfig` (review, minor a): on a config-on machine the
73
94
  * watermark entry sits UNCHECKED while the credit will render anyway —
74
95
  * unchecked is "don't emit the flag", not "off", and the multiselect has no
@@ -81,7 +102,9 @@ export function extrasFor(
81
102
  graphics: boolean,
82
103
  opts: { watermarkFromConfig?: boolean } = {},
83
104
  ): { value: (typeof EXTRAS)[number]["value"]; label: string; hint: string }[] {
84
- const list = graphics ? [...EXTRAS] : EXTRAS.filter((e) => e.value !== "graphicsClip");
105
+ const list = graphics
106
+ ? [...EXTRAS]
107
+ : EXTRAS.filter((e) => e.value !== "graphicsClip" && e.value !== "sfx");
85
108
  if (opts.watermarkFromConfig !== true) return [...list];
86
109
  return list.map((e) =>
87
110
  e.value === "watermark"
@@ -346,6 +369,26 @@ export async function produceWizard(
346
369
  // Same OFF-switch shape (the punch defaults ON, face-only): a tick maps
347
370
  // to `jumpCuts: false` and produceArgv emits `--no-jump-cuts`.
348
371
  if (chosen.includes("jumpCutsOff")) extras.jumpCuts = false;
372
+ if (chosen.includes("sfx")) {
373
+ // Follow-up under the same extra, like --clip's seconds prompt: the level
374
+ // only means anything once effects are on. `normal` is preselected
375
+ // because it is the CLI's own default — and picking it still emits the
376
+ // bare `--sfx` rather than `--sfx-level normal`, produceArgv's
377
+ // default-elision rule. The hints say what the level actually CHANGES
378
+ // (density, and whether the meme-tagged sounds are eligible at all),
379
+ // because "subtle/normal/meme" alone reads as a volume knob.
380
+ extras.sfx = unwrap(
381
+ await select({
382
+ message: "How much sound design?",
383
+ initialValue: "normal",
384
+ options: [
385
+ { value: "subtle", label: "subtle", hint: "a couple of accents a minute" },
386
+ { value: "normal", label: "normal", hint: "recommended" },
387
+ { value: "meme", label: "meme", hint: "denser, and meme-tagged sounds become eligible" },
388
+ ],
389
+ }),
390
+ ) as ProduceExtras["sfx"];
391
+ }
349
392
  if (chosen.includes("watermark")) extras.watermark = true;
350
393
  if (chosen.includes("youtube")) {
351
394
  extras.youtube = true;