diff --git a/services/remotion/public/audio/music-ambient.mp3 b/services/remotion/public/audio/music-ambient.mp3 new file mode 100644 index 0000000..6f6a0ef Binary files /dev/null and b/services/remotion/public/audio/music-ambient.mp3 differ diff --git a/services/remotion/public/audio/sfx-chime.mp3 b/services/remotion/public/audio/sfx-chime.mp3 new file mode 100644 index 0000000..af145c6 Binary files /dev/null and b/services/remotion/public/audio/sfx-chime.mp3 differ diff --git a/services/remotion/public/audio/sfx-pop.mp3 b/services/remotion/public/audio/sfx-pop.mp3 new file mode 100644 index 0000000..c27463e Binary files /dev/null and b/services/remotion/public/audio/sfx-pop.mp3 differ diff --git a/services/remotion/public/audio/sfx-whoosh.mp3 b/services/remotion/public/audio/sfx-whoosh.mp3 new file mode 100644 index 0000000..a17c9ce Binary files /dev/null and b/services/remotion/public/audio/sfx-whoosh.mp3 differ diff --git a/services/remotion/public/illustrations/assets.json b/services/remotion/public/illustrations/assets.json index bc95345..0781c69 100644 --- a/services/remotion/public/illustrations/assets.json +++ b/services/remotion/public/illustrations/assets.json @@ -358,5 +358,45 @@ "generator": "createAvatar(openPeeps,{seed:'flatrender-peep-30'})", "url": "https://www.dicebear.com/styles/open-peeps/", "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" } } diff --git a/services/remotion/scripts/check-assets.mjs b/services/remotion/scripts/check-assets.mjs index 1af3e1c..d1be2cd 100644 --- a/services/remotion/scripts/check-assets.mjs +++ b/services/remotion/scripts/check-assets.mjs @@ -24,14 +24,21 @@ function walk(dir) { for (const e of readdirSync(dir)) { const p = join(dir, e); 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; } -// Asset files live under illustrations// and lottie/. -const files = [...walk(ILLUS), ...walk(join(PUBLIC, "lottie"))] - .map((p) => relative(ILLUS, p).split("\\").join("/")); +// Ledger keys: illustrations are relative to illustrations/ (e.g. dicebear/x.svg); +// lottie + audio carry their folder prefix (lottie/x.json, audio/x.mp3). +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]); if (missing.length) { diff --git a/services/remotion/src/compositions/FlexStory.tsx b/services/remotion/src/compositions/FlexStory.tsx index cd03b5f..e6ed27d 100644 --- a/services/remotion/src/compositions/FlexStory.tsx +++ b/services/remotion/src/compositions/FlexStory.tsx @@ -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; 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) => { 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 ( + {music ? ); };