feat(remotion): audio layer — self-authored music bed + transition SFX in FlexStory
Adds audio to the scene engine without any third-party/geo-blocked sourcing: the beds + SFX are synthesized with ffmpeg, so they're license-free (CC0, self-authored) and need no acquisition — the same play as self-authoring Lottie. - public/audio/: music-ambient.mp3 (soft 3-tone pad, looped) + sfx-whoosh/pop/chime. - FlexStory: optional music/musicVolume/sfx props (optional so the existing render binding needs no change). Renders <Audio loop> for the bed + a whoosh at each scene start and a chime on the final scene, driven by precomputed scene starts. - check-assets: now also scans public/audio (+ lottie) with folder-prefixed keys; assets.json ledgers the 4 audio files (CC0 self-authored). Verified: tsc clean; a 6s FlexStory render produces an MP4 with a real audio stream (ffprobe: codec_type=audio). NOTE: these are placeholder/SFX-grade; a premium curated music library (by vibe) is a separate sourcing sweep, and the studio music picker → FlexStory `music` prop is a follow-up wiring. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -358,5 +358,45 @@
|
|||||||
"generator": "createAvatar(openPeeps,{seed:'flatrender-peep-30'})",
|
"generator": "createAvatar(openPeeps,{seed:'flatrender-peep-30'})",
|
||||||
"url": "https://www.dicebear.com/styles/open-peeps/",
|
"url": "https://www.dicebear.com/styles/open-peeps/",
|
||||||
"vendored": "2026-06-22"
|
"vendored": "2026-06-22"
|
||||||
|
},
|
||||||
|
"audio/music-ambient.mp3": {
|
||||||
|
"source": "FlatRender (self-authored, ffmpeg synthesis)",
|
||||||
|
"license": "CC0-1.0",
|
||||||
|
"license_class": "CC0",
|
||||||
|
"commercial_ok": true,
|
||||||
|
"attribution_required": false,
|
||||||
|
"ai_training_allowed": true,
|
||||||
|
"note": "ambient pad music bed (looped)",
|
||||||
|
"vendored": "2026-06-23"
|
||||||
|
},
|
||||||
|
"audio/sfx-whoosh.mp3": {
|
||||||
|
"source": "FlatRender (self-authored, ffmpeg synthesis)",
|
||||||
|
"license": "CC0-1.0",
|
||||||
|
"license_class": "CC0",
|
||||||
|
"commercial_ok": true,
|
||||||
|
"attribution_required": false,
|
||||||
|
"ai_training_allowed": true,
|
||||||
|
"note": "scene-transition whoosh",
|
||||||
|
"vendored": "2026-06-23"
|
||||||
|
},
|
||||||
|
"audio/sfx-pop.mp3": {
|
||||||
|
"source": "FlatRender (self-authored, ffmpeg synthesis)",
|
||||||
|
"license": "CC0-1.0",
|
||||||
|
"license_class": "CC0",
|
||||||
|
"commercial_ok": true,
|
||||||
|
"attribution_required": false,
|
||||||
|
"ai_training_allowed": true,
|
||||||
|
"note": "accent pop",
|
||||||
|
"vendored": "2026-06-23"
|
||||||
|
},
|
||||||
|
"audio/sfx-chime.mp3": {
|
||||||
|
"source": "FlatRender (self-authored, ffmpeg synthesis)",
|
||||||
|
"license": "CC0-1.0",
|
||||||
|
"license_class": "CC0",
|
||||||
|
"commercial_ok": true,
|
||||||
|
"attribution_required": false,
|
||||||
|
"ai_training_allowed": true,
|
||||||
|
"note": "outro chime",
|
||||||
|
"vendored": "2026-06-23"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,14 +24,21 @@ function walk(dir) {
|
|||||||
for (const e of readdirSync(dir)) {
|
for (const e of readdirSync(dir)) {
|
||||||
const p = join(dir, e);
|
const p = join(dir, e);
|
||||||
if (statSync(p).isDirectory()) out = out.concat(walk(p));
|
if (statSync(p).isDirectory()) out = out.concat(walk(p));
|
||||||
else if (/\.(svg|json)$/i.test(e) && e !== "assets.json") out.push(p);
|
else if (/\.(svg|json|mp3|wav|ogg|m4a)$/i.test(e) && e !== "assets.json") out.push(p);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Asset files live under illustrations/<source>/ and lottie/.
|
// Ledger keys: illustrations are relative to illustrations/ (e.g. dicebear/x.svg);
|
||||||
const files = [...walk(ILLUS), ...walk(join(PUBLIC, "lottie"))]
|
// lottie + audio carry their folder prefix (lottie/x.json, audio/x.mp3).
|
||||||
.map((p) => relative(ILLUS, p).split("\\").join("/"));
|
const SCAN = [
|
||||||
|
{ root: ILLUS, prefix: "" },
|
||||||
|
{ root: join(PUBLIC, "lottie"), prefix: "lottie/" },
|
||||||
|
{ root: join(PUBLIC, "audio"), prefix: "audio/" },
|
||||||
|
];
|
||||||
|
const files = SCAN.flatMap(({ root, prefix }) =>
|
||||||
|
walk(root).map((p) => prefix + relative(root, p).split("\\").join("/"))
|
||||||
|
);
|
||||||
|
|
||||||
const missing = files.filter((f) => !ledger[f]);
|
const missing = files.filter((f) => !ledger[f]);
|
||||||
if (missing.length) {
|
if (missing.length) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { AbsoluteFill, Sequence, useVideoConfig } from "remotion";
|
import { AbsoluteFill, Audio, Sequence, staticFile, useVideoConfig } from "remotion";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { colorSchema } from "../lib/branding";
|
import { colorSchema } from "../lib/branding";
|
||||||
import { FONT } from "../lib/fonts";
|
import { FONT } from "../lib/fonts";
|
||||||
@@ -21,11 +21,16 @@ export const flexStorySchema = z.object({
|
|||||||
props: z.record(z.string()),
|
props: z.record(z.string()),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
// Audio (optional so the existing render binding doesn't need to send them).
|
||||||
|
music: z.string().optional(), // path/url of the music bed; "" = silent
|
||||||
|
musicVolume: z.number().optional(),
|
||||||
|
sfx: z.boolean().optional(), // transition whoosh + outro chime
|
||||||
...colorSchema,
|
...colorSchema,
|
||||||
});
|
});
|
||||||
type Props = z.infer<typeof flexStorySchema>;
|
type Props = z.infer<typeof flexStorySchema>;
|
||||||
|
|
||||||
const FPS = 30;
|
const FPS = 30;
|
||||||
|
const resolveAudio = (u: string) => (/^https?:\/\//.test(u) ? u : staticFile(u));
|
||||||
|
|
||||||
export const flexStoryDefaults: Props = {
|
export const flexStoryDefaults: Props = {
|
||||||
scenes: [
|
scenes: [
|
||||||
@@ -40,6 +45,9 @@ export const flexStoryDefaults: Props = {
|
|||||||
secondaryColor: "#6f9d96",
|
secondaryColor: "#6f9d96",
|
||||||
backgroundColor: "#ece4d6",
|
backgroundColor: "#ece4d6",
|
||||||
textColor: "#2b3a55",
|
textColor: "#2b3a55",
|
||||||
|
music: "audio/music-ambient.mp3",
|
||||||
|
musicVolume: 0.6,
|
||||||
|
sfx: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeScenes = (props: Props) =>
|
const activeScenes = (props: Props) =>
|
||||||
@@ -55,21 +63,44 @@ export const FlexStory: React.FC<Props> = (props) => {
|
|||||||
textColor: props.textColor,
|
textColor: props.textColor,
|
||||||
};
|
};
|
||||||
const scenes = activeScenes(props);
|
const scenes = activeScenes(props);
|
||||||
let from = 0;
|
const music = props.music === undefined ? "audio/music-ambient.mp3" : props.music;
|
||||||
|
const musicVolume = props.musicVolume ?? 0.6;
|
||||||
|
const sfx = props.sfx ?? true;
|
||||||
|
|
||||||
|
// Precompute each scene's start frame + duration (shared by visuals + SFX).
|
||||||
|
const starts: number[] = [];
|
||||||
|
let acc = 0;
|
||||||
|
const durations = scenes.map((sc) => {
|
||||||
|
const dur = Math.round(clampDuration(sc.durationSec, getBlock(sc.blockId)!) * fps);
|
||||||
|
starts.push(acc);
|
||||||
|
acc += dur;
|
||||||
|
return dur;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AbsoluteFill style={{ backgroundColor: colors.backgroundColor, fontFamily: FONT }}>
|
<AbsoluteFill style={{ backgroundColor: colors.backgroundColor, fontFamily: FONT }}>
|
||||||
|
{music ? <Audio src={resolveAudio(music)} loop volume={musicVolume} /> : null}
|
||||||
|
|
||||||
{scenes.map((sc, i) => {
|
{scenes.map((sc, i) => {
|
||||||
const block = getBlock(sc.blockId)!;
|
const Comp = getBlock(sc.blockId)!.component;
|
||||||
const dur = Math.round(clampDuration(sc.durationSec, block) * fps);
|
return (
|
||||||
const Comp = block.component;
|
<Sequence key={i} from={starts[i]} durationInFrames={durations[i]}>
|
||||||
const node = (
|
<Comp data={withDefaults(getBlock(sc.blockId)!, sc.props || {})} colors={colors} L={L} index={i} total={scenes.length} durationInFrames={durations[i]} />
|
||||||
<Sequence key={i} from={from} durationInFrames={dur}>
|
|
||||||
<Comp data={withDefaults(block, sc.props || {})} colors={colors} L={L} index={i} total={scenes.length} durationInFrames={dur} />
|
|
||||||
</Sequence>
|
</Sequence>
|
||||||
);
|
);
|
||||||
from += dur;
|
|
||||||
return node;
|
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Transition SFX: whoosh at each scene start, a chime on the final scene. */}
|
||||||
|
{sfx
|
||||||
|
? scenes.map((_, i) => (
|
||||||
|
<Sequence key={`sfx${i}`} from={starts[i]}>
|
||||||
|
<Audio
|
||||||
|
src={staticFile(i === scenes.length - 1 ? "audio/sfx-chime.mp3" : "audio/sfx-whoosh.mp3")}
|
||||||
|
volume={0.5}
|
||||||
|
/>
|
||||||
|
</Sequence>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
</AbsoluteFill>
|
</AbsoluteFill>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user