@ossclip/core 0.1.33 → 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/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 +7 -0
- package/src/config.ts +84 -10
- package/src/exec.ts +13 -2
- package/src/grounding.ts +35 -13
- package/src/index.ts +2 -0
- package/src/ingest.ts +1 -1
- package/src/overrides.ts +248 -1
- package/src/producer/caption-regen.ts +132 -0
- package/src/producer/index.ts +2 -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/publish/captions.ts +25 -2
- package/src/publish/delivery.ts +303 -0
- package/src/publish/index.ts +3 -0
- package/src/publish/limits.ts +52 -0
- package/src/publish/postiz.ts +113 -25
- package/src/publish/progress.ts +75 -0
- package/src/publish/provider.ts +24 -1
- package/src/resolution.ts +114 -0
- package/src/schema.ts +49 -0
- package/src/sfx-pack.ts +281 -0
- package/src/transcribe.ts +13 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { z } from "zod/v4";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How big the output actually renders (2026-08-27).
|
|
5
|
+
*
|
|
6
|
+
* ossclip rendered 1080×1920 unconditionally, and three separate stages
|
|
7
|
+
* enforced it: the folder-concat target, the mezzanine's scale filter, and
|
|
8
|
+
* the render. A 4K take therefore lost three quarters of its pixels before
|
|
9
|
+
* anything looked at it — invisible on LinkedIn/Instagram/TikTok, which cap
|
|
10
|
+
* at 1080p anyway, but real on YouTube, which keeps 4K and gives it a better
|
|
11
|
+
* codec tier.
|
|
12
|
+
*
|
|
13
|
+
* This is the ONE place that decides the size, so those three stages cannot
|
|
14
|
+
* disagree. It returns a SCALE FACTOR, not a stage to build from: the
|
|
15
|
+
* composition must stay 1080-wide because `captionFontSizeFor` (scenes/
|
|
16
|
+
* stage.ts) answers in ABSOLUTE px — 64 portrait, 44 landscape — so a
|
|
17
|
+
* composition built at 2160 would draw captions a quarter of their intended
|
|
18
|
+
* size. Remotion's own `scale` renders that same composition larger, fonts
|
|
19
|
+
* and strokes included.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** `--resolution`: an explicit short-edge height, or `auto` from the source. */
|
|
23
|
+
export const RESOLUTION_CHOICES = ["auto", "1080", "1440", "2160"] as const;
|
|
24
|
+
|
|
25
|
+
export type ResolutionChoice = (typeof RESOLUTION_CHOICES)[number];
|
|
26
|
+
|
|
27
|
+
/** The gate every user-supplied resolution passes through — flag AND config,
|
|
28
|
+
* so a hand-edited `"resolution": "4k"` earns the same refusal as a typo'd
|
|
29
|
+
* flag rather than a silent fallback (CLAUDE.md: parse, never coerce). */
|
|
30
|
+
export const ResolutionChoiceSchema = z.enum(RESOLUTION_CHOICES);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The ceiling `auto` will not cross, as a short-edge height. An 8K source
|
|
34
|
+
* answers 2160 rather than 4320: h264 at 8K is not universally playable, and
|
|
35
|
+
* the render cost grows with the pixel count.
|
|
36
|
+
*/
|
|
37
|
+
export const MAX_AUTO_HEIGHT = 2160;
|
|
38
|
+
|
|
39
|
+
/** The base short edge both frames share — the unit every choice divides by. */
|
|
40
|
+
const BASE_SHORT_EDGE = 1080;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `auto` snaps DOWN to a half step (1, 1.5, 2). Two reasons, both hard:
|
|
44
|
+
* h264 needs EVEN dimensions, and 1080/1920 times a half step is always even
|
|
45
|
+
* while an arbitrary factor is not (1.125 → 1215, odd); and rounding odd
|
|
46
|
+
* dimensions to even would drift the frame off 9:16, which the platforms
|
|
47
|
+
* letterbox. Snapping down rather than up keeps the promise that auto never
|
|
48
|
+
* invents detail the source does not have.
|
|
49
|
+
*/
|
|
50
|
+
const AUTO_STEP = 0.5;
|
|
51
|
+
|
|
52
|
+
export interface OutputFrame {
|
|
53
|
+
/** What Remotion renders the 1080-wide composition at. */
|
|
54
|
+
scale: number;
|
|
55
|
+
/** The resulting file's dimensions — what `production.json` records. */
|
|
56
|
+
width: number;
|
|
57
|
+
height: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The clip a FOLDER input can honestly be sized by: the smallest.
|
|
62
|
+
*
|
|
63
|
+
* A folder concat letterboxes every take into one frame (`buildConcatFilter`),
|
|
64
|
+
* so the frame carries only what the weakest clip has — sizing by the largest
|
|
65
|
+
* would upscale every other take and charge render time for invented pixels.
|
|
66
|
+
* Clips that failed to probe are ignored rather than counted as zero, and a
|
|
67
|
+
* listing with nothing usable answers `null` so the caller falls back to its
|
|
68
|
+
* default instead of sizing a render off a guess.
|
|
69
|
+
*/
|
|
70
|
+
export function smallestSource(
|
|
71
|
+
sizes: ReadonlyArray<{ width: number; height: number }>,
|
|
72
|
+
): { width: number; height: number } | null {
|
|
73
|
+
const usable = sizes.filter((s) => s.width > 0 && s.height > 0);
|
|
74
|
+
if (usable.length === 0) return null;
|
|
75
|
+
return usable.reduce((min, s) => (s.width * s.height < min.width * min.height ? s : min));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function resolveOutputFrame(args: {
|
|
79
|
+
frame: { width: number; height: number };
|
|
80
|
+
/** The source's DISPLAY dimensions (rotation already applied by the probe). */
|
|
81
|
+
source: { width: number; height: number };
|
|
82
|
+
resolution: ResolutionChoice;
|
|
83
|
+
}): OutputFrame {
|
|
84
|
+
const { frame, source, resolution } = args;
|
|
85
|
+
const at = (scale: number): OutputFrame => ({
|
|
86
|
+
scale,
|
|
87
|
+
width: Math.round(frame.width * scale),
|
|
88
|
+
height: Math.round(frame.height * scale),
|
|
89
|
+
});
|
|
90
|
+
if (resolution !== "auto") {
|
|
91
|
+
return at(Number(resolution) / BASE_SHORT_EDGE);
|
|
92
|
+
}
|
|
93
|
+
// A probe that answered nothing cannot size anything: today's 1080p is the
|
|
94
|
+
// honest fallback, never a throw in the middle of a render.
|
|
95
|
+
if (!(source.width > 0) || !(source.height > 0)) return at(1);
|
|
96
|
+
|
|
97
|
+
// The pixels that SURVIVE the crop, not the ones the file advertises. The
|
|
98
|
+
// source is fitted to the output's aspect and the overflow is cropped
|
|
99
|
+
// (produce's own `force_original_aspect_ratio=increase,crop=`), so the
|
|
100
|
+
// usable width is whichever edge binds.
|
|
101
|
+
const frameAspect = frame.width / frame.height;
|
|
102
|
+
const sourceAspect = source.width / source.height;
|
|
103
|
+
const usableWidth =
|
|
104
|
+
sourceAspect > frameAspect
|
|
105
|
+
? source.height * frameAspect // wider than the frame: sides are cropped
|
|
106
|
+
: source.width; // taller than the frame: top/bottom cropped
|
|
107
|
+
|
|
108
|
+
const raw = usableWidth / frame.width;
|
|
109
|
+
const snapped = Math.floor(raw / AUTO_STEP) * AUTO_STEP;
|
|
110
|
+
const capped = Math.min(snapped, MAX_AUTO_HEIGHT / BASE_SHORT_EDGE);
|
|
111
|
+
// Never below today's output: a 720p source still renders 1080p, which is
|
|
112
|
+
// what every caller already depends on.
|
|
113
|
+
return at(Math.max(capped, 1));
|
|
114
|
+
}
|
package/src/schema.ts
CHANGED
|
@@ -99,6 +99,43 @@ export type RenderSettings = z.infer<typeof RenderSettingsSchema>;
|
|
|
99
99
|
|
|
100
100
|
import { SceneSchema, ThemeSchema } from "./scene-schema";
|
|
101
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The sound-effect plan as `production.json` STORES it: the level it was
|
|
104
|
+
* planned at plus the surviving placements.
|
|
105
|
+
*
|
|
106
|
+
* Restated here rather than imported from `producer/sfx.ts` on purpose, and
|
|
107
|
+
* the duplication is load-bearing: this module is in the EDITOR's runtime
|
|
108
|
+
* graph (browser.ts → overrides.ts → `RemovalReasonSchema`), while
|
|
109
|
+
* `producer/sfx.ts` reaches `beats.ts` → `cover.ts` → `node:child_process`.
|
|
110
|
+
* Importing the model-facing schema here would drag the whole producer — and
|
|
111
|
+
* node — into the Remotion/editor bundle. The two shapes are pinned equal by
|
|
112
|
+
* a test (`sfx-production-slot.test.ts`), which is what keeps this from
|
|
113
|
+
* becoming a silent second truth: the model-facing one caps `rationale` on the
|
|
114
|
+
* way IN (R27 §123's `cappedText`), and by the time a placement is stored the
|
|
115
|
+
* cap has already been applied.
|
|
116
|
+
*/
|
|
117
|
+
export const ProductionSfxSchema = z.object({
|
|
118
|
+
level: z.enum(["subtle", "normal", "meme"]),
|
|
119
|
+
placements: z.array(
|
|
120
|
+
z.object({
|
|
121
|
+
soundId: z.string(),
|
|
122
|
+
/** Index into the REPAIRED transcript — word indices, never seconds. */
|
|
123
|
+
word: z.number().int().nonnegative(),
|
|
124
|
+
/**
|
|
125
|
+
* The scene whose ENTRANCE this sound marks, when it has one
|
|
126
|
+
* (2026-08-29). Optional and absent-means-speech-synced, so every plan
|
|
127
|
+
* written before the field parses byte-identically. `word` above stays
|
|
128
|
+
* required beside it — it is the fallback when the scene is deleted or
|
|
129
|
+
* re-planned away (`SfxPlacementSchema` owns the full argument).
|
|
130
|
+
*/
|
|
131
|
+
sceneId: z.string().optional(),
|
|
132
|
+
gain: z.number().min(0).max(2).optional(),
|
|
133
|
+
rationale: z.string().optional(),
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
});
|
|
137
|
+
export type ProductionSfx = z.infer<typeof ProductionSfxSchema>;
|
|
138
|
+
|
|
102
139
|
/** The single source of truth for a production. Every pipeline stage is a pure function over this. */
|
|
103
140
|
export const ProductionSchema = z.object({
|
|
104
141
|
version: z.literal(1),
|
|
@@ -184,6 +221,18 @@ export const ProductionSchema = z.object({
|
|
|
184
221
|
})
|
|
185
222
|
.optional(),
|
|
186
223
|
scenes: z.array(SceneSchema).optional(),
|
|
224
|
+
/**
|
|
225
|
+
* The `--sfx` placement plan (level + word-anchored placements), post
|
|
226
|
+
* `normalizeSfxPlan`. Optional and absent-means-no-sound-design, so every
|
|
227
|
+
* pre-feature `production.json` parses unchanged — and so a run without the
|
|
228
|
+
* flag writes a file byte-identical to what it always wrote.
|
|
229
|
+
*
|
|
230
|
+
* WORDS, not seconds, like every other anchor in this file: the cutlist can
|
|
231
|
+
* change under a re-render, and a second-stamped placement would drift off
|
|
232
|
+
* the moment it does (the resolver re-derives output time through the
|
|
233
|
+
* TimeMap, `resolveSfxCues`).
|
|
234
|
+
*/
|
|
235
|
+
sfx: ProductionSfxSchema.optional(),
|
|
187
236
|
/**
|
|
188
237
|
* WHO planned this production (R16 §78). `usage.json` answers "what did
|
|
189
238
|
* that cost", but it describes one run and a fully-cached re-run makes no
|
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
|
+
}
|
package/src/transcribe.ts
CHANGED
|
@@ -235,6 +235,16 @@ export interface WhisperOptions {
|
|
|
235
235
|
* silently dropping the user's terms.
|
|
236
236
|
*/
|
|
237
237
|
prompt?: string;
|
|
238
|
+
/**
|
|
239
|
+
* whisper's TRANSLATE task (`-tr`): decode non-English speech straight to
|
|
240
|
+
* ENGLISH text (2026-08-29). Orthogonal to `language`, which only says what
|
|
241
|
+
* is being SPOKEN — `-l ur` alone emits Urdu script, correct for Urdu
|
|
242
|
+
* captions and wrong for an English-captioned short. Pass both together:
|
|
243
|
+
* whisper still needs to know the source language to decode it well.
|
|
244
|
+
*
|
|
245
|
+
* Left unset, the spawned args stay byte-identical to every prior run.
|
|
246
|
+
*/
|
|
247
|
+
translate?: boolean;
|
|
238
248
|
}
|
|
239
249
|
|
|
240
250
|
/**
|
|
@@ -261,6 +271,9 @@ export function whisperArgs(opts: WhisperOptions, wavPath: string): string[] {
|
|
|
261
271
|
"--no-prints",
|
|
262
272
|
];
|
|
263
273
|
if (opts.language !== undefined) args.push("-l", opts.language);
|
|
274
|
+
// AFTER `-l`: whisper reads the pair as "this language, translated", and
|
|
275
|
+
// keeping the order fixed is what makes the arg list assertable.
|
|
276
|
+
if (opts.translate === true) args.push("-tr");
|
|
264
277
|
if (opts.prompt !== undefined) args.push("--prompt", opts.prompt);
|
|
265
278
|
return args;
|
|
266
279
|
}
|