mulmocast 2.18.0 → 2.19.0
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/schemas/mulmo_script.json +100 -0
- package/lib/actions/audio.js +4 -1
- package/lib/actions/avatar.js +4 -1
- package/lib/agents/add_bgm_agent.d.ts +13 -1
- package/lib/agents/add_bgm_agent.js +77 -4
- package/lib/cli/bin.js +0 -0
- package/lib/mcp/server.js +0 -0
- package/lib/methods/mulmo_studio_context.d.ts +2 -0
- package/lib/methods/mulmo_studio_context.js +19 -0
- package/lib/types/schema.d.ts +64 -0
- package/lib/types/schema.js +42 -0
- package/lib/types/type.d.ts +2 -1
- package/lib/utils/context.d.ts +32 -0
- package/lib/utils/jinglescript.d.ts +12 -0
- package/lib/utils/jinglescript.js +63 -0
- package/package.json +5 -4
- package/scripts/test/test_jinglescript_sound_effects.json +101 -0
- package/scripts/test/test_sound_effects.json +64 -0
- package/scripts/test/test_sound_effects_animation.json +441 -0
- package/scripts/test/test_captions_2.json~ +0 -278
- package/scripts/test/test_en_portrait.json~ +0 -31
- package/scripts/test/test_slide_image_ref_gemini_en.json~ +0 -289
- package/scripts/test/test_vertexai.json~ +0 -21
|
@@ -15465,6 +15465,106 @@
|
|
|
15465
15465
|
"soundEffectPrompt": {
|
|
15466
15466
|
"type": "string"
|
|
15467
15467
|
},
|
|
15468
|
+
"soundEffects": {
|
|
15469
|
+
"description": "Sound effects mixed into the audio track during this beat. Multiple effects may overlap.",
|
|
15470
|
+
"type": "array",
|
|
15471
|
+
"items": {
|
|
15472
|
+
"type": "object",
|
|
15473
|
+
"properties": {
|
|
15474
|
+
"source": {
|
|
15475
|
+
"oneOf": [
|
|
15476
|
+
{
|
|
15477
|
+
"type": "object",
|
|
15478
|
+
"properties": {
|
|
15479
|
+
"kind": {
|
|
15480
|
+
"type": "string",
|
|
15481
|
+
"const": "url"
|
|
15482
|
+
},
|
|
15483
|
+
"url": {
|
|
15484
|
+
"type": "string",
|
|
15485
|
+
"format": "uri"
|
|
15486
|
+
}
|
|
15487
|
+
},
|
|
15488
|
+
"required": [
|
|
15489
|
+
"kind",
|
|
15490
|
+
"url"
|
|
15491
|
+
],
|
|
15492
|
+
"additionalProperties": false
|
|
15493
|
+
},
|
|
15494
|
+
{
|
|
15495
|
+
"type": "object",
|
|
15496
|
+
"properties": {
|
|
15497
|
+
"kind": {
|
|
15498
|
+
"type": "string",
|
|
15499
|
+
"const": "path"
|
|
15500
|
+
},
|
|
15501
|
+
"path": {
|
|
15502
|
+
"type": "string",
|
|
15503
|
+
"minLength": 1
|
|
15504
|
+
}
|
|
15505
|
+
},
|
|
15506
|
+
"required": [
|
|
15507
|
+
"kind",
|
|
15508
|
+
"path"
|
|
15509
|
+
],
|
|
15510
|
+
"additionalProperties": false
|
|
15511
|
+
},
|
|
15512
|
+
{
|
|
15513
|
+
"type": "object",
|
|
15514
|
+
"properties": {
|
|
15515
|
+
"kind": {
|
|
15516
|
+
"type": "string",
|
|
15517
|
+
"const": "jinglescript"
|
|
15518
|
+
},
|
|
15519
|
+
"score": {
|
|
15520
|
+
"type": "object",
|
|
15521
|
+
"propertyNames": {
|
|
15522
|
+
"type": "string"
|
|
15523
|
+
},
|
|
15524
|
+
"additionalProperties": {},
|
|
15525
|
+
"description": "JingleScript score (format jinglescript/1), rendered to audio when the audio is generated"
|
|
15526
|
+
}
|
|
15527
|
+
},
|
|
15528
|
+
"required": [
|
|
15529
|
+
"kind",
|
|
15530
|
+
"score"
|
|
15531
|
+
],
|
|
15532
|
+
"additionalProperties": false
|
|
15533
|
+
}
|
|
15534
|
+
],
|
|
15535
|
+
"description": "Audio file of the sound effect (url or path), or a JingleScript score to synthesize"
|
|
15536
|
+
},
|
|
15537
|
+
"startAt": {
|
|
15538
|
+
"default": 0,
|
|
15539
|
+
"description": "Seconds from when the beat appears on screen (the same clock as its html animation) at which the sound effect plays. The first beat's narration starts audioParams.introPadding later",
|
|
15540
|
+
"type": "number",
|
|
15541
|
+
"minimum": 0
|
|
15542
|
+
},
|
|
15543
|
+
"volume": {
|
|
15544
|
+
"default": 1,
|
|
15545
|
+
"description": "Volume of the sound effect. 1.0 is the gain of the narration at its default volume; audioParams.audioVolume and ttsVolume do not affect sound effects",
|
|
15546
|
+
"type": "number",
|
|
15547
|
+
"minimum": 0,
|
|
15548
|
+
"maximum": 4
|
|
15549
|
+
},
|
|
15550
|
+
"duration": {
|
|
15551
|
+
"description": "Maximum play length in seconds; the effect is cut (with a short fade-out) after it. Default: the whole file, even past the end of the beat",
|
|
15552
|
+
"type": "number",
|
|
15553
|
+
"exclusiveMinimum": 0
|
|
15554
|
+
},
|
|
15555
|
+
"loop": {
|
|
15556
|
+
"description": "Repeat the effect until duration, or until the beat leaves the screen when duration is not set (e.g. a ticking clock)",
|
|
15557
|
+
"type": "boolean"
|
|
15558
|
+
}
|
|
15559
|
+
},
|
|
15560
|
+
"required": [
|
|
15561
|
+
"source",
|
|
15562
|
+
"startAt",
|
|
15563
|
+
"volume"
|
|
15564
|
+
],
|
|
15565
|
+
"additionalProperties": false
|
|
15566
|
+
}
|
|
15567
|
+
},
|
|
15468
15568
|
"htmlPrompt": {
|
|
15469
15569
|
"type": "object",
|
|
15470
15570
|
"properties": {
|
package/lib/actions/audio.js
CHANGED
|
@@ -5,6 +5,7 @@ import { fileWriteAgent } from "@graphai/vanilla_node_agents";
|
|
|
5
5
|
import { ttsOpenaiAgent, ttsGoogleAgent, ttsGeminiAgent, ttsElevenlabsAgent, ttsKotodamaAgent, addBGMAgent, combineAudioFilesAgent, mediaMockAgent, } from "../agents/index.js";
|
|
6
6
|
import { audioGraphOption } from "./graph_option.js";
|
|
7
7
|
import { createUsageCallback } from "../utils/usage_callback.js";
|
|
8
|
+
import { renderJingleScores } from "../utils/jinglescript.js";
|
|
8
9
|
import { getAudioArtifactFilePath, getAudioFilePath, getGroupedAudioFilePath, getOutputStudioFilePath, resolveDirPath, defaultBGMPath, mkdir, writingMessage, } from "../utils/file.js";
|
|
9
10
|
import { localizedText } from "../utils/utils.js";
|
|
10
11
|
import { text2hash } from "../utils/utils_node.js";
|
|
@@ -202,7 +203,7 @@ export const audio_graph_data = {
|
|
|
202
203
|
wait: ":combineFiles",
|
|
203
204
|
voiceFile: ":audioCombinedFilePath",
|
|
204
205
|
outputFile: ":audioArtifactFilePath",
|
|
205
|
-
context: ":
|
|
206
|
+
context: ":combineFiles", // has studio.beats[].startAt, needed to place sound effects
|
|
206
207
|
params: {
|
|
207
208
|
musicFile: ":musicFile",
|
|
208
209
|
},
|
|
@@ -268,6 +269,8 @@ export const audio = async (context, args) => {
|
|
|
268
269
|
const outputStudioFilePath = getOutputStudioFilePath(outDirPath, fileName);
|
|
269
270
|
mkdir(outDirPath);
|
|
270
271
|
mkdir(audioSegmentDirPath);
|
|
272
|
+
// Before TTS, so an invalid score fails fast.
|
|
273
|
+
await renderJingleScores(context);
|
|
271
274
|
const graph = new GraphAI(audio_graph_data, audioAgents, await audioGraphOption(context, settings));
|
|
272
275
|
callbacks?.forEach((callback) => graph.registerCallback(callback));
|
|
273
276
|
graph.registerCallback(createUsageCallback(context));
|
package/lib/actions/avatar.js
CHANGED
|
@@ -168,7 +168,10 @@ const renderAvatarTrack = async (plan, context, getAvatarScript) => {
|
|
|
168
168
|
// rendered once, at the largest size it is shown; smaller placements scale it down
|
|
169
169
|
const height = even(Math.max(...plan.placements.map((p) => percent(p.position.scale, canvas.height))));
|
|
170
170
|
const segments = plan.segments.map((segment) => ({ ...segment, audioStamp: fileStamp(segment.audio) }));
|
|
171
|
-
|
|
171
|
+
// bump `version` when the track would come out differently, including an avatarscript upgrade that
|
|
172
|
+
// changes its output (5: avatarscript 0.4.0 holds a phrase's last sound): a reused track needs no
|
|
173
|
+
// avatarscript, so its version cannot be part of this
|
|
174
|
+
const identity = { version: 5, segments, source: plan.source, sourceStamp: await sourceStamp(plan.source), duration: plan.duration, height, padTop: PAD_TOP };
|
|
172
175
|
const hash = createHash("sha256").update(JSON.stringify(identity)).digest("hex").slice(0, 12);
|
|
173
176
|
const dir = MulmoStudioContextMethods.getImageProjectDirPath(context);
|
|
174
177
|
const name = `avatar_${plan.speakerId.replace(/[^\w-]/g, "_")}_${hash}`;
|
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import type { AgentFunctionInfo } from "graphai";
|
|
2
2
|
import { MulmoStudioContext } from "../types/index.js";
|
|
3
|
+
import { FfmpegContext } from "../utils/ffmpeg_utils.js";
|
|
3
4
|
export declare const resolveAddBgmMixParams: (audioParams: MulmoStudioContext["presentationStyle"]["audioParams"]) => {
|
|
4
5
|
useExplicitMix: boolean;
|
|
5
6
|
voiceVolume: number;
|
|
6
7
|
};
|
|
7
|
-
export declare const resolveAddBgmFilterConfig: (useExplicitMix: boolean) => {
|
|
8
|
+
export declare const resolveAddBgmFilterConfig: (useExplicitMix: boolean, mixedInputId?: string) => {
|
|
8
9
|
amixNormalize: string;
|
|
9
10
|
mixedOutputId: string;
|
|
10
11
|
limiterFilter: string | undefined;
|
|
11
12
|
};
|
|
13
|
+
export type SoundEffectPlacement = {
|
|
14
|
+
file: string;
|
|
15
|
+
startAt: number;
|
|
16
|
+
volume: number;
|
|
17
|
+
duration?: number;
|
|
18
|
+
loop: boolean;
|
|
19
|
+
};
|
|
20
|
+
export declare const getSoundEffectPlacements: (context: MulmoStudioContext) => SoundEffectPlacement[];
|
|
21
|
+
export declare const soundEffectGain: (useExplicitMix: boolean) => 0.5 | 1;
|
|
22
|
+
export declare const buildSoundEffectFilters: (inputIds: number[], placements: SoundEffectPlacement[], gain: number, outputId: string) => string[];
|
|
23
|
+
export declare const addSoundEffects: (ffmpegContext: FfmpegContext, context: MulmoStudioContext) => "mixed" | "mixed_sfx";
|
|
12
24
|
declare const addBGMAgentInfo: AgentFunctionInfo;
|
|
13
25
|
export default addBGMAgentInfo;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { GraphAILogger } from "graphai";
|
|
2
2
|
import { FfmpegContextAddInput, FfmpegContextInit, FfmpegContextGenerateOutput, ffmpegGetMediaDuration } from "../utils/ffmpeg_utils.js";
|
|
3
3
|
import { MulmoStudioContextMethods } from "../methods/mulmo_studio_context.js";
|
|
4
|
+
import { MulmoMediaSourceMethods } from "../methods/mulmo_media_source.js";
|
|
4
5
|
import { isFile } from "../utils/file.js";
|
|
6
|
+
import { userAssert } from "../utils/utils.js";
|
|
7
|
+
import { getJingleFilePath } from "../utils/jinglescript.js";
|
|
5
8
|
import { agentGenerationError, agentFileNotExistError, audioAction, audioFileTarget } from "../utils/error_cause.js";
|
|
6
9
|
export const resolveAddBgmMixParams = (audioParams) => {
|
|
7
10
|
const useExplicitMix = audioParams.ttsVolume !== undefined;
|
|
@@ -11,14 +14,82 @@ export const resolveAddBgmMixParams = (audioParams) => {
|
|
|
11
14
|
voiceVolume: audioParams.audioVolume * ttsVolume,
|
|
12
15
|
};
|
|
13
16
|
};
|
|
14
|
-
export const resolveAddBgmFilterConfig = (useExplicitMix) => {
|
|
17
|
+
export const resolveAddBgmFilterConfig = (useExplicitMix, mixedInputId = "mixed") => {
|
|
15
18
|
const amixNormalize = useExplicitMix ? ":normalize=0" : "";
|
|
16
19
|
return {
|
|
17
20
|
amixNormalize,
|
|
18
|
-
mixedOutputId: useExplicitMix ? "mixed_limited" :
|
|
19
|
-
limiterFilter: useExplicitMix ?
|
|
21
|
+
mixedOutputId: useExplicitMix ? "mixed_limited" : mixedInputId,
|
|
22
|
+
limiterFilter: useExplicitMix ? `[${mixedInputId}]alimiter=limit=0.95:attack=5:release=50[mixed_limited]` : undefined,
|
|
20
23
|
};
|
|
21
24
|
};
|
|
25
|
+
// Absolute placement of every beat's sound effects on the final audio timeline. startAt counts from
|
|
26
|
+
// when the beat appears on screen, the same clock as its html animation (for the first beat, that is
|
|
27
|
+
// introPadding before its narration starts).
|
|
28
|
+
// The context must come from combineAudioFilesAgent, which sets studio.beats[].startAt.
|
|
29
|
+
export const getSoundEffectPlacements = (context) => {
|
|
30
|
+
return context.studio.script.beats.flatMap((beat, index) => (beat.soundEffects ?? []).map((soundEffect, seIndex) => {
|
|
31
|
+
const beatStartAt = context.studio.beats[index]?.startAt;
|
|
32
|
+
userAssert(beatStartAt !== undefined, `soundEffects: startAt of beat ${index} is not computed yet`);
|
|
33
|
+
const { source } = soundEffect;
|
|
34
|
+
const file = source.kind === "jinglescript" ? getJingleFilePath(source.score, context) : MulmoMediaSourceMethods.resolve(source, context);
|
|
35
|
+
userAssert(!!file, `soundEffects: unsupported source at beat ${index}, effect ${seIndex} (use url or path)`);
|
|
36
|
+
const startAt = soundEffect.startAt ?? 0;
|
|
37
|
+
const loop = soundEffect.loop ?? false;
|
|
38
|
+
// A looping effect without duration plays until the beat leaves the screen.
|
|
39
|
+
const duration = soundEffect.duration ?? (loop ? MulmoStudioContextMethods.getBeatScreenDuration(context, index) - startAt : undefined);
|
|
40
|
+
userAssert(duration === undefined || duration > 0, `soundEffects: beat ${index}, effect ${seIndex} starts after the end of the beat`);
|
|
41
|
+
return {
|
|
42
|
+
file,
|
|
43
|
+
startAt: MulmoStudioContextMethods.getBeatScreenStartAt(context, index) + startAt,
|
|
44
|
+
volume: soundEffect.volume ?? 1.0,
|
|
45
|
+
...(duration !== undefined ? { duration } : {}),
|
|
46
|
+
loop,
|
|
47
|
+
};
|
|
48
|
+
}));
|
|
49
|
+
};
|
|
50
|
+
// In legacy mode, amix (normalize=1) halves the voice and the music. Sound effects are mixed in
|
|
51
|
+
// afterwards with normalize=0, so apply the same factor to keep volume 1.0 at the gain of the narration at its
|
|
52
|
+
// default volume. audioVolume / ttsVolume deliberately do not scale sound effects.
|
|
53
|
+
export const soundEffectGain = (useExplicitMix) => (useExplicitMix ? 1.0 : 0.5);
|
|
54
|
+
const soundEffectFadeOut = 0.05; // seconds, avoids a click where the effect is cut
|
|
55
|
+
const getSoundEffectTrim = (duration) => {
|
|
56
|
+
const fade = Math.min(soundEffectFadeOut, duration);
|
|
57
|
+
return `atrim=duration=${duration}, afade=t=out:st=${duration - fade}:d=${fade}, `;
|
|
58
|
+
};
|
|
59
|
+
export const buildSoundEffectFilters = (inputIds, placements, gain, outputId) => {
|
|
60
|
+
const ids = placements.map((_, index) => `se_${index}`);
|
|
61
|
+
const filters = placements.map((placement, index) => {
|
|
62
|
+
const delayMs = Math.round(placement.startAt * 1000);
|
|
63
|
+
const trim = placement.duration !== undefined ? getSoundEffectTrim(placement.duration) : "";
|
|
64
|
+
return `[${inputIds[index]}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo, ${trim}volume=${placement.volume * gain}, adelay=${delayMs}|${delayMs}[${ids[index]}]`;
|
|
65
|
+
});
|
|
66
|
+
if (ids.length === 1) {
|
|
67
|
+
return [...filters, `[${ids[0]}]anull[${outputId}]`];
|
|
68
|
+
}
|
|
69
|
+
const inputs = ids.map((id) => "[" + id + "]").join("");
|
|
70
|
+
return [...filters, `${inputs}amix=inputs=${ids.length}:duration=longest:normalize=0[${outputId}]`];
|
|
71
|
+
};
|
|
72
|
+
// Adds the beats' sound effects to ffmpegContext and mixes them onto [mixed].
|
|
73
|
+
// Returns the id of the resulting stream ("mixed" when there are none).
|
|
74
|
+
export const addSoundEffects = (ffmpegContext, context) => {
|
|
75
|
+
const { useExplicitMix } = resolveAddBgmMixParams(context.presentationStyle.audioParams);
|
|
76
|
+
const soundEffects = getSoundEffectPlacements(context);
|
|
77
|
+
if (soundEffects.length === 0) {
|
|
78
|
+
return "mixed";
|
|
79
|
+
}
|
|
80
|
+
soundEffects.forEach(({ file }) => {
|
|
81
|
+
if (!/^https?:\/\//.test(file) && !isFile(file)) {
|
|
82
|
+
throw new Error(`AddBGMAgent soundEffect file not exist: ${file}`, {
|
|
83
|
+
cause: agentFileNotExistError("addBGMAgent", audioAction, audioFileTarget, file),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
GraphAILogger.log("soundEffects:", soundEffects);
|
|
88
|
+
const inputIds = soundEffects.map(({ file, loop }) => FfmpegContextAddInput(ffmpegContext, file, loop ? ["-stream_loop", "-1"] : undefined));
|
|
89
|
+
ffmpegContext.filterComplex.push(...buildSoundEffectFilters(inputIds, soundEffects, soundEffectGain(useExplicitMix), "sfx"));
|
|
90
|
+
ffmpegContext.filterComplex.push("[mixed][sfx]amix=inputs=2:duration=first:normalize=0[mixed_sfx]");
|
|
91
|
+
return "mixed_sfx";
|
|
92
|
+
};
|
|
22
93
|
const addBGMAgent = async ({ namedInputs, params, }) => {
|
|
23
94
|
const { voiceFile, outputFile, context } = namedInputs;
|
|
24
95
|
const { musicFile } = params;
|
|
@@ -44,8 +115,10 @@ const addBGMAgent = async ({ namedInputs, params, }) => {
|
|
|
44
115
|
const { useExplicitMix, voiceVolume } = resolveAddBgmMixParams(audioParams);
|
|
45
116
|
ffmpegContext.filterComplex.push(`[${musicInputIndex}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo, volume=${audioParams.bgmVolume}[music]`);
|
|
46
117
|
ffmpegContext.filterComplex.push(`[${voiceInputIndex}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo, volume=${voiceVolume}, adelay=${introPadding * 1000}|${introPadding * 1000}[voice]`);
|
|
47
|
-
const { amixNormalize
|
|
118
|
+
const { amixNormalize } = resolveAddBgmFilterConfig(useExplicitMix);
|
|
48
119
|
ffmpegContext.filterComplex.push(`[music][voice]amix=inputs=2:duration=longest${amixNormalize}[mixed]`);
|
|
120
|
+
const mixedInputId = addSoundEffects(ffmpegContext, context);
|
|
121
|
+
const { mixedOutputId, limiterFilter } = resolveAddBgmFilterConfig(useExplicitMix, mixedInputId);
|
|
49
122
|
if (limiterFilter) {
|
|
50
123
|
ffmpegContext.filterComplex.push(limiterFilter);
|
|
51
124
|
}
|
package/lib/cli/bin.js
CHANGED
|
File without changes
|
package/lib/mcp/server.js
CHANGED
|
File without changes
|
|
@@ -23,6 +23,8 @@ export declare const MulmoStudioContextMethods: {
|
|
|
23
23
|
needTranslate(context: MulmoStudioContext, includeCaption?: boolean): boolean | "" | undefined;
|
|
24
24
|
getIntroPadding(context: MulmoStudioContext): number;
|
|
25
25
|
getExtraPadding(context: MulmoStudioContext, index: number): number;
|
|
26
|
+
getBeatScreenStartAt(context: MulmoStudioContext, index: number): number;
|
|
27
|
+
getBeatScreenDuration(context: MulmoStudioContext, index: number): number;
|
|
26
28
|
getBeatDuration(context: MulmoStudioContext, index: number): number;
|
|
27
29
|
getVoiceOverGroupDuration(context: MulmoStudioContext, index: number): number;
|
|
28
30
|
isMovieBeat(context: MulmoStudioContext, studioBeat: MulmoStudioBeat, beat: MulmoBeat): boolean;
|
|
@@ -110,6 +110,25 @@ export const MulmoStudioContextMethods = {
|
|
|
110
110
|
}
|
|
111
111
|
return 0;
|
|
112
112
|
},
|
|
113
|
+
// When this beat appears on screen, in seconds from the start of the movie. The first beat's segment
|
|
114
|
+
// starts at 0 and absorbs the intro padding; every later beat starts when its narration does.
|
|
115
|
+
getBeatScreenStartAt(context, index) {
|
|
116
|
+
if (index === 0) {
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
return MulmoStudioContextMethods.getIntroPadding(context) + (context.studio.beats[index]?.startAt ?? 0);
|
|
120
|
+
},
|
|
121
|
+
// How long this beat stays on screen from getBeatScreenStartAt: its whole segment, including the
|
|
122
|
+
// voice_over beats that share its shot. A voice_over beat itself is counted for its own slice only.
|
|
123
|
+
// The audio ends outroPadding after the last beat; getExtraPadding gives a single-beat script only
|
|
124
|
+
// the intro padding, so its outro is added here.
|
|
125
|
+
getBeatScreenDuration(context, index) {
|
|
126
|
+
if (MulmoBeatMethods.isVoiceOver(context.studio.script.beats[index])) {
|
|
127
|
+
return MulmoStudioContextMethods.getBeatDuration(context, index);
|
|
128
|
+
}
|
|
129
|
+
const singleBeatOutro = context.studio.beats.length === 1 ? context.presentationStyle.audioParams.outroPadding : 0;
|
|
130
|
+
return MulmoStudioContextMethods.getSegmentDuration(context, index) + singleBeatOutro;
|
|
131
|
+
},
|
|
113
132
|
// The duration this beat occupies on the audio timeline.
|
|
114
133
|
getBeatDuration(context, index) {
|
|
115
134
|
return (context.studio.beats[index]?.duration ?? 0) + MulmoStudioContextMethods.getExtraPadding(context, index);
|
package/lib/types/schema.d.ts
CHANGED
|
@@ -4985,6 +4985,22 @@ export declare const mulmoMovieParamsSchema: z.ZodObject<{
|
|
|
4985
4985
|
concurrency: z.ZodOptional<z.ZodNumber>;
|
|
4986
4986
|
generateAudio: z.ZodOptional<z.ZodBoolean>;
|
|
4987
4987
|
}, z.core.$strip>;
|
|
4988
|
+
export declare const mulmoBeatSoundEffectSchema: z.ZodObject<{
|
|
4989
|
+
source: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
4990
|
+
kind: z.ZodLiteral<"url">;
|
|
4991
|
+
url: z.ZodURL;
|
|
4992
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
4993
|
+
kind: z.ZodLiteral<"path">;
|
|
4994
|
+
path: z.ZodString;
|
|
4995
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
4996
|
+
kind: z.ZodLiteral<"jinglescript">;
|
|
4997
|
+
score: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
4998
|
+
}, z.core.$strict>], "kind">;
|
|
4999
|
+
startAt: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
5000
|
+
volume: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
5001
|
+
duration: z.ZodOptional<z.ZodNumber>;
|
|
5002
|
+
loop: z.ZodOptional<z.ZodBoolean>;
|
|
5003
|
+
}, z.core.$strict>;
|
|
4988
5004
|
export declare const mulmoBeatSchema: z.ZodObject<{
|
|
4989
5005
|
speaker: z.ZodOptional<z.ZodString>;
|
|
4990
5006
|
text: z.ZodDefault<z.ZodOptional<z.ZodString>>;
|
|
@@ -9086,6 +9102,22 @@ export declare const mulmoBeatSchema: z.ZodObject<{
|
|
|
9086
9102
|
imagePrompt: z.ZodOptional<z.ZodString>;
|
|
9087
9103
|
moviePrompt: z.ZodOptional<z.ZodString>;
|
|
9088
9104
|
soundEffectPrompt: z.ZodOptional<z.ZodString>;
|
|
9105
|
+
soundEffects: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
9106
|
+
source: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
9107
|
+
kind: z.ZodLiteral<"url">;
|
|
9108
|
+
url: z.ZodURL;
|
|
9109
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
9110
|
+
kind: z.ZodLiteral<"path">;
|
|
9111
|
+
path: z.ZodString;
|
|
9112
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
9113
|
+
kind: z.ZodLiteral<"jinglescript">;
|
|
9114
|
+
score: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
9115
|
+
}, z.core.$strict>], "kind">;
|
|
9116
|
+
startAt: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
9117
|
+
volume: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
9118
|
+
duration: z.ZodOptional<z.ZodNumber>;
|
|
9119
|
+
loop: z.ZodOptional<z.ZodBoolean>;
|
|
9120
|
+
}, z.core.$strict>>>;
|
|
9089
9121
|
htmlPrompt: z.ZodOptional<z.ZodObject<{
|
|
9090
9122
|
systemPrompt: z.ZodDefault<z.ZodOptional<z.ZodString>>;
|
|
9091
9123
|
prompt: z.ZodString;
|
|
@@ -14353,6 +14385,22 @@ export declare const mulmoScriptSchema: z.ZodObject<{
|
|
|
14353
14385
|
imagePrompt: z.ZodOptional<z.ZodString>;
|
|
14354
14386
|
moviePrompt: z.ZodOptional<z.ZodString>;
|
|
14355
14387
|
soundEffectPrompt: z.ZodOptional<z.ZodString>;
|
|
14388
|
+
soundEffects: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
14389
|
+
source: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
14390
|
+
kind: z.ZodLiteral<"url">;
|
|
14391
|
+
url: z.ZodURL;
|
|
14392
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
14393
|
+
kind: z.ZodLiteral<"path">;
|
|
14394
|
+
path: z.ZodString;
|
|
14395
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
14396
|
+
kind: z.ZodLiteral<"jinglescript">;
|
|
14397
|
+
score: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
14398
|
+
}, z.core.$strict>], "kind">;
|
|
14399
|
+
startAt: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
14400
|
+
volume: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
14401
|
+
duration: z.ZodOptional<z.ZodNumber>;
|
|
14402
|
+
loop: z.ZodOptional<z.ZodBoolean>;
|
|
14403
|
+
}, z.core.$strict>>>;
|
|
14356
14404
|
htmlPrompt: z.ZodOptional<z.ZodObject<{
|
|
14357
14405
|
systemPrompt: z.ZodDefault<z.ZodOptional<z.ZodString>>;
|
|
14358
14406
|
prompt: z.ZodString;
|
|
@@ -19144,6 +19192,22 @@ export declare const mulmoStudioSchema: z.ZodObject<{
|
|
|
19144
19192
|
imagePrompt: z.ZodOptional<z.ZodString>;
|
|
19145
19193
|
moviePrompt: z.ZodOptional<z.ZodString>;
|
|
19146
19194
|
soundEffectPrompt: z.ZodOptional<z.ZodString>;
|
|
19195
|
+
soundEffects: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
19196
|
+
source: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
19197
|
+
kind: z.ZodLiteral<"url">;
|
|
19198
|
+
url: z.ZodURL;
|
|
19199
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
19200
|
+
kind: z.ZodLiteral<"path">;
|
|
19201
|
+
path: z.ZodString;
|
|
19202
|
+
}, z.core.$strict>, z.ZodObject<{
|
|
19203
|
+
kind: z.ZodLiteral<"jinglescript">;
|
|
19204
|
+
score: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
19205
|
+
}, z.core.$strict>], "kind">;
|
|
19206
|
+
startAt: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
19207
|
+
volume: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
|
|
19208
|
+
duration: z.ZodOptional<z.ZodNumber>;
|
|
19209
|
+
loop: z.ZodOptional<z.ZodBoolean>;
|
|
19210
|
+
}, z.core.$strict>>>;
|
|
19147
19211
|
htmlPrompt: z.ZodOptional<z.ZodObject<{
|
|
19148
19212
|
systemPrompt: z.ZodDefault<z.ZodOptional<z.ZodString>>;
|
|
19149
19213
|
prompt: z.ZodString;
|
package/lib/types/schema.js
CHANGED
|
@@ -612,6 +612,44 @@ export const mulmoMovieParamsSchema = z.object({
|
|
|
612
612
|
concurrency: z.number().int().positive().optional().describe("Max concurrent movie generation requests"),
|
|
613
613
|
generateAudio: z.boolean().optional().describe("Request audio generation in the video (model-dependent)"),
|
|
614
614
|
});
|
|
615
|
+
export const mulmoBeatSoundEffectSchema = z
|
|
616
|
+
.object({
|
|
617
|
+
source: z
|
|
618
|
+
.discriminatedUnion("kind", [
|
|
619
|
+
z.object({ kind: z.literal("url"), url: URLStringSchema }).strict(),
|
|
620
|
+
z.object({ kind: z.literal("path"), path: z.string().min(1) }).strict(),
|
|
621
|
+
z
|
|
622
|
+
.object({
|
|
623
|
+
kind: z.literal("jinglescript"),
|
|
624
|
+
score: z.record(z.string(), z.unknown()).describe("JingleScript score (format jinglescript/1), rendered to audio when the audio is generated"),
|
|
625
|
+
})
|
|
626
|
+
.strict(),
|
|
627
|
+
])
|
|
628
|
+
.describe("Audio file of the sound effect (url or path), or a JingleScript score to synthesize"),
|
|
629
|
+
startAt: z
|
|
630
|
+
.number()
|
|
631
|
+
.min(0)
|
|
632
|
+
.optional()
|
|
633
|
+
.default(0)
|
|
634
|
+
.describe("Seconds from when the beat appears on screen (the same clock as its html animation) at which the sound effect plays. The first beat's narration starts audioParams.introPadding later"),
|
|
635
|
+
volume: z
|
|
636
|
+
.number()
|
|
637
|
+
.min(0)
|
|
638
|
+
.max(4)
|
|
639
|
+
.optional()
|
|
640
|
+
.default(1.0)
|
|
641
|
+
.describe("Volume of the sound effect. 1.0 is the gain of the narration at its default volume; audioParams.audioVolume and ttsVolume do not affect sound effects"),
|
|
642
|
+
duration: z
|
|
643
|
+
.number()
|
|
644
|
+
.positive()
|
|
645
|
+
.optional()
|
|
646
|
+
.describe("Maximum play length in seconds; the effect is cut (with a short fade-out) after it. Default: the whole file, even past the end of the beat"),
|
|
647
|
+
loop: z
|
|
648
|
+
.boolean()
|
|
649
|
+
.optional()
|
|
650
|
+
.describe("Repeat the effect until duration, or until the beat leaves the screen when duration is not set (e.g. a ticking clock)"),
|
|
651
|
+
})
|
|
652
|
+
.strict();
|
|
615
653
|
export const mulmoBeatSchema = z
|
|
616
654
|
.object({
|
|
617
655
|
speaker: speakerIdSchema.optional(),
|
|
@@ -643,6 +681,10 @@ export const mulmoBeatSchema = z
|
|
|
643
681
|
imagePrompt: z.string().optional(),
|
|
644
682
|
moviePrompt: z.string().optional(),
|
|
645
683
|
soundEffectPrompt: z.string().optional(),
|
|
684
|
+
soundEffects: z
|
|
685
|
+
.array(mulmoBeatSoundEffectSchema)
|
|
686
|
+
.optional()
|
|
687
|
+
.describe("Sound effects mixed into the audio track during this beat. Multiple effects may overlap."),
|
|
646
688
|
htmlPrompt: htmlPromptParamsSchema.optional(),
|
|
647
689
|
enableLipSync: z.boolean().optional().describe("Enable lip sync generation for this beat"),
|
|
648
690
|
hidden: z.boolean().optional().describe("Hide this beat from the presentation"),
|
package/lib/types/type.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type CallbackFunction } from "graphai";
|
|
2
2
|
import type { UsageCollectorAPI } from "./usage.js";
|
|
3
|
-
import { langSchema, localizedTextSchema, mulmoBeatSchema, mulmoScriptSchema, mulmoStudioSchema, mulmoStudioBeatSchema, mulmoStoryboardSchema, mulmoStoryboardSceneSchema, mulmoStudioMultiLingualSchema, mulmoStudioMultiLingualArraySchema, mulmoStudioMultiLingualDataSchema, mulmoStudioMultiLingualFileSchema, speakerDictionarySchema, speakerSchema, mulmoSpeechParamsSchema, mulmoImageParamsSchema, mulmoImageParamsImagesValueSchema, mulmoImageParamsImagesSchema, mulmoFillOptionSchema, mulmoTransitionSchema, mulmoVideoFilterSchema, mulmoMovieParamsSchema, mulmoSoundEffectParamsSchema, mulmoLipSyncParamsSchema, textSlideParamsSchema, speechOptionsSchema, speakerDataSchema, mulmoCanvasDimensionSchema, mulmoPromptTemplateSchema, mulmoPromptTemplateFileSchema, text2ImageProviderSchema, text2HtmlImageProviderSchema, text2MovieProviderSchema, text2SpeechProviderSchema, mulmoPresentationStyleSchema, multiLingualTextsSchema, mulmoImageAssetSchema, mulmoMermaidMediaSchema, mulmoRemotionMediaSchema, remotionCodeSourceSchema, mulmoTextSlideMediaSchema, mulmoMarkdownMediaSchema, mulmoImageMediaSchema, mulmoChartMediaSchema, mediaSourceSchema, mediaSourceMermaidSchema, backgroundImageSchema, backgroundImageSourceSchema, mulmoSessionStateSchema, mulmoOpenAIImageModelSchema, mulmoGoogleImageModelSchema, mulmoGoogleMovieModelSchema, mulmoReplicateMovieModelSchema, mulmoImagePromptMediaSchema, mulmoImageReferenceSchema, mulmoMovieMediaSchema, mulmoMoviePromptMediaSchema, markdownLayoutSchema, row2Schema, grid2x2Schema, mulmoAvatarParamsSchema, avatarPositionSchema, speakerAvatarSchema, mulmoAvatarTrackSchema } from "./schema.js";
|
|
3
|
+
import { langSchema, localizedTextSchema, mulmoBeatSchema, mulmoScriptSchema, mulmoStudioSchema, mulmoStudioBeatSchema, mulmoStoryboardSchema, mulmoStoryboardSceneSchema, mulmoStudioMultiLingualSchema, mulmoStudioMultiLingualArraySchema, mulmoStudioMultiLingualDataSchema, mulmoStudioMultiLingualFileSchema, speakerDictionarySchema, speakerSchema, mulmoSpeechParamsSchema, mulmoImageParamsSchema, mulmoImageParamsImagesValueSchema, mulmoImageParamsImagesSchema, mulmoFillOptionSchema, mulmoTransitionSchema, mulmoVideoFilterSchema, mulmoMovieParamsSchema, mulmoSoundEffectParamsSchema, mulmoBeatSoundEffectSchema, mulmoLipSyncParamsSchema, textSlideParamsSchema, speechOptionsSchema, speakerDataSchema, mulmoCanvasDimensionSchema, mulmoPromptTemplateSchema, mulmoPromptTemplateFileSchema, text2ImageProviderSchema, text2HtmlImageProviderSchema, text2MovieProviderSchema, text2SpeechProviderSchema, mulmoPresentationStyleSchema, multiLingualTextsSchema, mulmoImageAssetSchema, mulmoMermaidMediaSchema, mulmoRemotionMediaSchema, remotionCodeSourceSchema, mulmoTextSlideMediaSchema, mulmoMarkdownMediaSchema, mulmoImageMediaSchema, mulmoChartMediaSchema, mediaSourceSchema, mediaSourceMermaidSchema, backgroundImageSchema, backgroundImageSourceSchema, mulmoSessionStateSchema, mulmoOpenAIImageModelSchema, mulmoGoogleImageModelSchema, mulmoGoogleMovieModelSchema, mulmoReplicateMovieModelSchema, mulmoImagePromptMediaSchema, mulmoImageReferenceSchema, mulmoMovieMediaSchema, mulmoMoviePromptMediaSchema, markdownLayoutSchema, row2Schema, grid2x2Schema, mulmoAvatarParamsSchema, avatarPositionSchema, speakerAvatarSchema, mulmoAvatarTrackSchema } from "./schema.js";
|
|
4
4
|
import { pdf_modes, pdf_sizes, storyToScriptGenerateMode } from "./const.js";
|
|
5
5
|
import type { LLM } from "./provider2agent.js";
|
|
6
6
|
import { z } from "zod";
|
|
@@ -43,6 +43,7 @@ export type MulmoStudioMultiLingualFile = z.infer<typeof mulmoStudioMultiLingual
|
|
|
43
43
|
export type MultiLingualTexts = z.infer<typeof multiLingualTextsSchema>;
|
|
44
44
|
export type MulmoMovieParams = z.infer<typeof mulmoMovieParamsSchema>;
|
|
45
45
|
export type MulmoSoundEffectParams = z.infer<typeof mulmoSoundEffectParamsSchema>;
|
|
46
|
+
export type MulmoBeatSoundEffect = z.infer<typeof mulmoBeatSoundEffectSchema>;
|
|
46
47
|
export type MulmoLipSyncParams = z.infer<typeof mulmoLipSyncParamsSchema>;
|
|
47
48
|
export type MulmoAvatarParams = z.infer<typeof mulmoAvatarParamsSchema>;
|
|
48
49
|
export type MulmoAvatarPosition = z.infer<typeof avatarPositionSchema>;
|
package/lib/utils/context.d.ts
CHANGED
|
@@ -2504,6 +2504,22 @@ export declare const createStudioData: (_mulmoScript: MulmoScript, fileName: str
|
|
|
2504
2504
|
imagePrompt?: string | undefined;
|
|
2505
2505
|
moviePrompt?: string | undefined;
|
|
2506
2506
|
soundEffectPrompt?: string | undefined;
|
|
2507
|
+
soundEffects?: {
|
|
2508
|
+
source: {
|
|
2509
|
+
kind: "url";
|
|
2510
|
+
url: string;
|
|
2511
|
+
} | {
|
|
2512
|
+
kind: "path";
|
|
2513
|
+
path: string;
|
|
2514
|
+
} | {
|
|
2515
|
+
kind: "jinglescript";
|
|
2516
|
+
score: Record<string, unknown>;
|
|
2517
|
+
};
|
|
2518
|
+
startAt: number;
|
|
2519
|
+
volume: number;
|
|
2520
|
+
duration?: number | undefined;
|
|
2521
|
+
loop?: boolean | undefined;
|
|
2522
|
+
}[] | undefined;
|
|
2507
2523
|
htmlPrompt?: {
|
|
2508
2524
|
systemPrompt: string;
|
|
2509
2525
|
prompt: string;
|
|
@@ -5143,6 +5159,22 @@ export declare const initializeContextFromFiles: (files: FileObject, raiseError:
|
|
|
5143
5159
|
imagePrompt?: string | undefined;
|
|
5144
5160
|
moviePrompt?: string | undefined;
|
|
5145
5161
|
soundEffectPrompt?: string | undefined;
|
|
5162
|
+
soundEffects?: {
|
|
5163
|
+
source: {
|
|
5164
|
+
kind: "url";
|
|
5165
|
+
url: string;
|
|
5166
|
+
} | {
|
|
5167
|
+
kind: "path";
|
|
5168
|
+
path: string;
|
|
5169
|
+
} | {
|
|
5170
|
+
kind: "jinglescript";
|
|
5171
|
+
score: Record<string, unknown>;
|
|
5172
|
+
};
|
|
5173
|
+
startAt: number;
|
|
5174
|
+
volume: number;
|
|
5175
|
+
duration?: number | undefined;
|
|
5176
|
+
loop?: boolean | undefined;
|
|
5177
|
+
}[] | undefined;
|
|
5146
5178
|
htmlPrompt?: {
|
|
5147
5179
|
systemPrompt: string;
|
|
5148
5180
|
prompt: string;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { MulmoStudioContext } from "../types/index.js";
|
|
2
|
+
type JingleScore = Record<string, unknown>;
|
|
3
|
+
export declare const getJingleFilePath: (score: JingleScore, context: MulmoStudioContext) => string;
|
|
4
|
+
export declare const getJingleScores: (context: MulmoStudioContext) => {
|
|
5
|
+
score: Record<string, unknown>;
|
|
6
|
+
beatIndex: number;
|
|
7
|
+
effectIndex: number;
|
|
8
|
+
}[];
|
|
9
|
+
export declare const validateJingleScores: (context: MulmoStudioContext) => void;
|
|
10
|
+
export declare const renderJingleScores: (context: MulmoStudioContext) => Promise<void>;
|
|
11
|
+
export declare const writeFileAtomically: (filePath: string, data: Uint8Array) => void;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { GraphAILogger } from "graphai";
|
|
4
|
+
import { checkScore, formatProblem, loadSamples, parseScore, render, toWav } from "jinglescript";
|
|
5
|
+
import { MulmoStudioContextMethods } from "../methods/mulmo_studio_context.js";
|
|
6
|
+
import { hashSHA256, mkdir } from "./file.js";
|
|
7
|
+
import { userAssert } from "./utils.js";
|
|
8
|
+
// The audio mix (addBGMAgent) runs at 44.1 kHz.
|
|
9
|
+
const JINGLE_SAMPLE_RATE = 44100;
|
|
10
|
+
// Per download of a recorded sample (grandpiano: about 1.2 MB per note).
|
|
11
|
+
const SAMPLE_FETCH_TIMEOUT_MS = 60000;
|
|
12
|
+
// Rendered jingles are cached by the hash of their score, so identical scores share one file.
|
|
13
|
+
export const getJingleFilePath = (score, context) => {
|
|
14
|
+
return path.join(MulmoStudioContextMethods.getAudioDirPath(context), `jingle_${hashSHA256(JSON.stringify(score))}.wav`);
|
|
15
|
+
};
|
|
16
|
+
export const getJingleScores = (context) => {
|
|
17
|
+
return context.studio.script.beats.flatMap((beat, beatIndex) => (beat.soundEffects ?? []).flatMap((soundEffect, effectIndex) => soundEffect.source.kind === "jinglescript" ? [{ score: soundEffect.source.score, beatIndex, effectIndex }] : []));
|
|
18
|
+
};
|
|
19
|
+
// Checks every JingleScript score in the script; throws listing every problem with its location.
|
|
20
|
+
export const validateJingleScores = (context) => {
|
|
21
|
+
const problems = getJingleScores(context).flatMap(({ score, beatIndex, effectIndex }) => checkScore(score).errors.map((problem) => `beats[${beatIndex}].soundEffects[${effectIndex}].source.score: ${formatProblem(problem).trim()}`));
|
|
22
|
+
userAssert(problems.length === 0, `Invalid JingleScript score:\n${problems.join("\n")}`);
|
|
23
|
+
};
|
|
24
|
+
const fetchWithTimeout = (input, init) => fetch(input, { ...init, signal: AbortSignal.timeout(SAMPLE_FETCH_TIMEOUT_MS) });
|
|
25
|
+
// Sampled instruments (grandpiano) play recordings that jinglescript downloads on first use and
|
|
26
|
+
// caches; a score without them loads nothing.
|
|
27
|
+
const loadJingleSamples = async (score, location) => {
|
|
28
|
+
try {
|
|
29
|
+
return await loadSamples(score, { fetch: fetchWithTimeout });
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
throw new Error(`${location}: failed to load JingleScript samples: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
// Renders the JingleScript scores of the script to WAV files (skipping cached ones unless forced).
|
|
36
|
+
export const renderJingleScores = async (context) => {
|
|
37
|
+
validateJingleScores(context);
|
|
38
|
+
for (const { score, beatIndex, effectIndex } of getJingleScores(context)) {
|
|
39
|
+
const filePath = getJingleFilePath(score, context);
|
|
40
|
+
if (!context.force && fs.existsSync(filePath)) {
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const parsed = parseScore(score);
|
|
44
|
+
const samples = await loadJingleSamples(parsed, `beats[${beatIndex}].soundEffects[${effectIndex}].source.score`);
|
|
45
|
+
const { audio, sampleRate } = render(parsed, { sampleRate: JINGLE_SAMPLE_RATE, samples });
|
|
46
|
+
mkdir(path.dirname(filePath));
|
|
47
|
+
writeFileAtomically(filePath, toWav(audio, sampleRate));
|
|
48
|
+
GraphAILogger.info(`jinglescript: rendered ${filePath}`);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
// Writes to a temporary file next to the target, then renames it, so an interrupted write never
|
|
52
|
+
// leaves a partial WAV that a later run would take for a cached one.
|
|
53
|
+
export const writeFileAtomically = (filePath, data) => {
|
|
54
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
55
|
+
try {
|
|
56
|
+
fs.writeFileSync(tmpPath, data);
|
|
57
|
+
fs.renameSync(tmpPath, filePath);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
fs.rmSync(tmpPath, { force: true });
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
};
|