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:
soroush.asadi
2026-06-23 17:31:19 +03:30
parent c0d04fa855
commit 3eab1056c8
7 changed files with 92 additions and 14 deletions
@@ -1,5 +1,5 @@
import React from "react";
import { AbsoluteFill, Sequence, useVideoConfig } from "remotion";
import { AbsoluteFill, Audio, Sequence, staticFile, useVideoConfig } from "remotion";
import { z } from "zod";
import { colorSchema } from "../lib/branding";
import { FONT } from "../lib/fonts";
@@ -21,11 +21,16 @@ export const flexStorySchema = z.object({
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,
});
type Props = z.infer<typeof flexStorySchema>;
const FPS = 30;
const resolveAudio = (u: string) => (/^https?:\/\//.test(u) ? u : staticFile(u));
export const flexStoryDefaults: Props = {
scenes: [
@@ -40,6 +45,9 @@ export const flexStoryDefaults: Props = {
secondaryColor: "#6f9d96",
backgroundColor: "#ece4d6",
textColor: "#2b3a55",
music: "audio/music-ambient.mp3",
musicVolume: 0.6,
sfx: true,
};
const activeScenes = (props: Props) =>
@@ -55,21 +63,44 @@ export const FlexStory: React.FC<Props> = (props) => {
textColor: props.textColor,
};
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 (
<AbsoluteFill style={{ backgroundColor: colors.backgroundColor, fontFamily: FONT }}>
{music ? <Audio src={resolveAudio(music)} loop volume={musicVolume} /> : null}
{scenes.map((sc, i) => {
const block = getBlock(sc.blockId)!;
const dur = Math.round(clampDuration(sc.durationSec, block) * fps);
const Comp = block.component;
const node = (
<Sequence key={i} from={from} durationInFrames={dur}>
<Comp data={withDefaults(block, sc.props || {})} colors={colors} L={L} index={i} total={scenes.length} durationInFrames={dur} />
const Comp = getBlock(sc.blockId)!.component;
return (
<Sequence key={i} from={starts[i]} durationInFrames={durations[i]}>
<Comp data={withDefaults(getBlock(sc.blockId)!, sc.props || {})} colors={colors} L={L} index={i} total={scenes.length} durationInFrames={durations[i]} />
</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>
);
};