NOTE (2026-10-24, later): Parts 2-4 below were measured on the Koldflux tool as live BEFORE its update (frame-rate and audio normalization, commit 98b929eb7). Part 7 at the end re-runs 20 cases on the updated tool. Where they differ (mixed frame rates, audio format) Part 7 is the current behaviour. Merge video clips: test log, 2026-10-24 Tools: ffmpeg/ffprobe 6.1.1-3ubuntu5 on Ubuntu (native, for clips and checks); Playwright headless Chromium against https://koldflux.com/tools/video/merge (live, production, page HTTP 200). Test clips: 3 s each, made by us with ffmpeg lavfi (solid color, drawtext label, frame counter, moving box, sine tone, a different tone per clip). No third-party footage. ## Part 1: test clips (ffprobe) a_1080p30.mp4: h264|1920|1080|30/1 aac|48000|2|0/0 3.000000 b_720p30.mp4: h264|1280|720|30/1 aac|48000|2|0/0 3.000000 c_portrait.mp4: h264|1080|1920|30/1 aac|48000|2|0/0 3.000000 d_720p24.mp4: h264|1280|720|24/1 aac|48000|2|0/0 3.000000 d_720p24_as30.mp4: h264|1280|720|30/1 aac|48000|2|0/0 3.000000 e_1080p60.mp4: h264|1920|1080|60/1 aac|48000|2|0/0 3.000000 e_1080p60_as30.mp4: h264|1920|1080|30/1 aac|48000|2|0/0 3.000000 f_720p25.mp4: h264|1280|720|25/1 aac|48000|2|0/0 3.000000 g_hevc.mp4: hevc|1280|720|30/1 aac|48000|2|0/0 3.000000 h_vp9.webm: vp9|1280|720|30/1 opus|48000|2|0/0 3.008000 i_mpeg4.avi: mpeg4|1280|720|30/1 mp3|44100|2|0/0 3.033333 j_silent.mp4: h264|1280|720|30/1 3.000000 k_mono44.mp4: h264|1280|720|30/1 aac|44100|1|0/0 3.000000 l_rotated.mp4: h264|1920|1080|30/1|90 aac|48000|2|0/0 3.000000 (l_rotated.mp4 = a 1920x1080 clip with a display-matrix rotation of 90 degrees, made with: ffmpeg -display_rotation 90 -i l_raw.mp4 -c copy l_rotated.mp4) ## Part 2: live tool, one run per case. Each run: new browser context, load page, choose files, choose Output shape (0 = vertical 1080x1920 default, 1 = horizontal 1920x1080), click Merge, wait for Download merged MP4, save, ffprobe. Seconds = click to download link ready (after the engine had loaded in the page; includes first engine load, about 10 s). Run outputs (Playwright): 01-control-same-720p30 b + b, horizontal: done, 13.4 s 02-res-1080p-then-720p-horizontal a + b, horizontal: done, 12.4 s 03-res-720p-then-1080p-horizontal b + a, horizontal: done, 12.4 s 04-res-1080p-then-720p-default-vertical a + b, vertical (default): done, 12.4 s 05-portrait-then-landscape-vertical c + a, vertical: done, 11.9 s 06-portrait-then-landscape-horizontal c + a, horizontal: done, 11.9 s 07-fps-30-then-24 b + d, horizontal: NO RESULT. Progress stayed at "Merging... 0%". Waited 240 s (first run), then again 420 s (second run, 7 min): no download link, no error message. 08-fps-24-then-30 d + b: NO RESULT after 240 s 09-fps-60-then-30 e + a: NO RESULT after 75 s 10-fps-30-then-60 a + e: NO RESULT after 75 s 11-fps-25-then-30 f + b: NO RESULT after 75 s 12-codec-hevc-then-h264 g + b: done, 13.4 s 13-codec-vp9-webm-then-h264 h + b: done, 13.9 s 14-codec-mpeg4-avi-then-h264 i + b: done, 13.4 s 15-audio-silent-first j + b: done, 13.4 s 16-audio-silent-second b + j: done, 13.4 s 17-audio-mono44-then-stereo48 k + b: done, 13.4 s 18-phone-rotation-flag-then-landscape l + a, vertical: done, 11.9 s 19-three-clips-1080p30-720p30-720p24 a + b + d: NO RESULT after 75 s Debug poll of case 07 (every 5 s, 60 s): button text "Merging... 0%" at every poll, no console error, no page error. ## Part 3: ffprobe of each downloaded file (codec|width|height|r_frame_rate|duration|frames, then audio codec|rate|channels|...|duration) == 01-control-same-720p30.mp4 244155 bytes h264|1920|1080|30/1|6.000000|180 aac|48000|2|0/0|6.016000|282 == 02-res-1080p-then-720p-horizontal.mp4 220623 bytes h264|1920|1080|30/1|6.000000|180 aac|48000|2|0/0|6.016000|282 == 03-res-720p-then-1080p-horizontal.mp4 220347 bytes h264|1920|1080|30/1|6.000000|180 aac|48000|2|0/0|6.016000|282 == 04-res-1080p-then-720p-default-vertical.mp4 174820 bytes h264|1080|1920|30/1|6.000000|180 aac|48000|2|0/0|6.016000|282 == 05-portrait-then-landscape-vertical.mp4 194891 bytes h264|1080|1920|30/1|6.000000|180 aac|48000|2|0/0|6.016000|282 == 06-portrait-then-landscape-horizontal.mp4 198818 bytes h264|1920|1080|30/1|6.000000|180 aac|48000|2|0/0|6.016000|282 == 12-codec-hevc-then-h264.mp4 244452 bytes h264|1920|1080|30/1|6.000000|180 aac|48000|2|0/0|6.016000|282 == 13-codec-vp9-webm-then-h264.mp4 243203 bytes h264|1920|1080|30/1|6.000000|180 aac|48000|2|0/0|6.015000|283 == 14-codec-mpeg4-avi-then-h264.mp4 250864 bytes h264|1920|1080|30/1|6.033333|181 aac|44100|2|0/0|6.040998|261 == 15-audio-silent-first.mp4 142690 bytes h264|1920|1080|30/1|6.000000|180 == 16-audio-silent-second.mp4 142913 bytes h264|1920|1080|30/1|6.000000|180 == 17-audio-mono44-then-stereo48.mp4 207578 bytes h264|1920|1080|30/1|6.033333|181 aac|44100|1|0/0|6.026009|260 == 18-phone-rotation-flag-then-landscape.mp4 172712 bytes h264|1080|1920|30/1|6.000000|180 aac|48000|2|0/0|6.016000|282 ## Part 4: workaround, make every clip the same frame rate first, then use the tool ffmpeg -i d_720p24.mp4 -vf fps=30 -c:v libx264 -pix_fmt yuv420p -preset veryfast -c:a copy d_720p24_as30.mp4 (same for e_1080p60.mp4) 20-workaround-30-then-24-converted-to-30 b + d_as30, horizontal: done, 13.4 s 21-workaround-three-clips a + b + d_as30: done, 18.4 s 22-workaround-60-converted-to-30-then-30 e_as30 + a: done, 10.9 s == 20-workaround-30-then-24-converted-to-30.mp4 244776 bytes h264|1920|1080|30/1|6.000000|180 aac|48000|0/0|6.016000|282 == 21-workaround-three-clips.mp4 349251 bytes h264|1920|1080|30/1|9.000000|270 aac|48000|0/0|9.024000|423 == 22-workaround-60-converted-to-30-then-30.mp4 185677 bytes h264|1920|1080|30/1|6.000000|180 aac|48000|0/0|6.016000|282 ## Part 5: native ffmpeg command shown on the page (literal, run on b_720p30.mp4 + d_720p24.mp4, i.e. 30 fps + 24 fps) ffmpeg -nostdin -y -i clip1.mp4 -i clip2.mp4 -filter_complex "[0:v]scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[v0];[0:a]aresample=48000,aformat=channel_layouts=stereo[a0];[1:v]scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[v1];[1:a]aresample=48000,aformat=channel_layouts=stereo[a1];[v0][a0][v1][a1]concat=n=2:v=1:a=1[v][a]" -map "[v]" -map "[a]" -c:v libx264 -pix_fmt yuv420p -preset veryfast -c:a aac -b:a 160k merged.mp4 result: h264|1920|1080|30/1|6.000000|180 aac|48000|0/0|6.016000|282 Same recipe via a generator script on other pairs (output size 1920x1080, 30 fps): == n07-30-24.mp4 h264|1920|1080|30/1|6.000000|180 aac|48000|2|0/0|6.016000|282 == n09-60-30.mp4 h264|1920|1080|30/1|6.000000|180 aac|48000|2|0/0|6.016000|282 == n11-25-30.mp4 h264|1920|1080|30/1|6.000000|180 aac|48000|2|0/0|6.016000|282 == n14-avi-h264.mp4 h264|1920|1080|30/1|6.033333|181 aac|48000|2|0/0|6.041000|284 == n17-mono44-stereo48.mp4 h264|1920|1080|30/1|6.033333|181 aac|48000|2|0/0|6.026000|283 == n19-three.mp4 h264|1920|1080|30/1|9.000000|270 aac|48000|2|0/0|9.024000|423 (n07 = 30+24 fps, n09 = 60+30, n11 = 25+30, n14 = MPEG-4 AVI + H.264, n17 = mono 44.1 kHz + stereo 48 kHz, n19 = three clips 1080p30 + 720p30 + 720p24) ## Part 6: join without re-encoding (concat demuxer, -c copy), native printf "file b1.mp4\nfile b2.mp4\n" > same.txt ; ffmpeg -f concat -safe 0 -i same.txt -c copy same.mp4 (two copies of the same 720p30 clip; 0.08 s) == same.mp4 (list: file b1.mp4;file b2.mp4;) h264|1280|720|30/1|6.000000|180 aac|0/0|6.021333|284 == mixres.mp4 (list: file ../clips/a_1080p30.mp4;file ../clips/b_720p30.mp4;) h264|1920|1080|30/1|6.000000|180 aac|0/0|6.021333|284 == mixfps.mp4 (list: file ../clips/b_720p30.mp4;file ../clips/d_720p24.mp4;) h264|1280|720|30/1|4.800000|162 aac|0/0|6.021333|284 Decoded frame sizes in mixres.mp4 (ffprobe -show_entries frame=width,height): 89 1280,720 1 1280,720, 89 1920,1080 1 1920,1080, Decode check of mixfps.mp4 (ffmpeg -i mixfps.mp4 -f null -): [null @ 0x5e612b077300] Application provided invalid, non monotonically increasing dts to muxer in stream 0: 90 >= 88 Decode check of same.mp4: 0 error lines ## Part 7: re-run on the UPDATED tool (2026-10-24, after commit 98b929eb7), 20 cases Parts 2-4 above were measured on the tool as it was live before the update (image 12187db47). Part 6 (native ffmpeg) does not depend on the tool. What changed in the tool (commit 98b929eb7, from tool-fix-results.txt): every segment gets fps= (30 if unknown), audio is aresample=48000 plus stereo, and a 90 s no-progress watchdog was added. How: the worktree hubos/ia/20261019 at 98b929eb7 served with `next dev --webpack -p 3231` on the VPS (a local build, not production), Playwright headless Chromium at http://localhost:3231/tools/video/merge, one new browser context per case, 1440 px viewport, same procedure as Part 2 (choose files, choose Output shape, click Merge, wait for Download merged MP4, save, ffprobe). Time = click to download link ready, dev build, engine already cached by the browser per context. Clips: regenerated on 2026-10-24 with the same specs as Part 1 (not byte-identical to the originals): a/b/d/e come from the page's sample files (a_1080p30, b_720p30, d_720p24, e_720p60); c_portrait 1080x1920 30 fps; g_hevc; h_vp9.webm; i_mpeg4.avi (mp3 44.1 kHz); j_silent (no audio); k_mono44 (aac 44.1 kHz mono); f_720p25; l_rotated (1920x1080 with display-matrix rotation 90). Runner scripts: /tmp/h31/s/run.cjs and run2.cjs on the VPS (not shipped). Fields: video codec, width, height, r_frame_rate, nb_frames; audio codec, rate, channels, nb_frames; container duration. Not exercised: the 90 s no-progress watchdog (no run stalled; its message was not seen). Not tested: Safari, Firefox, phones, variable frame rate, files near 100 MB. ### 7a: audio and frame-rate cases 01-mono44-then-stereo48 | k_mono44.mp4 + b_720p30.mp4 | OK in 19.7s | stream|codec_name=h264|codec_type=video|width=1920|height=1080|r_frame_rate=30/1|nb_frames=181 ; stream|codec_name=aac|codec_type=audio|sample_rate=48000|channels=2|r_frame_rate=0/0|nb_frames=284 ; format|duration=6.033333 02-stereo48-then-mono44 | b_720p30.mp4 + k_mono44.mp4 | OK in 18.1s | stream|codec_name=h264|codec_type=video|width=1920|height=1080|r_frame_rate=30/1|nb_frames=180 ; stream|codec_name=aac|codec_type=audio|sample_rate=48000|channels=2|r_frame_rate=0/0|nb_frames=284 ; format|duration=6.026000 03-silent-then-sound | j_silent.mp4 + b_720p30.mp4 | OK in 18.1s | stream|codec_name=h264|codec_type=video|width=1920|height=1080|r_frame_rate=30/1|nb_frames=180 ; format|duration=6.000000 04-sound-then-silent | b_720p30.mp4 + j_silent.mp4 | OK in 18.1s | stream|codec_name=h264|codec_type=video|width=1920|height=1080|r_frame_rate=30/1|nb_frames=180 ; format|duration=6.000000 05-fps-24-then-30 | d_720p24.mp4 + b_720p30.mp4 | OK in 11.5s | stream|codec_name=h264|codec_type=video|width=1920|height=1080|r_frame_rate=24/1|nb_frames=144 ; stream|codec_name=aac|codec_type=audio|sample_rate=48000|channels=2|r_frame_rate=0/0|nb_frames=283 ; format|duration=6.016000 06-fps-30-then-24 | b_720p30.mp4 + d_720p24.mp4 | OK in 13.0s | stream|codec_name=h264|codec_type=video|width=1920|height=1080|r_frame_rate=30/1|nb_frames=180 ; stream|codec_name=aac|codec_type=audio|sample_rate=48000|channels=2|r_frame_rate=0/0|nb_frames=283 ; format|duration=6.016000 07-fps-60-then-30 | e_720p60.mp4 + b_720p30.mp4 | OK in 22.6s | stream|codec_name=h264|codec_type=video|width=1920|height=1080|r_frame_rate=60/1|nb_frames=360 ; stream|codec_name=aac|codec_type=audio|sample_rate=48000|channels=2|r_frame_rate=0/0|nb_frames=283 ; format|duration=6.016000 08-fps-30-then-60 | b_720p30.mp4 + e_720p60.mp4 | OK in 15.1s | stream|codec_name=h264|codec_type=video|width=1920|height=1080|r_frame_rate=30/1|nb_frames=180 ; stream|codec_name=aac|codec_type=audio|sample_rate=48000|channels=2|r_frame_rate=0/0|nb_frames=283 ; format|duration=6.016000 09-fps-25-then-30 | f_720p25.mp4 + b_720p30.mp4 | OK in 15.6s | stream|codec_name=h264|codec_type=video|width=1920|height=1080|r_frame_rate=25/1|nb_frames=150 ; stream|codec_name=aac|codec_type=audio|sample_rate=48000|channels=2|r_frame_rate=0/0|nb_frames=283 ; format|duration=6.016000 10-three-30-24-60 | b_720p30.mp4 + d_720p24.mp4 + e_720p60.mp4 | OK in 20.6s | stream|codec_name=h264|codec_type=video|width=1920|height=1080|r_frame_rate=30/1|nb_frames=270 ; stream|codec_name=aac|codec_type=audio|sample_rate=48000|channels=2|r_frame_rate=0/0|nb_frames=424 ; format|duration=9.024000 11-avi-44k-then-h264 | i_mpeg4.avi + b_720p30.mp4 | OK in 18.1s | stream|codec_name=h264|codec_type=video|width=1920|height=1080|r_frame_rate=30/1|nb_frames=181 ; stream|codec_name=aac|codec_type=audio|sample_rate=48000|channels=2|r_frame_rate=0/0|nb_frames=285 ; format|duration=6.041000 12-control-same | b_720p30.mp4 + b_720p30.mp4 | OK in 13.6s | stream|codec_name=h264|codec_type=video|width=1920|height=1080|r_frame_rate=30/1|nb_frames=180 ; stream|codec_name=aac|codec_type=audio|sample_rate=48000|channels=2|r_frame_rate=0/0|nb_frames=283 ; format|duration=6.016000 ### 7b: picture cases (resolution, aspect ratio, codec, rotation) 13-res-1080p-then-720p-horizontal | a_1080p30.mp4 + b_720p30.mp4 | OK in 13.6s | stream|codec_name=h264|codec_type=video|width=1920|height=1080|r_frame_rate=30/1|nb_frames=180 ; stream|codec_name=aac|codec_type=audio|sample_rate=48000|channels=2|r_frame_rate=0/0|nb_frames=283 ; format|duration=6.016000 14-res-720p-then-1080p-horizontal | b_720p30.mp4 + a_1080p30.mp4 | OK in 12.5s | stream|codec_name=h264|codec_type=video|width=1920|height=1080|r_frame_rate=30/1|nb_frames=180 ; stream|codec_name=aac|codec_type=audio|sample_rate=48000|channels=2|r_frame_rate=0/0|nb_frames=283 ; format|duration=6.016000 15-res-1080p-then-720p-default-vertical | a_1080p30.mp4 + b_720p30.mp4 | OK in 12.5s | stream|codec_name=h264|codec_type=video|width=1080|height=1920|r_frame_rate=30/1|nb_frames=180 ; stream|codec_name=aac|codec_type=audio|sample_rate=48000|channels=2|r_frame_rate=0/0|nb_frames=283 ; format|duration=6.016000 16-portrait-then-landscape-vertical | c_portrait.mp4 + a_1080p30.mp4 | OK in 16.1s | stream|codec_name=h264|codec_type=video|width=1080|height=1920|r_frame_rate=30/1|nb_frames=180 ; stream|codec_name=aac|codec_type=audio|sample_rate=48000|channels=2|r_frame_rate=0/0|nb_frames=283 ; format|duration=6.016000 17-portrait-then-landscape-horizontal | c_portrait.mp4 + a_1080p30.mp4 | OK in 13.6s | stream|codec_name=h264|codec_type=video|width=1920|height=1080|r_frame_rate=30/1|nb_frames=180 ; stream|codec_name=aac|codec_type=audio|sample_rate=48000|channels=2|r_frame_rate=0/0|nb_frames=283 ; format|duration=6.016000 18-hevc-then-h264 | g_hevc.mp4 + b_720p30.mp4 | OK in 18.1s | stream|codec_name=h264|codec_type=video|width=1920|height=1080|r_frame_rate=30/1|nb_frames=180 ; stream|codec_name=aac|codec_type=audio|sample_rate=48000|channels=2|r_frame_rate=0/0|nb_frames=283 ; format|duration=6.016000 19-vp9-webm-then-h264 | h_vp9.webm + b_720p30.mp4 | OK in 18.1s | stream|codec_name=h264|codec_type=video|width=1920|height=1080|r_frame_rate=30/1|nb_frames=180 ; stream|codec_name=aac|codec_type=audio|sample_rate=48000|channels=2|r_frame_rate=0/0|nb_frames=283 ; format|duration=6.015000 20-rotation-flag-then-landscape-vertical | l_rotated.mp4 + a_1080p30.mp4 | OK in 16.6s | stream|codec_name=h264|codec_type=video|width=1080|height=1920|r_frame_rate=30/1|nb_frames=180 ; stream|codec_name=aac|codec_type=audio|sample_rate=48000|channels=2|r_frame_rate=0/0|nb_frames=283 ; format|duration=6.016000 ### 7c: reading the results - All 20 runs finished. Frame rate: output r_frame_rate equals the first clip's rate in every run (24+30 -> 24 fps 144 frames; 30+24 -> 30 fps 180; 60+30 -> 60 fps 360; 30+60 -> 30 fps 180; 25+30 -> 25 fps 150; 30+24+60 -> 30 fps 270 frames). Before the update the same six cases never finished (cases 07-11 and 19 in Part 2). - Audio: every output with sound is AAC 48000 Hz 2 channels, including mono 44.1 kHz first (case 01) and the MPEG-4 AVI with 44.1 kHz mp3 (case 11). Before the update case 17 gave AAC 44.1 kHz mono and case 14 AAC 44.1 kHz stereo (Part 3). - Silent clip with a clip that has sound, either order (cases 03, 04): output has NO audio stream, unchanged from before. - Picture cases 13-20 match Part 3 sizes: 1920x1080 horizontal, 1080x1920 vertical, 30 fps, 180 frames; rotated clip joins as portrait. - Page claims about quality or exact bytes: result-a-then-b.mp4 on the page was produced before the update; its size (220,623 bytes) is not expected to match a new run.