diff --git a/apps/daemon/src/media/hyperframes-audio.ts b/apps/daemon/src/media/hyperframes-audio.ts index 44f11d1ef2..3e528e9666 100644 --- a/apps/daemon/src/media/hyperframes-audio.ts +++ b/apps/daemon/src/media/hyperframes-audio.ts @@ -58,10 +58,17 @@ export function hyperFramesAudioEncodingArgs(tracks: HyperFramesAudioTrack[], du // Filters stay within FFmpeg 4.1 (the bundled Linux build): every track is // made stereo so one per-channel delay covers it (no `adelay all=1`), and // amix's input averaging is undone with a gain (no `amix normalize=0`). + // Each track fades out where it stops sounding: its own authored end or the + // film's end, whichever comes first. A track that already ends quietly is + // unchanged; one cut short (a remake made shorter than its music) no longer + // stops on a click. const filters = tracks.map((track, index) => { const delayMs = Math.round(track.start * 1000); + const audible = Math.min(track.duration, duration - track.start); + const fade = audible > 0 ? Math.min(0.6, audible / 4) : 0; + const fadeOut = fade > 0 ? `afade=t=out:st=${round(audible - fade)}:d=${round(fade)},` : ''; return `[${index + 1}:a]atrim=duration=${track.duration},asetpts=PTS-STARTPTS,` - + `aformat=channel_layouts=stereo,volume=${track.volume},adelay=${delayMs}|${delayMs}[a${index}]`; + + `aformat=channel_layouts=stereo,${fadeOut}volume=${track.volume},adelay=${delayMs}|${delayMs}[a${index}]`; }); const mixed = tracks.length === 1 ? '[a0]' @@ -70,3 +77,7 @@ export function hyperFramesAudioEncodingArgs(tracks: HyperFramesAudioTrack[], du filters.push(`${mixed}apad,atrim=duration=${duration}[audio]`); return ['-filter_complex', filters.join(';'), '-map', '0:v', '-map', '[audio]', '-c:a', 'aac', '-b:a', '192k']; } + +function round(seconds: number): number { + return Math.round(seconds * 1000) / 1000; +} diff --git a/apps/daemon/tests/media/hyperframes-audio.test.ts b/apps/daemon/tests/media/hyperframes-audio.test.ts index 51301fe817..93d86f6148 100644 --- a/apps/daemon/tests/media/hyperframes-audio.test.ts +++ b/apps/daemon/tests/media/hyperframes-audio.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, writeFile, rm, symlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, expect, it } from 'vitest'; -import { prepareHyperFramesAudio } from '../../src/media/hyperframes-audio.js'; +import { hyperFramesAudioEncodingArgs, prepareHyperFramesAudio } from '../../src/media/hyperframes-audio.js'; let root: string; beforeEach(async () => { @@ -33,3 +33,17 @@ it.each(['data-start="intro + 2"', 'data-volume="2"', 'loop', 'data-media-start= await expect(prepareHyperFramesAudio(``, root)).rejects.toThrow(); }, ); + +it('fades each track out where it stops sounding, including a cut at the film end', () => { + const track = { file: 'music.mp3', format: 'mp3', volume: 1 }; + const graph = (tracks: Parameters[0], duration: number) => + hyperFramesAudioEncodingArgs(tracks, duration)[1]; + // Music authored for 15 s in a film cut to 10 s fades over the film's last 0.6 s. + expect(graph([{ ...track, start: 0, duration: 15 }], 10)).toContain('afade=t=out:st=9.4:d=0.6'); + // A track that ends inside the film fades at its own end, offset by its start. + expect(graph([{ ...track, start: 2, duration: 4 }], 10)).toContain('afade=t=out:st=3.4:d=0.6'); + // Short tracks keep most of their sound: the fade is at most a quarter of what plays. + expect(graph([{ ...track, start: 0, duration: 1 }], 10)).toContain('afade=t=out:st=0.75:d=0.25'); + // A track that starts after the film ends contributes no fade. + expect(graph([{ ...track, start: 12, duration: 3 }], 10)).not.toContain('afade'); +}); diff --git a/docs/motion-template-sources.md b/docs/motion-template-sources.md index 17f202aa4c..b379b06346 100644 --- a/docs/motion-template-sources.md +++ b/docs/motion-template-sources.md @@ -94,4 +94,8 @@ Every template carries an original procedural soundtrack in used and no audio-generation model was involved. HTML preview sound starts with the film; when the browser blocks autoplay, the first click or key press turns it on, and Sound off mutes it. Sound follows the master timeline. The desktop -export path mixes the timed local audio into the MP4 with FFmpeg. +export path mixes the timed local audio into the MP4 with FFmpeg and fades each +track out where it stops, so a remake made shorter than its music ends cleanly. +Make-same keeps the template's soundtrack by default, because the music is part +of the look being remade; a brief that asks for no music or different music +takes precedence. diff --git a/plugins/_official/examples/motion-energy-reel/SKILL.md b/plugins/_official/examples/motion-energy-reel/SKILL.md index 07931519c7..661b8ad740 100644 --- a/plugins/_official/examples/motion-energy-reel/SKILL.md +++ b/plugins/_official/examples/motion-energy-reel/SKILL.md @@ -65,7 +65,12 @@ frame expensive and slow the MP4 export. ## Sound Carry `assets/soundtrack.mp3` with the reference files. It is an original procedural soundtrack authored for this example and timed to its beats; no -audio from the source video is used. Keep the `