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 `