@ossclip/core 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.
@@ -0,0 +1,281 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { z } from "zod/v4";
6
+ import { CONFIG_DIR } from "./config";
7
+
8
+ /**
9
+ * One sound in a pack. `kind` is a `z.literal("sound")` rather than a plain
10
+ * string BECAUSE the field is reserved for future video memes: when that ships
11
+ * the literal becomes an enum, and until then an entry declaring any other
12
+ * kind must be skipped with a warning instead of loaded as a sound (a video
13
+ * clip handed to `<Audio>` is a broken render, not a degraded one).
14
+ *
15
+ * `tags` is free-form, but "meme" is the ONE tag with semantics: it gates the
16
+ * sound out of the menu below `--sfx-level meme`. Anything else is metadata a
17
+ * pack author writes for themselves.
18
+ */
19
+ export const SfxSoundSchema = z.object({
20
+ id: z.string().regex(/^[a-z0-9-]+$/),
21
+ kind: z.literal("sound"),
22
+ /** Relative to the pack directory — never absolute, never escaping it. */
23
+ file: z.string().min(1),
24
+ /**
25
+ * The line the LLM reads when picking this sound. Capped rather than
26
+ * unbounded because the whole library goes into every placement prompt;
27
+ * this is pack metadata (a human wrote it), so a bare `.max` that REJECTS
28
+ * is right here — the §112 "degrade instead of die" rule is about model
29
+ * output, and a pack author gets a named issue and a skipped entry.
30
+ */
31
+ whenToUse: z.string().min(1).max(200),
32
+ tags: z.array(z.string()).default([]),
33
+ /** Mix level for this sound, multiplied by any per-placement gain. */
34
+ gain: z.number().min(0).max(2).default(1),
35
+ durationSec: z.number().positive().optional(),
36
+ });
37
+ export type SfxSound = z.infer<typeof SfxSoundSchema>;
38
+
39
+ export const SfxPackSchema = z.object({
40
+ name: z.string().min(1),
41
+ sounds: z.array(SfxSoundSchema),
42
+ });
43
+ export type SfxPack = z.infer<typeof SfxPackSchema>;
44
+
45
+ /** The only tag the pipeline reads — see `SfxSoundSchema.tags`. */
46
+ export const SFX_MEME_TAG = "meme";
47
+
48
+ /** A sound resolved to a file on disk, with the pack it came from. */
49
+ export interface LoadedSfxSound extends SfxSound {
50
+ absPath: string;
51
+ packName: string;
52
+ }
53
+
54
+ /**
55
+ * Why a pack, or one entry in it, is not in the library. Every path that
56
+ * skips something emits one of these; nothing here throws, because a
57
+ * hand-written pack in `~/.ossclip/sfx` is user input and a typo in it must
58
+ * cost sound effects, not the produce run.
59
+ */
60
+ export interface SfxPackIssue {
61
+ /** Pack directory name, or the pack's declared name for the bundled pack. */
62
+ pack: string;
63
+ issue: string;
64
+ }
65
+
66
+ export interface SfxLibrary {
67
+ sounds: LoadedSfxSound[];
68
+ issues: SfxPackIssue[];
69
+ }
70
+
71
+ /**
72
+ * The bundled starter pack's directory. `import.meta.url` rather than a path
73
+ * relative to cwd, the `nastaliqFontFile()` shape (fonts.ts) — and the
74
+ * packaging test (R22 §111) scans for exactly that shape to prove `assets`
75
+ * rides in the npm tarball.
76
+ */
77
+ export function bundledSfxDir(): string {
78
+ return fileURLToPath(new URL("../assets/sfx", import.meta.url));
79
+ }
80
+
81
+ /** Where user packs live: `~/.ossclip/sfx/<pack>/pack.json`. */
82
+ export function userSfxDir(): string {
83
+ return join(CONFIG_DIR, "sfx");
84
+ }
85
+
86
+ /**
87
+ * Read one pack directory. Returns the sounds it can resolve plus an issue
88
+ * per entry it cannot — a missing mp3, an id that is not a slug, a `kind`
89
+ * this version does not render. Never throws: unreadable JSON is one issue
90
+ * for the whole pack.
91
+ */
92
+ function readPack(dir: string, label: string): SfxLibrary {
93
+ const issues: SfxPackIssue[] = [];
94
+ const manifest = join(dir, "pack.json");
95
+ let raw: unknown;
96
+ try {
97
+ raw = JSON.parse(readFileSync(manifest, "utf8"));
98
+ } catch (e) {
99
+ return { sounds: [], issues: [{ pack: label, issue: `unreadable pack.json: ${String(e)}` }] };
100
+ }
101
+ // Parsed shallowly first so ONE bad entry doesn't take the pack down with
102
+ // it (the scene-props batch fail-soft posture): the pack's own fields are
103
+ // validated here, each sound separately below.
104
+ const shell = z.object({ name: z.string().min(1), sounds: z.array(z.unknown()) }).safeParse(raw);
105
+ if (!shell.success) {
106
+ return { sounds: [], issues: [{ pack: label, issue: `invalid pack.json: ${shell.error.message}` }] };
107
+ }
108
+ const packName = shell.data.name;
109
+ const sounds: LoadedSfxSound[] = [];
110
+ for (const entry of shell.data.sounds) {
111
+ // Read `kind` BEFORE the schema so a future video-meme pack gets the
112
+ // reason it was skipped instead of a literal-mismatch error nobody can
113
+ // act on. v1 renders sounds only.
114
+ const kind = (entry as { kind?: unknown } | null)?.kind;
115
+ const id = (entry as { id?: unknown } | null)?.id;
116
+ const named = typeof id === "string" ? id : "<unnamed>";
117
+ if (typeof kind === "string" && kind !== "sound") {
118
+ issues.push({ pack: packName, issue: `skipped "${named}": kind "${kind}" is not supported yet` });
119
+ continue;
120
+ }
121
+ const parsed = SfxSoundSchema.safeParse(entry);
122
+ if (!parsed.success) {
123
+ issues.push({ pack: packName, issue: `skipped "${named}": ${parsed.error.message}` });
124
+ continue;
125
+ }
126
+ const absPath = join(dir, parsed.data.file);
127
+ if (!existsSync(absPath)) {
128
+ issues.push({ pack: packName, issue: `skipped "${parsed.data.id}": missing file ${parsed.data.file}` });
129
+ continue;
130
+ }
131
+ sounds.push({ ...parsed.data, absPath, packName });
132
+ }
133
+ return { sounds, issues };
134
+ }
135
+
136
+ /**
137
+ * The bundled starter pack's label in `SfxPackIssue.pack` — the pack's declared
138
+ * name, which is also what `readPack` falls back to when its manifest is
139
+ * unreadable and there is no declared name to quote.
140
+ */
141
+ const BUNDLED_PACK_LABEL = "ossclip-starter";
142
+
143
+ /**
144
+ * The `sfxBundledPack` config key, resolved. Default TRUE — the bundled pack is
145
+ * the library everyone who never wrote a pack has, so an absent key must keep
146
+ * the shipped behaviour.
147
+ *
148
+ * `typeof === "boolean"`, never truthiness (CLAUDE.md's parse-don't-coerce):
149
+ * a hand-edited `"sfxBundledPack": "no"` is a string, and coercing it would
150
+ * read as `true` — the opposite of what its author typed. It earns one warning
151
+ * and the default instead, and the warning is RETURNED rather than printed so
152
+ * this stays pure (`resolveSfxLevel`'s shape).
153
+ *
154
+ * It lives HERE, next to the loader, rather than beside its siblings in the
155
+ * CLI's produce.ts, because BOTH consumers need it — produce's sfx step and
156
+ * the edit server's sfx routes — and produce.ts already imports edit.ts, so
157
+ * the reverse import that would share it is a cycle. Two copies of this rule
158
+ * is the failure the editor cannot afford: it would offer sounds produce
159
+ * refuses to use.
160
+ */
161
+ export function resolveSfxBundledPack(configValue: unknown): {
162
+ include: boolean;
163
+ warning?: string;
164
+ } {
165
+ if (typeof configValue === "boolean") return { include: configValue };
166
+ if (configValue === undefined) return { include: true };
167
+ return {
168
+ include: true,
169
+ warning:
170
+ "⚠ config sfxBundledPack ignored — expected true or false, " +
171
+ "keeping the bundled pack in the library",
172
+ };
173
+ }
174
+
175
+ /**
176
+ * The bundled pack plus every user pack under `userDir`, merged by id.
177
+ *
178
+ * `includeBundled: false` (config `sfxBundledPack`) drops the bundled pack
179
+ * entirely, so ONLY `~/.ossclip/sfx` feeds the placement menu. It is not the
180
+ * same as overriding ids one by one: a user with their own pack still met
181
+ * `pop`, `click` and `riser-short` in every prompt, and the only way to get
182
+ * them out of the model's menu is to not load them.
183
+ *
184
+ * Duplicate policy:
185
+ * - user pack over bundled, silently — overriding a stock sound with your own
186
+ * recording is the WANTED case, not an error.
187
+ * - user vs user: the alphabetically first pack directory wins, with an issue,
188
+ * so the outcome is stable across filesystems that enumerate differently
189
+ * (readdir order is not a promise) and the loser is named out loud.
190
+ *
191
+ * `userDir` is a parameter with a default rather than a read of `homedir()`
192
+ * inside, so tests point it at a tmp dir and never touch a real home.
193
+ */
194
+ export function loadSfxLibrary(
195
+ opts: { userDir?: string; includeBundled?: boolean } = {},
196
+ ): SfxLibrary {
197
+ const userDir = opts.userDir ?? userSfxDir();
198
+ const includeBundled = opts.includeBundled ?? true;
199
+ const issues: SfxPackIssue[] = [];
200
+ const byId = new Map<string, LoadedSfxSound>();
201
+ /** Which pack currently owns each id, and whether it was a user pack. */
202
+ const owner = new Map<string, { pack: string; user: boolean }>();
203
+
204
+ if (includeBundled) {
205
+ const bundled = readPack(bundledSfxDir(), BUNDLED_PACK_LABEL);
206
+ issues.push(...bundled.issues);
207
+ for (const s of bundled.sounds) {
208
+ byId.set(s.id, s);
209
+ owner.set(s.id, { pack: s.packName, user: false });
210
+ }
211
+ }
212
+
213
+ let dirs: string[] = [];
214
+ try {
215
+ dirs = readdirSync(userDir, { withFileTypes: true })
216
+ .filter((e) => e.isDirectory())
217
+ .map((e) => e.name)
218
+ .sort();
219
+ } catch {
220
+ // No user pack directory at all is the normal case, not an issue.
221
+ dirs = [];
222
+ }
223
+ for (const name of dirs) {
224
+ const dir = join(userDir, name);
225
+ if (!existsSync(join(dir, "pack.json"))) continue; // not a pack, not an error
226
+ const pack = readPack(dir, name);
227
+ issues.push(...pack.issues);
228
+ for (const s of pack.sounds) {
229
+ const held = owner.get(s.id);
230
+ if (held?.user) {
231
+ issues.push({
232
+ pack: s.packName,
233
+ issue: `duplicate id "${s.id}" — keeping the one from "${held.pack}" (first pack alphabetically)`,
234
+ });
235
+ continue;
236
+ }
237
+ byId.set(s.id, s);
238
+ owner.set(s.id, { pack: s.packName, user: true });
239
+ }
240
+ }
241
+
242
+ // Sorted by id so the menu, the hash and every report read the same on
243
+ // every machine — merge order must not leak into the prompt.
244
+ const sounds = [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
245
+ // Excluded the only pack there was. Every caller already warns-and-skips on
246
+ // an empty library, but "no usable sounds" alone reads as a packaging bug in
247
+ // ossclip; the user turned this off in config and has nothing else on disk,
248
+ // and only the loader knows that. So it is named here, as an issue like any
249
+ // other, and the existing zero-sounds path prints it verbatim.
250
+ if (!includeBundled && sounds.length === 0) {
251
+ issues.push({
252
+ pack: BUNDLED_PACK_LABEL,
253
+ issue:
254
+ `bundled pack excluded ("sfxBundledPack": false) and no user packs found in ` +
255
+ `${userDir} — add a pack there, or set "sfxBundledPack": true to get it back`,
256
+ });
257
+ }
258
+ return { sounds, issues };
259
+ }
260
+
261
+ /**
262
+ * A fingerprint of the library as the MODEL sees it — id, whenToUse, tags,
263
+ * gain — and deliberately not the audio bytes or the file paths.
264
+ *
265
+ * This rides the placement cache key, so hashing the mp3s would re-bill an
266
+ * LLM call every time a pack is re-encoded at a different bitrate, for a
267
+ * prompt that is byte-identical. Conversely an edited `whenToUse` DOES change
268
+ * what the model was asked, so it must invalidate.
269
+ */
270
+ export function sfxLibraryHash(sounds: readonly SfxSound[]): string {
271
+ const material = [...sounds]
272
+ .map((s) => ({
273
+ id: s.id,
274
+ whenToUse: s.whenToUse,
275
+ // Sorted: tag order is authoring noise, not a different library.
276
+ tags: [...s.tags].sort(),
277
+ gain: s.gain,
278
+ }))
279
+ .sort((a, b) => a.id.localeCompare(b.id));
280
+ return createHash("sha256").update(JSON.stringify(material)).digest("hex");
281
+ }