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/editor-dist/assets/index-pWbFr8vc.js +168 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/cover.ts +18 -0
- package/src/edit-health.ts +51 -0
- package/src/edit-port.ts +296 -0
- package/src/edit.ts +340 -3
- package/src/interactive/offer-editor.ts +29 -4
- package/src/interactive/produce-argv.ts +18 -1
- package/src/interactive/produce-wizard.ts +47 -4
- package/src/produce.ts +652 -3
- package/src/program.ts +83 -6
- package/src/replay-argv.ts +30 -0
- package/editor-dist/assets/index-Dn813PGp.js +0 -166
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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.
|
|
@@ -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-
|
|
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) —
|
|
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),
|
|
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
|
|
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;
|