@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.
@@ -0,0 +1,29 @@
1
+ # Starter SFX pack — sources and licenses
2
+
3
+ Every file is license-clean for redistribution inside this repo and in
4
+ published, monetized videos.
5
+
6
+ ## Kenney "Interface Sounds" — CC0 (public domain)
7
+
8
+ Source: https://kenney.nl/assets/interface-sounds (zip:
9
+ `kenney_interface-sounds.zip`, downloaded 2026-08-29). License: Creative
10
+ Commons CC0. Transcoded to mono mp3 128k with ffmpeg.
11
+
12
+ | file | original |
13
+ |---|---|
14
+ | ding.mp3 | Audio/confirmation_001.ogg |
15
+ | pop.mp3 | Audio/drop_002.ogg |
16
+ | click.mp3 | Audio/click_002.ogg |
17
+ | error-buzz.mp3 | Audio/error_004.ogg |
18
+ | scratch.mp3 | Audio/scratch_003.ogg |
19
+
20
+ ## Synthesized in-repo — CC0
21
+
22
+ `whoosh-soft`, `whoosh-fast`, `swoosh-exit`, `riser-short`, `boom-dramatic`
23
+ and `tape-stop` are generated from pure ffmpeg expressions by
24
+ `synthesize.sh` in this directory (no third-party samples involved) and are
25
+ dedicated to the public domain under CC0. Rerun the script to regenerate.
26
+
27
+ The originally-planned "vine-boom"/"bruh" style voice memes are deliberately
28
+ NOT bundled — no CC0 source exists for them. Users who want them drop their
29
+ own files into `~/.ossclip/sfx/<pack>/` with a `pack.json`.
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "ossclip-starter",
3
+ "sounds": [
4
+ { "id": "whoosh-soft", "kind": "sound", "file": "whoosh-soft.mp3", "whenToUse": "A graphic or scene sliding in; gentle transitions between points.", "tags": [], "gain": 0.9, "durationSec": 0.7 },
5
+ { "id": "whoosh-fast", "kind": "sound", "file": "whoosh-fast.mp3", "whenToUse": "Quick cuts, list items landing, fast pace moments.", "tags": [], "gain": 0.9, "durationSec": 0.35 },
6
+ { "id": "swoosh-exit", "kind": "sound", "file": "swoosh-exit.mp3", "whenToUse": "Something being dismissed, deleted, or leaving the frame.", "tags": [], "gain": 0.9, "durationSec": 0.6 },
7
+ { "id": "riser-short", "kind": "sound", "file": "riser-short.mp3", "whenToUse": "Build-up right before a reveal, result, or big claim.", "tags": [], "gain": 0.8, "durationSec": 1.1 },
8
+ { "id": "ding", "kind": "sound", "file": "ding.mp3", "whenToUse": "Confirmation that something works; a key takeaway landing.", "tags": [], "gain": 1, "durationSec": 0.6 },
9
+ { "id": "pop", "kind": "sound", "file": "pop.mp3", "whenToUse": "A small element appearing; a light touch on a minor point.", "tags": [], "gain": 1, "durationSec": 0.3 },
10
+ { "id": "click", "kind": "sound", "file": "click.mp3", "whenToUse": "Describing a UI interaction: clicking, selecting, toggling.", "tags": [], "gain": 1, "durationSec": 0.2 },
11
+ { "id": "error-buzz", "kind": "sound", "file": "error-buzz.mp3", "whenToUse": "Something failing, a wrong approach, a bug being shown.", "tags": [], "gain": 0.9, "durationSec": 0.5 },
12
+ { "id": "scratch", "kind": "sound", "file": "scratch.mp3", "whenToUse": "A record-scratch 'wait, what?' pivot or sudden correction.", "tags": ["meme"], "gain": 1, "durationSec": 0.4 },
13
+ { "id": "boom-dramatic", "kind": "sound", "file": "boom-dramatic.mp3", "whenToUse": "Dramatic emphasis on an absurd or shocking statement.", "tags": ["meme"], "gain": 0.85, "durationSec": 1.6 },
14
+ { "id": "tape-stop", "kind": "sound", "file": "tape-stop.mp3", "whenToUse": "Everything stopping for an interjection or hard pivot.", "tags": ["meme"], "gain": 0.85, "durationSec": 1.1 }
15
+ ]
16
+ }
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,19 @@
1
+ #!/bin/sh
2
+ # Synthesizes the non-Kenney starter sounds from pure ffmpeg expressions, so
3
+ # the bundled pack carries zero third-party license risk beyond Kenney's CC0.
4
+ # Re-run to regenerate byte-similar (encoder-dependent) sources: sh synthesize.sh <outdir>
5
+ set -e
6
+ OUT="${1:-.}"
7
+ FF="${FFMPEG:-ffmpeg}"
8
+ # whoosh-soft: pink noise through a fading band sweep
9
+ $FF -y -f lavfi -i "anoisesrc=color=pink:duration=0.7:seed=7" -af "highpass=f=300,lowpass=f=2400,afade=t=in:d=0.15,afade=t=out:st=0.35:d=0.35,volume=1.4" "$OUT/whoosh-soft.wav"
10
+ # whoosh-fast: brighter, shorter
11
+ $FF -y -f lavfi -i "anoisesrc=color=white:duration=0.35:seed=11" -af "highpass=f=800,lowpass=f=5000,afade=t=in:d=0.05,afade=t=out:st=0.15:d=0.2,volume=1.2" "$OUT/whoosh-fast.wav"
12
+ # swoosh-exit: whoosh-soft reversed
13
+ $FF -y -f lavfi -i "anoisesrc=color=pink:duration=0.6:seed=13" -af "highpass=f=300,lowpass=f=2400,afade=t=in:d=0.1,afade=t=out:st=0.3:d=0.3,areverse,volume=1.3" "$OUT/swoosh-exit.wav"
14
+ # riser-short: quadratic pitch ramp + swelling noise
15
+ $FF -y -f lavfi -i "aevalsrc='0.35*sin(2*PI*(180+520*t*t)*t)*min(1,2.5*t)':d=1.1" -f lavfi -i "anoisesrc=color=pink:duration=1.1:seed=17" -filter_complex "[1]volume='0.25*t':eval=frame,highpass=f=600[n];[0][n]amix=inputs=2:duration=first,afade=t=out:st=0.95:d=0.15" "$OUT/riser-short.wav"
16
+ # boom-dramatic: sub sine thump with harmonic and long decay (the vine-boom register)
17
+ $FF -y -f lavfi -i "aevalsrc='0.95*sin(2*PI*55*t)*exp(-2.5*t)+0.3*sin(2*PI*110*t)*exp(-4*t)':d=1.6" -af "alimiter=limit=0.9" "$OUT/boom-dramatic.wav"
18
+ # tape-stop: 440->0 pitch slide via falling-rate expression
19
+ $FF -y -f lavfi -i "aevalsrc='0.5*sin(2*PI*330*(t-2.2*t*t/2/1.1))*(1-t/1.1)':d=1.1" -af "lowpass=f=3000" "$OUT/tape-stop.wav"
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossclip/core",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "ossclip's framework-free pipeline: schema, transcription, analysis, cutlist, captions, framing, and the LLM producer",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/assemble.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { Transcript } from "./schema";
2
2
  import type { Scene, SceneCue } from "./scene-schema";
