Skip to content

fix(video): Concatenate Videos preserves audio (cut and transitions) - #148

Open
lstein wants to merge 2 commits into
minimax-h3/08-tae-previewfrom
minimax-h3/09-concat-audio
Open

fix(video): Concatenate Videos preserves audio (cut and transitions)#148
lstein wants to merge 2 commits into
minimax-h3/08-tae-previewfrom
minimax-h3/09-concat-audio

Conversation

@lstein

@lstein lstein commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Summary

Concatenate Videos dropped audio. The node decoded inputs with imageio's frame iterator (video only) and encoded with make_mp4_writer without audio arguments, so any input carrying a soundtrack — every MiniMax H3 clip — produced a silent output. Extending an H3 video always lost its audio. (The node predates H3 here: it was built for Wan clips, which are silent, so there was nothing to lose.)

What it does now

When at least one input carries audio, the node rebuilds the soundtrack on the emitted timeline and muxes it in:

  • Decode via the bundled imageio-ffmpeg binary (new invokeai/app/util/video_audio.py: extract_audio_pcm, resample_linear, mux_audio_into_video). Verified against ffmpeg 7.0.2 that extraction of our own MP4s is sample-exact (edit-list/AAC-priming handled; onset error 0.06 ms).
  • Timeline mapping: each clip's PCM is sliced to its video's native span and mapped onto the output with one linear resample — covering sample-rate unification (to the first audible clip's rate) and fps-override retiming (audio speed/pitch follows the video's retime). Native-span preference: probed fps → probed duration → extracted length.
  • Assembly: cut splices sample-accurately against the per-clip frame counts actually decoded (recorded by the frame generator); crossfade blends the boundary equal-power; fade_through_black ramps out to silence and back in, with the same asymmetric odd-tf split as the frame path. Silent inputs contribute silence.
  • Mux: the finished track is attached to the already-encoded MP4 with a video stream copy (no re-encode).
  • All-silent inputs keep the old behavior (no audio stream). Audio failures fail the node loudly — a silent output masquerading as success is the exact bug this fixes.

Adversarial review

A fresh-context review attacked the timeline math empirically (impulse probes, boundary alignment, AAC priming behavior of the actual bundled ffmpeg). Two real bugs found and fixed, with regression tests that reproduce each:

  1. fade_through_black with transition_frames=1: the tail window is empty (1 // 2 == 0), and the boundary blend was keyed on the tail's existence — the incoming head frame's audio was silently dropped, shifting all subsequent audio earlier and masking the loss as trailing silence. Boundaries are now marked pending even with an empty tail window.
  2. fps override + unknown probe fps (VFR-flagged containers): the retime was skipped for that clip — audio played at original speed and went silent for its back half (or truncated). The probed duration is now the fallback for the native span, then the extracted length.

Also from review: subprocess.TimeoutExpired wrapped into typed errors, an extra cancel checkpoint, pre-resample copies freed eagerly, and the module docstring now states the audio path's memory profile honestly (O(total audio), a deliberate exception to the frame path's O(transition) bound — audio is ~3 orders of magnitude smaller per second).

Testing

17 new tests (real ffmpeg round-trips, sine tones, FFT assertions): extraction round-trip and silent detection, cut splicing, silent-input silence, crossfade length/blend/orientation, fade-through-black dip and the tf=1 regression, fps retime and both unknown-fps fallbacks, and an end-to-end encode→build→mux pipeline check. 41 concat tests pass overall.

Node version 1.1.0; openapi.json/schema.ts regenerated.

Note for upstreaming: this fixes upstream code (invoke-ai#9163) and is independent of H3 — it can be cherry-picked to a standalone upstream PR once the stack settles.

🤖 Generated with Claude Code

Second commit: Frame Range from Video had the same bug

Testing the extend-video workflow surfaced that extract_video_range also re-encoded video frames only — the trimmed source clip entered Concatenate Videos with no audio stream, so the concat correctly rendered its span as silence and the source's soundtrack never survived the trim.

The node now carries the matching slice of the source's audio through: the sample window for frames [start, end] is cut with the same fps → probed-duration → extracted-length fallback chain as the concat node, short audio tracks are zero-padded to keep alignment, an fps override retimes the audio with the video, and the result is muxed in with a video stream copy. Silent sources keep the old video-only output; audio failures fail loudly.

A fresh-context adversarial review attacked the temp-file lifecycle across all failure paths, the frame→sample math (single-frame ranges, end-of-clip ranges, negative indices, odd fps/rate ratios), the fallback chain's division guards, and the downstream concat contract (AAC padding on the trimmed output is trimmed off by concat's native-span slice — verified end-to-end empirically). No defects found.

7 new tests; node version 1.2.0; openapi.json/schema.ts regenerated.

The concat node decoded and re-encoded video frames only, so any input with an
audio track (MiniMax H3 clips carry AAC; Wan clips are silent) produced a
silent output. Extending an H3 video therefore always lost the soundtrack.

The node now rebuilds the soundtrack on the emitted timeline whenever at least
one input carries audio:

- audio decode via the bundled imageio-ffmpeg binary (new app/util/video_audio
  helpers: extract_audio_pcm, resample_linear, mux_audio_into_video);
- per-clip mapping onto the output timeline with a single linear resample,
  covering sample-rate unification (to the first audible clip's rate) and
  fps-override retiming (audio speed/pitch follows the video's retime);
- silent inputs contribute silence; all-silent inputs keep the old behavior
  (no audio stream);
- 'cut' splices sample-accurately against the per-clip frame counts actually
  decoded; 'crossfade' blends the boundary equal-power; 'fade_through_black'
  ramps out to silence and back in, mirroring the video, with the same
  asymmetric odd-tf split as the frame path;
- the finished track muxes into the already-encoded MP4 with a video stream
  copy (no re-encode).

Node version 1.1.0; openapi.json/schema.ts regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The extract_video_range node had the same defect just fixed in
video_concat: it re-encoded video frames only, so trimming a clip
silently stripped its soundtrack. In the extend-video workflow the
trimmed source clip feeds Concatenate Videos as clip 1, which then
correctly rendered its span as silence — the source's audio never
survived the trim.

The node now slices the matching span of the source's PCM (same
fps -> probed-duration -> extracted-length fallback chain as concat),
zero-pads short audio tracks to keep temporal alignment, retimes the
audio alongside the video under an fps override, and muxes it into the
trimmed MP4 with a video stream copy. Silent sources keep the old
video-only output. Audio failures fail the node loudly, matching the
concat node's policy.

Node version 1.2.0; openapi.json/schema.ts regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant