ossclip 0.1.34 → 0.1.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/editor-dist/assets/index-DSB_SCmp.js +166 -0
- package/editor-dist/index.html +1 -1
- package/package.json +4 -4
- package/src/edit-health.ts +51 -0
- package/src/edit-port.ts +296 -0
- package/src/edit.ts +273 -1
- package/src/interactive/offer-editor.ts +29 -4
- package/src/interactive/produce-argv.ts +18 -1
- package/src/interactive/produce-wizard.ts +40 -3
- package/src/produce.ts +467 -2
- package/src/program.ts +62 -6
- package/src/replay-argv.ts +30 -0
- package/editor-dist/assets/index-Dn813PGp.js +0 -166
package/src/produce.ts
CHANGED
|
@@ -156,6 +156,8 @@ import {
|
|
|
156
156
|
sliceTranscript,
|
|
157
157
|
type Analysis,
|
|
158
158
|
type AppliedRepair,
|
|
159
|
+
type BeatSheet,
|
|
160
|
+
BeatSheetSchema,
|
|
159
161
|
type BeatsValidationIssue,
|
|
160
162
|
type CleanupLevel,
|
|
161
163
|
type ClipWindow,
|
|
@@ -170,6 +172,34 @@ import {
|
|
|
170
172
|
RESOLUTION_CHOICES,
|
|
171
173
|
smallestSource,
|
|
172
174
|
ResolutionChoiceSchema,
|
|
175
|
+
// The --sfx path (2026-08-29): the library loader, the placement call, its
|
|
176
|
+
// deterministic gate, and the resolver that turns word anchors into cues.
|
|
177
|
+
loadSfxLibrary,
|
|
178
|
+
sfxLibraryHash,
|
|
179
|
+
// The `sfxBundledPack` config gate. It lives in core, not beside `resolveSfx`
|
|
180
|
+
// below, because the edit server has to resolve it identically — its own
|
|
181
|
+
// doc-comment has the reason.
|
|
182
|
+
resolveSfxBundledPack,
|
|
183
|
+
generateSfxPlan,
|
|
184
|
+
resolveSfxCues,
|
|
185
|
+
// Scene id → final start second, the scene-anchored placements' clock
|
|
186
|
+
// (2026-08-29). Built from `sceneCues`, never from the raw plan.
|
|
187
|
+
sceneStartSeconds,
|
|
188
|
+
// The user's layer over that plan (Phase 3), and the schema the carried
|
|
189
|
+
// forward plan is parsed back through.
|
|
190
|
+
applySfxOverrides,
|
|
191
|
+
ProductionSfxSchema,
|
|
192
|
+
sfxStagedFile,
|
|
193
|
+
formatSfxAccounting,
|
|
194
|
+
SfxLevelSchema,
|
|
195
|
+
SfxPlanSchema,
|
|
196
|
+
SfxValidationIssueSchema,
|
|
197
|
+
SFX_PROMPT_VERSION,
|
|
198
|
+
type LoadedSfxSound,
|
|
199
|
+
type SfxCue,
|
|
200
|
+
type SfxLevel,
|
|
201
|
+
type SfxPlacement,
|
|
202
|
+
type SfxValidationIssue,
|
|
173
203
|
type ProviderName,
|
|
174
204
|
type ResolutionChoice,
|
|
175
205
|
type Scene,
|
|
@@ -417,6 +447,38 @@ export function existingProducerStamp(work: string): Production["producer"] | un
|
|
|
417
447
|
}
|
|
418
448
|
}
|
|
419
449
|
|
|
450
|
+
/**
|
|
451
|
+
* The `sfx` plan the workdir's LAST run wrote, or undefined (Phase 3).
|
|
452
|
+
*
|
|
453
|
+
* This is how a `--scenes` replay keeps its sound design. The editor's Render
|
|
454
|
+
* pins the reviewed plan with `--scenes` and drops `--produce`
|
|
455
|
+
* (render-replay-args.ts), so the run never reaches the placement call — and
|
|
456
|
+
* the placement call is not what should decide, because the SCENES-REVIEWED
|
|
457
|
+
* doctrine says a render started from the editor must reproduce what the user
|
|
458
|
+
* reviewed. For SFX that reviewed state is exactly `production.json`'s plan
|
|
459
|
+
* plus `overrides.json`'s edits on top of it: re-placing would hand the user a
|
|
460
|
+
* different set of effects than the ones they just dragged, and skipping would
|
|
461
|
+
* hand them silence.
|
|
462
|
+
*
|
|
463
|
+
* Tolerant like `existingProducerStamp` above — a missing, unreadable or
|
|
464
|
+
* sfx-less file all mean "no prior sound design", which is what a first run
|
|
465
|
+
* says too. Parsed through `ProductionSfxSchema`, never cast: production.json
|
|
466
|
+
* is as hand-editable as anything else in the workdir, and a level of "MEME"
|
|
467
|
+
* must not reach the report as a level.
|
|
468
|
+
*/
|
|
469
|
+
export function priorSfxPlan(work: string): { level: SfxLevel; placements: SfxPlacement[] } | undefined {
|
|
470
|
+
try {
|
|
471
|
+
const raw = JSON.parse(readFileSync(join(work, "production.json"), "utf8")) as {
|
|
472
|
+
sfx?: unknown;
|
|
473
|
+
};
|
|
474
|
+
if (raw.sfx === undefined) return undefined;
|
|
475
|
+
const parsed = ProductionSfxSchema.safeParse(raw.sfx);
|
|
476
|
+
return parsed.success ? parsed.data : undefined;
|
|
477
|
+
} catch {
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
420
482
|
export function beatCacheKeyCandidates(
|
|
421
483
|
parts: Omit<Parameters<typeof beatSheetCacheKey>[0], "providerName">,
|
|
422
484
|
providerName: string,
|
|
@@ -715,6 +777,21 @@ export interface ProduceOptions {
|
|
|
715
777
|
* a file input.
|
|
716
778
|
*/
|
|
717
779
|
sort?: "name" | "mtime";
|
|
780
|
+
/**
|
|
781
|
+
* `--sfx` (2026-08-29): place sound effects from the loaded pack on the
|
|
782
|
+
* beats the producer planned. Tri-state like `watermark` — true/false when
|
|
783
|
+
* TYPED, undefined when not, so the config's `sfx` key can supply the
|
|
784
|
+
* default (`resolveSfx`). Requires a beat sheet, so it rides `--produce`;
|
|
785
|
+
* without one the step says so and skips.
|
|
786
|
+
*/
|
|
787
|
+
sfx?: boolean;
|
|
788
|
+
/**
|
|
789
|
+
* `--sfx-level`: how much sound design (`subtle | normal | meme`). Already
|
|
790
|
+
* zod-parsed to the union by program.ts, which also folds the implication
|
|
791
|
+
* (`sfxFlag`: a typed level turns `sfx` on); the CONFIG's `sfxLevel` arrives
|
|
792
|
+
* separately as an unvalidated value and `resolveSfxLevel` arbitrates.
|
|
793
|
+
*/
|
|
794
|
+
sfxLevel?: SfxLevel;
|
|
718
795
|
/**
|
|
719
796
|
* Whether `--sort` was TYPED, as opposed to commander filling in its
|
|
720
797
|
* `"name"` default. `sort` alone can't tell those apart, and `--sort` does
|
|
@@ -784,6 +861,103 @@ export function resolveCoverInVideo(
|
|
|
784
861
|
return flag ?? configValue === true;
|
|
785
862
|
}
|
|
786
863
|
|
|
864
|
+
/**
|
|
865
|
+
* `--sfx-level` implies `--sfx`: typing a level is asking for sound effects,
|
|
866
|
+
* and a run that quietly did nothing because the boolean was missing is the
|
|
867
|
+
* worst possible reading of it. Returns the tri-state `--sfx` carries —
|
|
868
|
+
* `undefined` when neither was typed, so the config's `sfx` key still gets its
|
|
869
|
+
* turn (`resolveSfx`). Pure, so the implication is testable without commander,
|
|
870
|
+
* the `jumpCutsFlag` posture.
|
|
871
|
+
*/
|
|
872
|
+
export function sfxFlag(
|
|
873
|
+
sfx: boolean | undefined,
|
|
874
|
+
sfxLevel: SfxLevel | undefined,
|
|
875
|
+
): boolean | undefined {
|
|
876
|
+
return sfxLevel !== undefined ? true : sfx;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/**
|
|
880
|
+
* The effective `--sfx` switch — `resolveCoverInVideo`'s semantics verbatim: a
|
|
881
|
+
* TYPED flag wins, and only then does the config's `sfx` supply the default,
|
|
882
|
+
* `=== true` rather than truthy so a hand-edited `"sfx": "yes"` cannot coerce
|
|
883
|
+
* sound effects onto every render.
|
|
884
|
+
*/
|
|
885
|
+
export function resolveSfx(flag: boolean | undefined, configValue: unknown): boolean {
|
|
886
|
+
return flag ?? configValue === true;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* The effective `--sfx-level`. `resolveLlmEffort`'s shape — the warning is
|
|
891
|
+
* RETURNED, not printed, so the resolution stays pure — and its precedence: a
|
|
892
|
+
* typed flag beats a config key, and a malformed config key costs a warning
|
|
893
|
+
* and the default rather than a coerced level. The default matters: `meme`
|
|
894
|
+
* unlocks the meme-tagged sounds, so a typo must never fall UP into it.
|
|
895
|
+
*/
|
|
896
|
+
export function resolveSfxLevel(
|
|
897
|
+
flag: SfxLevel | undefined,
|
|
898
|
+
configValue: unknown,
|
|
899
|
+
): { level: SfxLevel; warning?: string } {
|
|
900
|
+
if (flag !== undefined) return { level: flag };
|
|
901
|
+
if (configValue === undefined) return { level: "normal" };
|
|
902
|
+
const parsed = SfxLevelSchema.safeParse(configValue);
|
|
903
|
+
if (parsed.success) return { level: parsed.data };
|
|
904
|
+
return {
|
|
905
|
+
level: "normal",
|
|
906
|
+
warning: `⚠ config sfxLevel ignored — expected ${SfxLevelSchema.options.join("|")}, using normal`,
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* What `sfx-<key>.json` holds: the normalized plan plus the accounting a
|
|
912
|
+
* cached re-run would otherwise have to invent — `planned` is the count the
|
|
913
|
+
* MODEL returned, which nothing on disk could re-derive, and `issues` are the
|
|
914
|
+
* planning drops the report explains the shortfall with (the beat-sheet
|
|
915
|
+
* cache's `graphics`/`issues` rule, §78).
|
|
916
|
+
*
|
|
917
|
+
* Parsed on read, never trusted: a workdir file is as hand-editable as
|
|
918
|
+
* anything else here, and a mangled one must cost a re-plan, not a crash.
|
|
919
|
+
*/
|
|
920
|
+
const SfxPlanCacheSchema = SfxPlanSchema.extend({
|
|
921
|
+
planned: z.number().int().nonnegative().default(0),
|
|
922
|
+
issues: z.array(SfxValidationIssueSchema).default([]),
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
/**
|
|
926
|
+
* The placement-plan cache key: everything that changes the PLAN — which
|
|
927
|
+
* prompt asked, about which words, against which beat sheet, at what level,
|
|
928
|
+
* over which library. The §78 posture the beat sheet's key carries, and pure
|
|
929
|
+
* for the same reason: "a change that changes the answer must change the key"
|
|
930
|
+
* has to be assertable without a workdir or an LLM.
|
|
931
|
+
*
|
|
932
|
+
* `beatKey` folds the whole beat-sheet key in (provider, model, intent,
|
|
933
|
+
* framing, clip window…) rather than restating it: the graphics plan is IN the
|
|
934
|
+
* placement prompt, so a re-plan of the beats is a different question about
|
|
935
|
+
* the same words. `libraryHash` is pack METADATA only (`sfxLibraryHash`), so
|
|
936
|
+
* dropping a user pack in `~/.ossclip/sfx` invalidates while re-encoding an
|
|
937
|
+
* mp3 does not.
|
|
938
|
+
*/
|
|
939
|
+
export function sfxCacheKey(parts: {
|
|
940
|
+
promptVersion: number;
|
|
941
|
+
beatKey: string;
|
|
942
|
+
level: SfxLevel;
|
|
943
|
+
libraryHash: string;
|
|
944
|
+
/** The repaired transcript's TEXT — the beat key's rule, for the same reason. */
|
|
945
|
+
words: readonly string[];
|
|
946
|
+
}): string {
|
|
947
|
+
return createHash("sha1")
|
|
948
|
+
.update(
|
|
949
|
+
JSON.stringify([
|
|
950
|
+
parts.promptVersion,
|
|
951
|
+
parts.beatKey,
|
|
952
|
+
parts.level,
|
|
953
|
+
parts.libraryHash,
|
|
954
|
+
parts.words,
|
|
955
|
+
]),
|
|
956
|
+
)
|
|
957
|
+
.digest("hex")
|
|
958
|
+
.slice(0, 8);
|
|
959
|
+
}
|
|
960
|
+
|
|
787
961
|
/**
|
|
788
962
|
* Where the cover image lives right now, most-specific first — the EDITOR
|
|
789
963
|
* panel's ladder (`currentCoverImage` in edit.ts), deliberately the same one:
|
|
@@ -2821,11 +2995,52 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
2821
2995
|
let scenes: Scene[] = [];
|
|
2822
2996
|
/** Editorial output kept for the cover (§31): hook + its thumbnail form. */
|
|
2823
2997
|
let beatSheet: { hook: string; coverText?: string } | undefined;
|
|
2998
|
+
/**
|
|
2999
|
+
* The FULL sheet, moments included — the graphics plan the SFX call needs in
|
|
3000
|
+
* context (a whoosh syncs with a graphic entrance). Separate from
|
|
3001
|
+
* `beatSheet` above, which is deliberately the cover's two fields: this one
|
|
3002
|
+
* exists only while a plan is in hand this run, and is undefined on the
|
|
3003
|
+
* paths that never had one (`--scenes`, a pre-`--sfx` scenes cache).
|
|
3004
|
+
*/
|
|
3005
|
+
let fullSheet: BeatSheet | undefined;
|
|
2824
3006
|
/** The graphics accounting line for report.txt (§118b), and the beat-sheet
|
|
2825
3007
|
* issues that explain it. Cached alongside the beat sheet so a cached
|
|
2826
3008
|
* re-run's report keeps the accounting instead of erasing it (§78). */
|
|
2827
3009
|
let graphicsLine: string | undefined;
|
|
2828
3010
|
let beatIssues: BeatsValidationIssue[] = [];
|
|
3011
|
+
/**
|
|
3012
|
+
* The `--sfx` plan: the level it was planned at and the placements that
|
|
3013
|
+
* survived `normalizeSfxPlan`, stored on production.json and resolved to
|
|
3014
|
+
* cues once the cut is final (`resolveSfxCues`). Undefined = this run has no
|
|
3015
|
+
* sound design at all, which is what an absent `sfx` field means.
|
|
3016
|
+
*/
|
|
3017
|
+
let sfxPlan: { level: SfxLevel; placements: SfxPlacement[] } | undefined;
|
|
3018
|
+
/** The library the plan was made against — the resolver needs the same
|
|
3019
|
+
* sounds (gain, absPath) it planned over, and the stager needs their files. */
|
|
3020
|
+
let sfxSounds: LoadedSfxSound[] = [];
|
|
3021
|
+
/** Planning drops + the count the MODEL returned, carried to the one
|
|
3022
|
+
* accounting line the console and report.txt share (§118b's contract). */
|
|
3023
|
+
let sfxIssues: SfxValidationIssue[] = [];
|
|
3024
|
+
let sfxPlanned = 0;
|
|
3025
|
+
/** Whether the placement step actually ran — see the notice below the
|
|
3026
|
+
* scenes block for the runs where `--sfx` cannot reach it. */
|
|
3027
|
+
let sfxAttempted = false;
|
|
3028
|
+
// Resolved HERE, above the branch that uses it, so BOTH the placement step
|
|
3029
|
+
// and the "this run has no beat sheet" notice read one answer. Typed beats
|
|
3030
|
+
// config for the switch; the level is zod-parsed out of the config, never
|
|
3031
|
+
// coerced (`resolveSfxLevel`).
|
|
3032
|
+
const sfxOn = resolveSfx(opts.sfx, cfg.sfx);
|
|
3033
|
+
const sfxLevelResolved = resolveSfxLevel(opts.sfxLevel, cfg.sfxLevel);
|
|
3034
|
+
if (sfxOn && sfxLevelResolved.warning) console.log(sfxLevelResolved.warning);
|
|
3035
|
+
const sfxLevel = sfxLevelResolved.level;
|
|
3036
|
+
// Which packs this machine offers (`sfxBundledPack`) — resolved next to the
|
|
3037
|
+
// level, and for the same reason: BOTH library loads below (the placement
|
|
3038
|
+
// step and the carry-forward branch) must read one answer, or a run would
|
|
3039
|
+
// plan against one library and stage from another.
|
|
3040
|
+
const sfxBundled = resolveSfxBundledPack(cfg.sfxBundledPack);
|
|
3041
|
+
if (sfxOn && sfxBundled.warning) console.log(sfxBundled.warning);
|
|
3042
|
+
/** The loader's opts, shared by both library loads. */
|
|
3043
|
+
const sfxLoad = { includeBundled: sfxBundled.include };
|
|
2829
3044
|
/** Who planned this run (R16 §78) — stamped into production.json below. */
|
|
2830
3045
|
let producerStamp: Production["producer"];
|
|
2831
3046
|
/** The resolved `--clip` window (R19 §93) — set only on a clip run; feeds
|
|
@@ -3008,6 +3223,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3008
3223
|
// cache under the post-resolution key so re-runs and replays hit it.
|
|
3009
3224
|
scenes = clipFresh.scenes;
|
|
3010
3225
|
beatSheet = { hook: clipFresh.beatSheet.hook, coverText: clipFresh.beatSheet.coverText };
|
|
3226
|
+
fullSheet = clipFresh.beatSheet;
|
|
3011
3227
|
console.log(`▸ hook: ${clipFresh.beatSheet.hook}`);
|
|
3012
3228
|
console.log(
|
|
3013
3229
|
`▸ planned ${clipFresh.beatSheet.moments.length} moments, ${scenes.length} scenes` +
|
|
@@ -3035,7 +3251,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3035
3251
|
await writeFile(join(work, `scenes-${adoptKey}.json`), JSON.stringify(scenes, null, 2));
|
|
3036
3252
|
await writeFile(
|
|
3037
3253
|
join(work, `beatsheet-${adoptKey}.json`),
|
|
3038
|
-
JSON.stringify(
|
|
3254
|
+
JSON.stringify(
|
|
3255
|
+
{ ...beatSheet, moments: fullSheet.moments, graphics: graphicsLine, issues: beatIssues },
|
|
3256
|
+
null,
|
|
3257
|
+
2,
|
|
3258
|
+
),
|
|
3039
3259
|
);
|
|
3040
3260
|
} else if (existsSync(sceneCache)) {
|
|
3041
3261
|
scenes = z.array(SceneSchema).parse(JSON.parse(await readFile(sceneCache, "utf8")));
|
|
@@ -3054,12 +3274,26 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3054
3274
|
const cached = JSON.parse(await readFile(beatCache, "utf8")) as {
|
|
3055
3275
|
hook: string;
|
|
3056
3276
|
coverText?: string;
|
|
3277
|
+
moments?: unknown;
|
|
3057
3278
|
graphics?: string;
|
|
3058
3279
|
issues?: BeatsValidationIssue[];
|
|
3059
3280
|
};
|
|
3060
3281
|
beatSheet = { hook: cached.hook, coverText: cached.coverText };
|
|
3061
3282
|
graphicsLine = cached.graphics;
|
|
3062
3283
|
beatIssues = cached.issues ?? [];
|
|
3284
|
+
// The MOMENTS ride the cache too (2026-08-29, --sfx): this file used to
|
|
3285
|
+
// keep only the cover's two fields, so a cached run had no graphics
|
|
3286
|
+
// plan to put in front of the placement call — and "produce once, add
|
|
3287
|
+
// --sfx later" is the normal way anyone reaches this feature. Parsed,
|
|
3288
|
+
// not trusted (a hand-edited or pre-`--sfx` file simply has no
|
|
3289
|
+
// `moments`), and its absence costs the SFX step alone: everything the
|
|
3290
|
+
// cache did before this is read above and unaffected.
|
|
3291
|
+
const sheet = BeatSheetSchema.safeParse({
|
|
3292
|
+
hook: cached.hook,
|
|
3293
|
+
coverText: cached.coverText,
|
|
3294
|
+
moments: cached.moments,
|
|
3295
|
+
});
|
|
3296
|
+
if (sheet.success) fullSheet = sheet.data;
|
|
3063
3297
|
}
|
|
3064
3298
|
} else {
|
|
3065
3299
|
const aiAnim = isInteractive()
|
|
@@ -3106,6 +3340,7 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3106
3340
|
if (aiAnim) aiAnim.stop();
|
|
3107
3341
|
scenes = result.scenes;
|
|
3108
3342
|
beatSheet = { hook: result.beatSheet.hook, coverText: result.beatSheet.coverText };
|
|
3343
|
+
fullSheet = result.beatSheet;
|
|
3109
3344
|
console.log(`▸ hook: ${result.beatSheet.hook}`);
|
|
3110
3345
|
console.log(
|
|
3111
3346
|
`▸ planned ${result.beatSheet.moments.length} moments, ${scenes.length} scenes` +
|
|
@@ -3132,9 +3367,137 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3132
3367
|
await writeFile(join(work, `scenes-${freshKey}.json`), JSON.stringify(scenes, null, 2));
|
|
3133
3368
|
await writeFile(
|
|
3134
3369
|
join(work, `beatsheet-${freshKey}.json`),
|
|
3135
|
-
JSON.stringify(
|
|
3370
|
+
JSON.stringify(
|
|
3371
|
+
{ ...beatSheet, moments: fullSheet.moments, graphics: graphicsLine, issues: beatIssues },
|
|
3372
|
+
null,
|
|
3373
|
+
2,
|
|
3374
|
+
),
|
|
3136
3375
|
);
|
|
3137
3376
|
}
|
|
3377
|
+
|
|
3378
|
+
// ---- Sound effects (`--sfx`): the placement call -----------------------
|
|
3379
|
+
// HERE, inside the producer branch, because the placement prompt needs the
|
|
3380
|
+
// graphics plan in front of it (Approach A: a whoosh syncs with a graphic
|
|
3381
|
+
// entrance) — and because a beat sheet is the one thing this feature
|
|
3382
|
+
// cannot do without. `--scenes` and a plain run never reach this block;
|
|
3383
|
+
// the notice for that case is printed by the caller below.
|
|
3384
|
+
if (sfxOn) {
|
|
3385
|
+
sfxAttempted = true;
|
|
3386
|
+
const library = loadSfxLibrary(sfxLoad);
|
|
3387
|
+
for (const issue of library.issues) console.log(` ⚠ sfx pack ${issue.pack}: ${issue.issue}`);
|
|
3388
|
+
if (library.sounds.length === 0) {
|
|
3389
|
+
// Warn and continue, never kill: an empty library is a packaging or a
|
|
3390
|
+
// user-pack problem, and it costs sound effects, not the video.
|
|
3391
|
+
console.log(" ⚠ sfx: no usable sounds in the library — skipping sound effects");
|
|
3392
|
+
} else {
|
|
3393
|
+
sfxSounds = library.sounds;
|
|
3394
|
+
const sfxKey = sfxCacheKey({
|
|
3395
|
+
promptVersion: SFX_PROMPT_VERSION,
|
|
3396
|
+
// The beat key, not its parts: the graphics plan is IN this prompt,
|
|
3397
|
+
// so a different sheet is a different question (see `sfxCacheKey`).
|
|
3398
|
+
beatKey: hitKey,
|
|
3399
|
+
level: sfxLevel,
|
|
3400
|
+
libraryHash: sfxLibraryHash(library.sounds),
|
|
3401
|
+
words: beatKeyParts.words,
|
|
3402
|
+
});
|
|
3403
|
+
const sfxCache = join(work, `sfx-${sfxKey}.json`);
|
|
3404
|
+
if (existsSync(sfxCache)) {
|
|
3405
|
+
// Parsed, never trusted — a cache file is as hand-editable as any
|
|
3406
|
+
// other artefact in the workdir. A malformed one is a re-plan, not a
|
|
3407
|
+
// crash, so it falls through to the call below.
|
|
3408
|
+
const cached = SfxPlanCacheSchema.safeParse(
|
|
3409
|
+
JSON.parse(await readFile(sfxCache, "utf8")),
|
|
3410
|
+
);
|
|
3411
|
+
if (cached.success) {
|
|
3412
|
+
sfxPlan = { level: sfxLevel, placements: cached.data.placements };
|
|
3413
|
+
sfxPlanned = cached.data.planned;
|
|
3414
|
+
sfxIssues = cached.data.issues;
|
|
3415
|
+
console.log(`▸ sfx cached (${sfxPlan.placements.length} placement(s), level ${sfxLevel})`);
|
|
3416
|
+
}
|
|
3417
|
+
}
|
|
3418
|
+
if (!sfxPlan && fullSheet === undefined) {
|
|
3419
|
+
// The one case the cache cannot cover: a workdir planned before
|
|
3420
|
+
// `--sfx` existed (or by a pre-2026-08-29 build) has scenes but no
|
|
3421
|
+
// moments, so there is no graphics plan to place against. Say what
|
|
3422
|
+
// to do rather than silently rendering a mute video.
|
|
3423
|
+
console.log(
|
|
3424
|
+
" ⚠ sfx: this workdir's cached plan has no beat sheet to place against — " +
|
|
3425
|
+
"re-plan (delete its scenes-*.json) to get sound effects",
|
|
3426
|
+
);
|
|
3427
|
+
} else if (!sfxPlan) {
|
|
3428
|
+
console.log(`▸ placing sound effects (level ${sfxLevel})…`);
|
|
3429
|
+
const result = await phases.time("llm", () =>
|
|
3430
|
+
generateSfxPlan(provider!, transcript, fullSheet!, library.sounds, sfxLevel),
|
|
3431
|
+
);
|
|
3432
|
+
sfxPlan = { level: sfxLevel, placements: result.plan.placements };
|
|
3433
|
+
sfxPlanned = result.planned;
|
|
3434
|
+
sfxIssues = result.issues;
|
|
3435
|
+
await writeFile(
|
|
3436
|
+
sfxCache,
|
|
3437
|
+
// The accounting rides the cache for §78's reason, the same one
|
|
3438
|
+
// `graphics`/`issues` ride the beat-sheet cache: a cached re-run
|
|
3439
|
+
// must be able to print the SAME accounting line rather than
|
|
3440
|
+
// erasing it, and `planned` is a count only the call itself knew.
|
|
3441
|
+
JSON.stringify(
|
|
3442
|
+
{ placements: sfxPlan.placements, planned: sfxPlanned, issues: sfxIssues },
|
|
3443
|
+
null,
|
|
3444
|
+
2,
|
|
3445
|
+
),
|
|
3446
|
+
);
|
|
3447
|
+
}
|
|
3448
|
+
for (const issue of sfxIssues) {
|
|
3449
|
+
console.log(` ⚠ sfx placement ${issue.placement}: ${issue.issue}`);
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
|
|
3455
|
+
// `--sfx` on a run that never reached the placement call — `--scenes` (which
|
|
3456
|
+
// is what the editor's Render replays), or a plain cut with no producer at
|
|
3457
|
+
// all.
|
|
3458
|
+
//
|
|
3459
|
+
// The plan carries FORWARD from the workdir's last `production.json` here
|
|
3460
|
+
// (Phase 3), and that is the scenes-reviewed doctrine applied to sound: an
|
|
3461
|
+
// editor render replays the REVIEWED state, and for SFX the reviewed state
|
|
3462
|
+
// is the prior plan plus overrides.json's edits on top of it (`priorSfxPlan`
|
|
3463
|
+
// has the full argument). The level comes from that record too — the user
|
|
3464
|
+
// reviewed effects placed at THAT level, and re-reading `--sfx-level` here
|
|
3465
|
+
// would describe them with a number they were never planned under. It is
|
|
3466
|
+
// written back below like any other run's plan, so a chain of editor renders
|
|
3467
|
+
// never breaks.
|
|
3468
|
+
//
|
|
3469
|
+
// No prior record is the only case left with nothing to carry: never a
|
|
3470
|
+
// silent ignore, and the message names the missing half rather than the flag
|
|
3471
|
+
// the user typed.
|
|
3472
|
+
if (sfxOn && !sfxAttempted) {
|
|
3473
|
+
const prior = priorSfxPlan(work);
|
|
3474
|
+
if (prior === undefined) {
|
|
3475
|
+
console.log(
|
|
3476
|
+
" ⚠ sfx: sound effects are placed against the producer's beat sheet — " +
|
|
3477
|
+
"add --produce (the editor's re-plan) to get them",
|
|
3478
|
+
);
|
|
3479
|
+
} else {
|
|
3480
|
+
// The same library the producer branch loads, and for the same two
|
|
3481
|
+
// consumers: the resolver needs each sound's gain and path, the stager
|
|
3482
|
+
// needs its file. An empty library costs the effects, never the video.
|
|
3483
|
+
const library = loadSfxLibrary(sfxLoad);
|
|
3484
|
+
for (const issue of library.issues) console.log(` ⚠ sfx pack ${issue.pack}: ${issue.issue}`);
|
|
3485
|
+
if (library.sounds.length === 0) {
|
|
3486
|
+
console.log(" ⚠ sfx: no usable sounds in the library — skipping sound effects");
|
|
3487
|
+
} else {
|
|
3488
|
+
sfxSounds = library.sounds;
|
|
3489
|
+
sfxPlan = prior;
|
|
3490
|
+
// `planned` is the reviewed plan's own size: this run made no model
|
|
3491
|
+
// call, so the only honest denominator for "N of M planned placed" is
|
|
3492
|
+
// what the user approved (the cached-plan branch's rule — the count
|
|
3493
|
+
// belongs to the plan, not to the run).
|
|
3494
|
+
sfxPlanned = prior.placements.length;
|
|
3495
|
+
console.log(
|
|
3496
|
+
`▸ sfx carried forward from the reviewed plan ` +
|
|
3497
|
+
`(${prior.placements.length} placement(s), level ${prior.level})`,
|
|
3498
|
+
);
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3138
3501
|
}
|
|
3139
3502
|
|
|
3140
3503
|
// Every LLM call is behind us — repair, beat sheet, one per scene — so this
|
|
@@ -3906,6 +4269,77 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3906
4269
|
);
|
|
3907
4270
|
}
|
|
3908
4271
|
|
|
4272
|
+
// ---- Sound effects: word anchors → cues, and the files they name --------
|
|
4273
|
+
// AFTER the cut is final (`applyUserCuts` above) and after the public dir is
|
|
4274
|
+
// known, because both are inputs: a placement anchored to a word the user's
|
|
4275
|
+
// own cut removed is dropped here, and the copies must land where
|
|
4276
|
+
// `staticFile()` will look.
|
|
4277
|
+
let sfxCues: SfxCue[] = [];
|
|
4278
|
+
/** Planning drops + render drops, counted together by the one accounting
|
|
4279
|
+
* line the console and report.txt share (`formatSfxAccounting`). */
|
|
4280
|
+
let sfxAllIssues: SfxValidationIssue[] = sfxIssues;
|
|
4281
|
+
let sfxLine: string | undefined;
|
|
4282
|
+
if (sfxPlan) {
|
|
4283
|
+
// The user's layer FIRST (Phase 3): retimes, swaps, gains, mutes and the
|
|
4284
|
+
// placements they added themselves, applied before any of the resolver's
|
|
4285
|
+
// arithmetic so a dragged effect is timed, cut-checked and mixed exactly
|
|
4286
|
+
// like a planned one. `sfxPlan` itself is left alone — production.json
|
|
4287
|
+
// stores the MODEL's plan, which is what these edit keys are derived from
|
|
4288
|
+
// (`sfxPlacementKey`), and folding them in would re-key the whole layer on
|
|
4289
|
+
// the next run.
|
|
4290
|
+
const edited = applySfxOverrides(sfxPlan.placements, overrideDoc.sfx);
|
|
4291
|
+
for (const d of edited.dropped) {
|
|
4292
|
+
console.log(
|
|
4293
|
+
d.reason === "stale key"
|
|
4294
|
+
? ` ⚠ sfx edit "${d.key}" no longer matches any planned placement — ` +
|
|
4295
|
+
`the re-plan dropped it`
|
|
4296
|
+
: ` ⚠ sfx edit "${d.key}" matches more than one placement — applied to the first`,
|
|
4297
|
+
);
|
|
4298
|
+
}
|
|
4299
|
+
// The accounting's denominator moves with the user's layer, because the
|
|
4300
|
+
// layer changes the SIZE of the plan the resolver saw: a mute REMOVES a
|
|
4301
|
+
// placement (it was un-planned by the user, not dropped by the pipeline)
|
|
4302
|
+
// and an add appends one. Without this, muting an effect would print as a
|
|
4303
|
+
// reasonless "1 dropped" and adding one as "6 of 5 planned placed".
|
|
4304
|
+
sfxPlanned += edited.placements.length - sfxPlan.placements.length;
|
|
4305
|
+
const resolved = resolveSfxCues(edited.placements, transcript, map, sfxSounds, {
|
|
4306
|
+
// The real check, injected (the resolver stays pure): a pack deleted
|
|
4307
|
+
// between planning and this render must cost the cue here, not a
|
|
4308
|
+
// Remotion 404 after the render has spent its minutes.
|
|
4309
|
+
exists: existsSync,
|
|
4310
|
+
// The scene timing context, from `sceneCues` — the FINAL list, with the
|
|
4311
|
+
// user's moves, trims, splits, pins and deletes already applied. That is
|
|
4312
|
+
// the whole point of the scene link (2026-08-29): a whoosh placed "as
|
|
4313
|
+
// the TitleCard enters" fired at a word, so moving the card in the
|
|
4314
|
+
// editor left the sound behind. Reading the producer's raw `scenes`
|
|
4315
|
+
// here instead would rebuild that bug exactly.
|
|
4316
|
+
sceneStarts: sceneStartSeconds(sceneCues),
|
|
4317
|
+
});
|
|
4318
|
+
sfxCues = resolved.cues;
|
|
4319
|
+
sfxAllIssues = [...sfxIssues, ...resolved.dropped];
|
|
4320
|
+
for (const d of resolved.dropped) console.log(` ⚠ sfx: ${d.issue}`);
|
|
4321
|
+
// Staged into the render's public dir AND the workdir when they differ,
|
|
4322
|
+
// for the Nastaliq font's reason verbatim: the render bundles
|
|
4323
|
+
// `dirname(renderVideo)`, `ossclip edit` serves the workdir, and both
|
|
4324
|
+
// mounts fetch the same served name. Only the sounds actually CUED are
|
|
4325
|
+
// copied — the library is a menu, not a payload.
|
|
4326
|
+
const staged = new Set(sfxCues.map((c) => c.soundFile));
|
|
4327
|
+
for (const sound of sfxSounds) {
|
|
4328
|
+
const rel = sfxStagedFile(sound);
|
|
4329
|
+
if (!staged.has(rel)) continue;
|
|
4330
|
+
for (const dir of new Set([renderPublicDirPath, work])) {
|
|
4331
|
+
// `join` on the filesystem side where `rel` itself stays
|
|
4332
|
+
// POSIX-literal — it is a served URL, these are paths (the
|
|
4333
|
+
// `sideImageDestRel` split).
|
|
4334
|
+
const dest = join(dir, ...rel.split("/"));
|
|
4335
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
4336
|
+
copyFileSync(sound.absPath, dest);
|
|
4337
|
+
}
|
|
4338
|
+
}
|
|
4339
|
+
sfxLine = formatSfxAccounting(sfxCues.length, sfxPlanned, sfxPlan.level, sfxAllIssues);
|
|
4340
|
+
console.log(`▸ ${sfxLine}`);
|
|
4341
|
+
}
|
|
4342
|
+
|
|
3909
4343
|
const production: Production = {
|
|
3910
4344
|
version: 1,
|
|
3911
4345
|
// `originalInput`, not `input`: for a folder run `input` is by now
|
|
@@ -3935,6 +4369,11 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
3935
4369
|
? { clip: { targetSec: clipTargetSec, ...clipWindow } }
|
|
3936
4370
|
: {}),
|
|
3937
4371
|
scenes: scenes.length > 0 ? scenes : undefined,
|
|
4372
|
+
// The PLAN, not the cues: word anchors survive a re-cut and the editor
|
|
4373
|
+
// edits them (Phase 3), while `render-props.json` carries the resolved
|
|
4374
|
+
// instants. Absent when this run has no sound design, so a no-`--sfx`
|
|
4375
|
+
// production.json is byte-identical to a pre-feature one.
|
|
4376
|
+
sfx: sfxPlan,
|
|
3938
4377
|
producer: producerStamp,
|
|
3939
4378
|
theme,
|
|
3940
4379
|
// The EFFECTIVE output size, not the base frame (`--resolution`): this is
|
|
@@ -4022,6 +4461,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4022
4461
|
.map((i) => ` ⚠ moment ${i.moment}: ${i.issue}\n`)
|
|
4023
4462
|
.join("");
|
|
4024
4463
|
}
|
|
4464
|
+
// The SFX accounting, the graphics block's contract exactly (§118b): ONE
|
|
4465
|
+
// formatter feeds the console line printed above and this one, so the two
|
|
4466
|
+
// can never say different things about the same run. The drops are listed
|
|
4467
|
+
// under it for the same reason the beat issues are listed under the graphics
|
|
4468
|
+
// line — the number alone does not say WHICH effect went missing.
|
|
4469
|
+
if (sfxLine) {
|
|
4470
|
+
report +=
|
|
4471
|
+
`\n${sfxLine}\n` +
|
|
4472
|
+
sfxAllIssues.map((i) => ` ⚠ placement ${i.placement}: ${i.issue}\n`).join("");
|
|
4473
|
+
}
|
|
4025
4474
|
if (provider) {
|
|
4026
4475
|
report += formatUsageReport(provider.usage, cfg.pricing);
|
|
4027
4476
|
// A cached run has no usage block to print, and used to leave the report
|
|
@@ -4614,6 +5063,12 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4614
5063
|
// actually render.
|
|
4615
5064
|
...(captionsHidden ? { captionsHidden: true } : {}),
|
|
4616
5065
|
...(opts.captions === false ? { captionsHiddenByFlag: true } : {}),
|
|
5066
|
+
// `--sfx`, written only when there is something to play (the watermark's
|
|
5067
|
+
// absent-means-off contract): a run without sound effects keeps a
|
|
5068
|
+
// render-props.json — and an audio graph — byte-identical to a pre-feature
|
|
5069
|
+
// one, and the composition reads an absent key as silence, not as an empty
|
|
5070
|
+
// track it still has to mount.
|
|
5071
|
+
...(sfxCues.length > 0 ? { sfxCues } : {}),
|
|
4617
5072
|
};
|
|
4618
5073
|
await writeFile(join(work, "render-props.json"), JSON.stringify(props, null, 2));
|
|
4619
5074
|
|
|
@@ -4785,6 +5240,16 @@ export async function produce(inputArg: string, opts: ProduceOptions): Promise<P
|
|
|
4785
5240
|
portrait,
|
|
4786
5241
|
audience,
|
|
4787
5242
|
thumbnailBrief,
|
|
5243
|
+
// The SFX switch and level, RESOLVED (flag + config folded) — the
|
|
5244
|
+
// watermark's rationale on a flag whose default is config-dependent
|
|
5245
|
+
// (`sfx`/`sfxLevel` in ~/.ossclip/config.json). Unpinned, the editor's
|
|
5246
|
+
// Render would place a DIFFERENT amount of sound design the moment that
|
|
5247
|
+
// config is edited, or none at all on a machine without it — and since
|
|
5248
|
+
// the replay carries the reviewed plan forward rather than re-placing
|
|
5249
|
+
// (`priorSfxPlan`), an unpinned `--sfx` is the difference between the
|
|
5250
|
+
// reviewed sound design and a silent video.
|
|
5251
|
+
sfx: sfxOn,
|
|
5252
|
+
sfxLevel,
|
|
4788
5253
|
});
|
|
4789
5254
|
// produce is the ONLY command that may write command.json — edit.ts's §129
|
|
4790
5255
|
// heal prepends the "produce" literal to any record that doesn't start
|