3
3
  import { resolveSceneProps } from "./scene-registry";
4
+ import type { LoadedSfxSound } from "./sfx-pack";
5
+ import type { SfxPlacement, SfxValidationIssue } from "./producer/sfx";
4
6
  import type { TimeMap } from "./timemap";
5
7
 
6
8
  /** Scenes shorter than this on screen get extended; still shorter → dropped. */
@@ -121,3 +123,199 @@ export function assembleScenes(
121
123
 
122
124
  return { cues, dropped };
123
125
  }
126
+
127
+ /**
128
+ * One sound effect, ready for the renderer: a staged file, an OUTPUT-time
129
+ * instant, and the mix level it plays at. No word index survives — by this
130
+ * point the anchor has done its job (timemap.ts:13's "all overlay timings live
131
+ * in OUTPUT time").
132
+ */
133
+ export interface SfxCue {
134
+ /** Path under the render's public dir — `sfx/<id>.<ext>`, POSIX-literal. */
135
+ soundFile: string;
136
+ atSec: number;
137
+ /** The sound's own gain times the placement's, resolved once here. */
138
+ gain: number;
139
+ }
140
+
141
+ /** Where staged sounds live inside the render's public dir. */
142
+ export const SFX_PUBLIC_SUBDIR = "sfx";
143
+
144
+ /**
145
+ * The staged name for a sound, keyed on its ID rather than its own filename:
146
+ * two packs may both ship a `whoosh.mp3`, and the id is what the plan actually
147
+ * references (ids are unique by construction — `loadSfxLibrary` merges by id).
148
+ *
149
+ * POSIX-literal `/`, never `join()` — this is a URL `staticFile()` resolves,
150
+ * not a filesystem path, and a Windows `\` here would be served verbatim
151
+ * (produce.ts's `sideImageDestRel` lesson). The extension is taken with a
152
+ * regex rather than `extname` to keep this module free of node built-ins.
153
+ */
154
+ export function sfxStagedFile(sound: { id: string; file: string }): string {
155
+ const ext = /\.[^./\\]+$/.exec(sound.file)?.[0] ?? "";
156
+ return `${SFX_PUBLIC_SUBDIR}/${sound.id}${ext}`;
157
+ }
158
+
159
+ /**
160
+ * Scene id → the output second that scene STARTS on, the scene-timing context
161
+ * `resolveSfxCues` places a scene-anchored placement against (2026-08-29).
162
+ *
163
+ * Built from the FINAL cue list — after `applyOverrides`, the splits, the
164
+ * pinned-timing reclamp and the hidden-cue drop — because that is the only
165
+ * list that knows where the user actually put the graphic. Feeding it the
166
+ * producer's raw scenes would reintroduce the exact field failure the scene
167
+ * link exists to fix.
168
+ *
169
+ * Keyed on the ROOT id (`splitCues` mints a second half as `<root>@<split
170
+ * id>`) and keeping the EARLIEST start, because a sound marks an ENTRANCE: a
171
+ * half the user split off later is the same graphic continuing, not a second
172
+ * appearance. A hidden scene is absent from the cues and therefore from this
173
+ * map, which is what makes "scene gone" a fact the resolver can report.
174
+ *
175
+ * Pure and cue-shaped rather than `SceneCue`-typed so the EDITOR can build the
176
+ * same map from the cue list its player is drawing — one implementation, or
177
+ * the diamond and the render's cue disagree about where scene-3 starts.
178
+ */
179
+ export function sceneStartSeconds(
180
+ cues: readonly { id: string; startSec: number }[],
181
+ ): Map<string, number> {
182
+ const starts = new Map<string, number>();
183
+ for (const c of cues) {
184
+ const root = c.id.split("@")[0]!;
185
+ const prev = starts.get(root);
186
+ if (prev === undefined || c.startSec < prev) starts.set(root, c.startSec);
187
+ }
188
+ return starts;
189
+ }
190
+
191
+ /**
192
+ * Word-anchored SFX placements → output-timed cues, the `assembleScenes`
193
+ * contract for a track of instants rather than spans (PHASE1 §5).
194
+ *
195
+ * Every drop is reported with the SAME machine-readable `reason` vocabulary
196
+ * the planning passes use, so `formatSfxAccounting` can count planning drops
197
+ * and render drops in one line. `placement` is the index in the plan handed
198
+ * in, which is the plan `production.json` stores — the editor can name the
199
+ * entry a warning is about.
200
+ *
201
+ * `exists` is injected (the `defaultProviderName(env, hasBin)` seam): the
202
+ * check is a real one — a user who deletes a pack between planning and
203
+ * re-rendering must get a dropped cue, not a Remotion 404 after the render has
204
+ * already spent its minutes — but the filesystem stays the CALLER's business,
205
+ * so the whole matrix is testable without writing mp3s to a tmp dir. The
206
+ * default assumes present: a caller that cannot see a filesystem has no basis
207
+ * for dropping anything.
208
+ *
209
+ * `sceneStarts` is the other injected seam (`sceneStartSeconds` builds it):
210
+ * a placement carrying a `sceneId` fires at that scene's FINAL start — the
211
+ * user's moves and trims already in it — instead of at the word the model
212
+ * rationalised it against. Defaults to empty, which is honest rather than
213
+ * silent: with no scene context every scene link reports "scene gone" and
214
+ * falls back to its word, which is exactly what happens when the scene really
215
+ * is gone.
216
+ *
217
+ * Not every entry in `dropped` is a drop. "scene gone" is an ISSUE — the cue
218
+ * is still emitted, on the word anchor — and is named in the same list so the
219
+ * console and report.txt say why the effect moved (`SfxDropReasonSchema` owns
220
+ * the distinction, and `DROP_REASONS` keeps it out of the casualty count).
221
+ */
222
+ export function resolveSfxCues(
223
+ placements: readonly SfxPlacement[],
224
+ transcript: Transcript,
225
+ map: TimeMap,
226
+ sounds: readonly LoadedSfxSound[],
227
+ opts: {
228
+ exists?: (absPath: string) => boolean;
229
+ sceneStarts?: ReadonlyMap<string, number>;
230
+ } = {},
231
+ ): { cues: SfxCue[]; dropped: SfxValidationIssue[] } {
232
+ const exists = opts.exists ?? (() => true);
233
+ const sceneStarts = opts.sceneStarts ?? new Map<string, number>();
234
+ const byId = new Map(sounds.map((s) => [s.id, s]));
235
+ const cues: SfxCue[] = [];
236
+ const dropped: SfxValidationIssue[] = [];
237
+
238
+ for (let i = 0; i < placements.length; i++) {
239
+ const p = placements[i]!;
240
+ // Identity first, then position — `normalizeSfxPlan`'s pass order, so a
241
+ // placement naming a sound that no longer exists reads as an unknown
242
+ // sound whether it was dropped at planning or here.
243
+ const sound = byId.get(p.soundId);
244
+ if (!sound) {
245
+ dropped.push({
246
+ placement: i,
247
+ reason: "unknown sound",
248
+ issue: `"${p.soundId}" is not in the sound library any more`,
249
+ });
250
+ continue;
251
+ }
252
+ if (!exists(sound.absPath)) {
253
+ dropped.push({
254
+ placement: i,
255
+ reason: "missing file",
256
+ issue: `"${p.soundId}" points at ${sound.absPath}, which is gone`,
257
+ });
258
+ continue;
259
+ }
260
+ // The SCENE anchor outranks the word (field report, 2026-08-29): a sound
261
+ // placed "as the TitleCard enters" has to follow the card when the user
262
+ // moves or trims it, and the map already carries the scene's final start.
263
+ // The word passes below are SKIPPED when it resolves, deliberately — the
264
+ // graphic is on screen whether or not the speech that motivated it
265
+ // survived the cut, so a cut-word drop here would silence an effect that
266
+ // has a perfectly good instant to fire on.
267
+ let atSec = p.sceneId === undefined ? undefined : sceneStarts.get(p.sceneId);
268
+ if (atSec === undefined) {
269
+ if (p.sceneId !== undefined) {
270
+ // An ISSUE, not a drop: `word` is required precisely so a placement
271
+ // outlives the scene it was synced to (deleted, hidden or renumbered
272
+ // by a re-plan are all normal). Printed so report.txt says why the
273
+ // whoosh is back on the speech.
274
+ dropped.push({
275
+ placement: i,
276
+ reason: "scene gone",
277
+ issue: `"${p.soundId}" scene ${p.sceneId} gone — using word anchor`,
278
+ });
279
+ }
280
+ const word = transcript.words[p.word];
281
+ if (!word) {
282
+ dropped.push({
283
+ placement: i,
284
+ reason: "outside transcript",
285
+ issue: `word ${p.word} is beyond this transcript (${transcript.words.length} words)`,
286
+ });
287
+ continue;
288
+ }
289
+ // The cut check, `assembleScenes`' rule for a single anchor: a scene whose
290
+ // words were cut is dropped rather than slid to the nearest kept instant,
291
+ // and an effect is even less forgiving — it would fire over speech that
292
+ // never motivated it.
293
+ const mapped = map.mapWord(word);
294
+ if (mapped === null) {
295
+ dropped.push({
296
+ placement: i,
297
+ reason: "cut word",
298
+ issue: `"${p.soundId}" was anchored to word ${p.word} ("${word.text}"), which this cut removed`,
299
+ });
300
+ continue;
301
+ }
302
+ // The word's START: the effect fires as the word lands, which is what
303
+ // the model was asked to place ("the effect fires at that word").
304
+ atSec = mapped.start;
305
+ }
306
+ cues.push({
307
+ soundFile: sfxStagedFile(sound),
308
+ atSec,
309
+ // ONE multiplication, here — the renderer receives a number and does no
310
+ // mixing arithmetic of its own, so what the editor shows as a gain and
311
+ // what the render plays can never disagree.
312
+ gain: sound.gain * (p.gain ?? 1),
313
+ });
314
+ }
315
+
316
+ // Sorted by time: the plan is already word-sorted, but a cut can only ever
317
+ // preserve order, and a track the renderer walks in time order is one a
318
+ // human reading render-props.json can check against the video.
319
+ cues.sort((a, b) => a.atSec - b.atSec);
320
+ return { cues, dropped };
321
+ }
package/src/browser.ts CHANGED
@@ -11,6 +11,13 @@ export * from "./overrides";
11
11
  // The editor derives its plain takes with the SAME function the pipeline
