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/editor-dist/assets/index-DSB_SCmp.js +166 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/edit-health.ts +51 -0
- package/src/edit-port.ts +296 -0
- package/src/edit.ts +273 -1
- package/src/interactive/offer-editor.ts +29 -4
- package/src/interactive/produce-argv.ts +18 -1
- package/src/interactive/produce-wizard.ts +40 -3
- package/src/produce.ts +467 -2
- package/src/program.ts +62 -6
- package/src/replay-argv.ts +30 -0
- package/editor-dist/assets/index-Dn813PGp.js +0 -166
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
|
-
|
|
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: {
|
|
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
|
-
|
|
68
|
-
|
|
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(
|
|
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) —
|
|
16
|
-
* behind one "anything else?" multiselect
|
|
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
|
|
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;
|