@ossclip/core 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/assets/sfx/ATTRIBUTION.md +29 -0
- package/assets/sfx/boom-dramatic.mp3 +0 -0
- package/assets/sfx/click.mp3 +0 -0
- package/assets/sfx/ding.mp3 +0 -0
- package/assets/sfx/error-buzz.mp3 +0 -0
- package/assets/sfx/pack.json +16 -0
- package/assets/sfx/pop.mp3 +0 -0
- package/assets/sfx/riser-short.mp3 +0 -0
- package/assets/sfx/scratch.mp3 +0 -0
- package/assets/sfx/swoosh-exit.mp3 +0 -0
- package/assets/sfx/synthesize.sh +19 -0
- package/assets/sfx/tape-stop.mp3 +0 -0
- package/assets/sfx/whoosh-fast.mp3 +0 -0
- package/assets/sfx/whoosh-soft.mp3 +0 -0
- package/package.json +1 -1
- package/src/assemble.ts +198 -0
- package/src/browser.ts +22 -0
- package/src/color-grade.ts +562 -0
- package/src/config.ts +66 -0
- package/src/index.ts +3 -0
- package/src/ingest.ts +51 -8
- package/src/lut-library.ts +81 -0
- package/src/overrides.ts +280 -1
- package/src/producer/index.ts +1 -0
- package/src/producer/mock.ts +34 -1
- package/src/producer/scene-props.ts +20 -2
- package/src/producer/sfx.ts +465 -0
- package/src/recut.ts +32 -3
- package/src/scene-schema.ts +3 -0
- package/src/schema.ts +49 -0
- package/src/sfx-pack.ts +281 -0
package/src/sfx-pack.ts
ADDED
|
@@ -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
|
+
}
|