12
12
  // uses — a copy would drift and the two timelines would disagree.
13
13
  export * from "./fill";
14
+ // The SFX lane positions scene-anchored markers off the SAME map produce
15
+ // resolves their cues from (`resolveSfxCues`) — a second "where does scene-3
16
+ // start" is how the editor's diamond and the render's cue would disagree.
17
+ // `assemble.ts` is browser-safe (type imports plus `scene-registry`, already
18
+ // on this surface), but only this one function is exposed: the rest of the
19
+ // module is the pipeline's, not the bundle's.
20
+ export { sceneStartSeconds } from "./assemble";
14
21
  export { ZOOM_MAX_SCALE, zoomScaleAt, type ZoomSegment } from "./zoom";
15
22
  // Pure geometry only — the ffmpeg/cache half lives in ./content-rect-detect
16
23
  // and must never enter the Remotion bundle.
package/src/config.ts CHANGED
@@ -76,6 +76,38 @@ export interface OssclipConfig {
76
76
  * (`resolveCoverInVideo`), the `watermark` contract exactly.
77
77
  */
78
78
  coverInVideo?: boolean;
79
+ /**
80
+ * Place sound effects on every produce run (`--sfx`), so a creator who
81
+ * always wants sound design writes it once instead of typing the flag.
82
+ * DEFAULT OFF: effects are an editorial choice, and inheriting them
83
+ * silently would change how every existing project sounds. `--sfx` wins
84
+ * per run (`resolveSfx`, the `watermark` contract).
85
+ */
86
+ sfx?: boolean;
87
+ /**
88
+ * How much sound design `--sfx` places: "subtle" | "normal" (the default) |
89
+ * "meme". File-only, the `resolution` posture: validated where it is USED
90
+ * (`resolveSfxLevel` in produce.ts), so a hand-edited "loud" earns one
91
+ * warning and `normal` rather than a coerced level — and never `meme`,
92
+ * which is the one that unlocks the meme-tagged sounds.
93
+ */
94
+ sfxLevel?: string;
95
+ /**
96
+ * Whether the bundled starter pack feeds the sound-effect menu. DEFAULT ON —
97
+ * it is the whole library for anyone who never wrote a pack. Set `false` and
98
+ * only `~/.ossclip/sfx` is loaded, which is the ask of someone whose own pack
99
+ * overrides a few stock ids and who wants the REST of them (pop, click,
100
+ * riser-short…) out of the model's menu entirely — overriding ids one at a
101
+ * time cannot do that.
102
+ *
103
+ * Config-level on purpose, with no CLI flag: which packs you own is a
104
+ * property of the machine, not of a run. File-only, the `sfxLevel` posture:
105
+ * validated where it is USED (`resolveSfxBundledPack`, next to the loader in
106
+ * sfx-pack.ts, because the edit server needs the same answer), so a
107
+ * hand-edited `"no"` earns one warning and the bundled pack, never a coerced
108
+ * exclusion.
109
+ */
110
+ sfxBundledPack?: boolean;
79
111
  /**
80
112
  * Terms of art the speaker uses — "JSON", "ossclip", "Genkit" — biasing
81
113
  * transcription (whisper `--prompt`), vouching repair corrections, and
@@ -152,6 +184,14 @@ export interface OssclipConfig {
152
184
  * (`publishConfigured` in the CLI), never coerced.
153
185
  */
154
186
  postizUrl?: string;
187
+ /**
188
+ * `--resolution`'s default for this machine: "auto" (keep what the source
189
+ * has, capped at 2160), "1080" (the built-in default), "1440" or "2160".
190
+ * File-only, the `watermark` posture: validated where it is USED
191
+ * (`resolveResolution` in produce.ts), so a hand-edited "4k" earns one
192
+ * warning and the 1080 default rather than a coerced render size.
193
+ */
194
+ resolution?: string;
155
195
  /**
156
196
  * USD per million tokens, keyed by model id or family substring — overrides
157
197
  * the built-in assumptions in `producer/usage.ts` so a run's cost line
@@ -218,22 +258,39 @@ export function loadConfig(): OssclipConfig {
218
258
  } catch {
219
259
  // no config file — fine
220
260
  }
261
+ return resolveConfig(fileCfg, process.env);
262
+ }
263
+
264
+ /**
265
+ * The pure half of `loadConfig` — the file-vs-env-vs-default resolution with
266
+ * no homedir read, so the mapping itself is testable (config.test.ts). Split
267
+ * out after the 2026-08-27 publish E2E: `postizUrl` sat on the TYPE and in
268
+ * the docs but this hand-written mapping never copied it, so publish reported
269
+ * "missing postizUrl" against a config.json that plainly had it — a key that
270
+ * exists only in the type is invisible at runtime, and nothing could say so
271
+ * while the mapping lived behind the filesystem.
272
+ */
273
+ export function resolveConfig(
274
+ fileCfg: Partial<OssclipConfig>,
275
+ env: NodeJS.ProcessEnv,
276
+ ): OssclipConfig {
221
277
  return {
222
- ffmpegPath: process.env.OSSCLIP_FFMPEG ?? fileCfg.ffmpegPath ?? DEFAULTS.ffmpegPath,
223
- ffprobePath: process.env.OSSCLIP_FFPROBE ?? fileCfg.ffprobePath ?? DEFAULTS.ffprobePath,
224
- whisperPath: process.env.OSSCLIP_WHISPER ?? fileCfg.whisperPath ?? DEFAULTS.whisperPath,
225
- modelDir: process.env.OSSCLIP_MODEL_DIR ?? fileCfg.modelDir ?? DEFAULTS.modelDir,
226
- model: process.env.OSSCLIP_MODEL ?? fileCfg.model ?? DEFAULTS.model,
227
- fastModel: process.env.OSSCLIP_FAST_MODEL ?? fileCfg.fastModel,
278
+ ffmpegPath: env.OSSCLIP_FFMPEG ?? fileCfg.ffmpegPath ?? DEFAULTS.ffmpegPath,
279
+ ffprobePath: env.OSSCLIP_FFPROBE ?? fileCfg.ffprobePath ?? DEFAULTS.ffprobePath,
280
+ whisperPath: env.OSSCLIP_WHISPER ?? fileCfg.whisperPath ?? DEFAULTS.whisperPath,
281
+ modelDir: env.OSSCLIP_MODEL_DIR ?? fileCfg.modelDir ?? DEFAULTS.modelDir,
282
+ model: env.OSSCLIP_MODEL ?? fileCfg.model ?? DEFAULTS.model,
283
+ fastModel: env.OSSCLIP_FAST_MODEL ?? fileCfg.fastModel,
228
284
  // File-only, the `dictionary` posture — and deliberately NO env spelling
229
285
  // (flag + config are the whole interface): validated where it is USED
230
286
  // (`resolveLlmEffort` in produce.ts), so a hand-edited `"max"` earns one
231
287
  // warning there and agy's default, never a coerced effort.
232
288
  llmEffort: fileCfg.llmEffort,
233
- speaker: process.env.OSSCLIP_SPEAKER ?? fileCfg.speaker,
234
- openEditorAfterProduce: (process.env.OSSCLIP_OPEN_EDITOR ??
235
- fileCfg.openEditorAfterProduce) as OpenEditorPref | undefined,
236
- browserExecutable: process.env.OSSCLIP_BROWSER ?? fileCfg.browserExecutable,
289
+ speaker: env.OSSCLIP_SPEAKER ?? fileCfg.speaker,
290
+ openEditorAfterProduce: (env.OSSCLIP_OPEN_EDITOR ?? fileCfg.openEditorAfterProduce) as
291
+ | OpenEditorPref
292
+ | undefined,
293
+ browserExecutable: env.OSSCLIP_BROWSER ?? fileCfg.browserExecutable,
237
294
  // File-only, like `pricing`: an env spelling would arrive as a string,
238
295
  // and "false" is truthy — parse-don't-coerce says no such trap. The
239
296
  // strict `=== true` check lives at the consumer (produce's
@@ -245,6 +302,18 @@ export function loadConfig(): OssclipConfig {
245
302
  // non-boolean stays OFF — the safe default for something that paints over
246
303
  // the first frames of the hook.
247
304
  coverInVideo: fileCfg.coverInVideo,
305
+ // File-only, `watermark`'s posture again: the strict `=== true` lives at
306
+ // the consumer (produce's `resolveSfx`) and the level is zod-parsed there
307
+ // (`resolveSfxLevel`), so a hand-edited non-boolean stays OFF and a
308
+ // misspelled level earns a warning and `normal`.
309
+ sfx: fileCfg.sfx,
310
+ sfxLevel: fileCfg.sfxLevel,
311
+ // File-only too, and NO env spelling for the `watermark` reason: "false"
312
+ // arrives truthy from an environment. The typeof check lives at the
313
+ // consumer (`resolveSfxBundledPack` in sfx-pack.ts), where an absent or
314
+ // malformed value keeps the bundled pack — the safe default, since the
315
+ // alternative is a library that may be empty.
316
+ sfxBundledPack: fileCfg.sfxBundledPack,
248
317
  // File-only for the same reason as `watermark`: these are structured
249
318
  // values a hand-editable JSON file supplies, and parse-don't-coerce says
250
319
  // the strict checks live at the consumer — `validDictionary` /
@@ -272,5 +341,10 @@ export function loadConfig(): OssclipConfig {
272
341
  thumbnailBrief: fileCfg.thumbnailBrief,
273
342
  thumbnailModel: fileCfg.thumbnailModel,
274
343
  pricing: fileCfg.pricing,
344
+ // File-only, non-secret by declaration (the field's own doc): the API key
345
+ // deliberately lives in the environment (publish.ts's
346
+ // `publishConfigured`), so this is only the instance URL.
347
+ postizUrl: fileCfg.postizUrl,
348
+ resolution: fileCfg.resolution,
275
349
  };
276
350
  }
package/src/exec.ts CHANGED
@@ -12,13 +12,24 @@ export interface ExecResult {
12
12
  export function run(
13
13
  bin: string,
14
14
  args: string[],
15
- opts: { allowNonZero?: boolean; stdin?: string } = {},
15
+ opts: {
16
+ allowNonZero?: boolean;
17
+ stdin?: string;
18
+ /** Per-chunk stdout tap, IN ADDITION to collection — the delivery
19
+ * encode's `-progress pipe:1` stream needs live chunks, not the
20
+ * post-mortem transcript. */
21
+ onStdout?: (chunk: string) => void;
22
+ } = {},
16
23
  ): Promise<ExecResult> {
17
24
  return new Promise((resolve, reject) => {
18
25
  const child = spawn(bin, args, { stdio: ["pipe", "pipe", "pipe"] });
19
26
  let stdout = "";
20
27
  let stderr = "";
21
- child.stdout.on("data", (c: Buffer) => (stdout += c.toString()));
28
+ child.stdout.on("data", (c: Buffer) => {
29
+ const text = c.toString();
30
+ stdout += text;
31
+ opts.onStdout?.(text);
32
+ });
22
33
  child.stderr.on("data", (c: Buffer) => (stderr += c.toString()));
23
34
  child.on("error", (err) => reject(new Error(`${bin} failed to start: ${err.message}`)));
24
35
  child.on("close", (code) => {
package/src/grounding.ts CHANGED
@@ -102,6 +102,39 @@ function stringsOf(value: unknown): string[] {
102
102
  return [];
103
103
  }
104
104
 
105
+ /**
106
+ * The tokens in `text` that the transcript nowhere supports, in text order,
107
+ * duplicates kept. This IS the grounding rule — `checkGrounding` walks scene
108
+ * fields through it, and the publish panel's caption regenerate runs it over
109
+ * a rewritten caption as an ADVISORY (captions legitimately contain brand and
110
+ * platform words never spoken, so its callers show notes, never a block).
111
+ * One spelling on purpose: the module header's §17 history is what a second
112
+ * copy of the supported() relaxation would eventually re-earn.
113
+ *
114
+ * The spoken set is rebuilt per call — cheap at real transcript sizes, and
115
+ * the price of keeping the rule callable on a single string.
116
+ *
117
+ * The transcript parameter demands only spoken text, which is all the rule
118
+ * reads: a full `Transcript` satisfies it, and so does the edit server's
119
+ * leniently-read transcript.json (words filtered to those with string text,
120
+ * timing not re-validated for a check that never looks at it).
121
+ */
122
+ export function ungroundedTokens(
123
+ text: string,
124
+ transcript: { words: ReadonlyArray<{ text: string }> },
125
+ speaker?: string,
126
+ ): string[] {
127
+ const spoken = new Set([
128
+ ...transcript.words.flatMap((w) => tokenize(w.text)),
129
+ ...(speaker ? tokenize(speaker) : []),
130
+ ]);
131
+ const supported = (token: string): boolean =>
132
+ spoken.has(token) ||
133
+ spoken.has(`${token}s`) ||
134
+ (token.endsWith("s") && spoken.has(token.slice(0, -1)));
135
+ return tokenize(text).filter((token) => needsSupport(token) && !supported(token));
136
+ }
137
+
105
138
  export function checkGrounding(
106
139
  scenes: readonly Scene[],
107
140
  transcript: Transcript,
@@ -113,25 +146,14 @@ export function checkGrounding(
113
146
  */
114
147
  speaker?: string,
115
148
  ): GroundingIssue[] {
116
- const spoken = new Set([
117
- ...transcript.words.flatMap((w) => tokenize(w.text)),
118
- ...(speaker ? tokenize(speaker) : []),
119
- ]);
120
- const supported = (token: string): boolean =>
121
- spoken.has(token) ||
122
- spoken.has(`${token}s`) ||
123
- (token.endsWith("s") && spoken.has(token.slice(0, -1)));
124
-
125
149
  const issues: GroundingIssue[] = [];
126
150
  for (const scene of scenes) {
127
151
  const fields = CHECKED_FIELDS[scene.component] ?? [];
128
152
  const merged = { ...scene.props, ...scene.overrides };
129
153
  for (const field of fields) {
130
154
  for (const text of stringsOf(merged[field])) {
131
- for (const token of tokenize(text)) {
132
- if (needsSupport(token) && !supported(token)) {
133
- issues.push({ sceneId: scene.id, component: scene.component, field, token });
134
- }
155
+ for (const token of ungroundedTokens(text, transcript, speaker)) {
156
+ issues.push({ sceneId: scene.id, component: scene.component, field, token });
135
157
  }
136
158
  }
137
159
  }
package/src/index.ts CHANGED
@@ -19,6 +19,7 @@ export * from "./blooper";
19
19
  export * from "./retake";
20
20
  export * from "./captions";
21
21
  export * from "./fonts";
22
+ export * from "./sfx-pack";
22
23
  export * from "./dictionary";
23
24
  export * from "./zoom";
24
25
  export * from "./grounding";
@@ -42,4 +43,5 @@ export * from "./export-premiere-xml";
42
43
  export * from "./export-premiere-project";
43
44
  export * from "./export-xmeml-util";
44
45
  export * from "./config";
46
+ export * from "./resolution";
45
47
  export { run } from "./exec";
package/src/ingest.ts CHANGED
@@ -137,7 +137,7 @@ export interface MezzanineScale {
137
137
  }
138
138
 
139
139
  /** Nearest even dimension — yuv420 chroma subsampling needs both axes even. */
140
- function evenDim(v: number): number {
140
+ export function evenDim(v: number): number {
141
141
  return Math.max(2, 2 * Math.round(v / 2));
142
142
  }
143
143