@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,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
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:
|
|
223
|
-
ffprobePath:
|
|
224
|
-
whisperPath:
|
|
225
|
-
modelDir:
|
|
226
|
-
model:
|
|
227
|
-
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:
|
|
234
|
-
openEditorAfterProduce: (
|
|
235
|
-
|
|
236
|
-
|
|
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: {
|
|
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) =>
|
|
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
|
|
132
|
-
|
|
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
|
|