Last active
May 18, 2026 11:26
-
-
Save coderobe/6b52be04e6122756ee3f453824b812c2 to your computer and use it in GitHub Desktop.
Experiments on whether sparse FFmpeg HLS/fMP4 transcodes preserve enough information to be able to splice segments from separate runs together cleanly.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env ruby | |
| # frozen_string_literal: true | |
| # | |
| # ffmpeg_sparse_transcode_test.rb input_audio [--verbose] | |
| # | |
| # Check whether sparse FFmpeg HLS/fMP4 transcodes preserve enough timing | |
| # information to splice segments from separate runs together. | |
| # | |
| # Use --verbose for ffmpeg logging. | |
| # | |
| require "fileutils" | |
| require "json" | |
| require "open3" | |
| require "pathname" | |
| require "tempfile" | |
| VERBOSE = ARGV.delete("--verbose") | |
| INPUT = ARGV[0] | |
| abort("usage: ruby #{$PROGRAM_NAME} input_audio [--verbose]") unless INPUT | |
| abort("input file not found: #{INPUT}") unless File.exist?(INPUT) | |
| SEGMENT_DURATION = 6 | |
| SEAM_WINDOW_SECONDS = 0.05 | |
| BASELINE_WINDOW_SECONDS = 0.10 | |
| BASELINE_GAP_SECONDS = 0.025 | |
| TRANSIENT_BUCKETS_MS = [ | |
| [-10, 0], | |
| [0, 5], | |
| [5, 10], | |
| [10, 20], | |
| [20, 40] | |
| ].freeze | |
| OUTPUT_ROOT = Pathname.new("ffmpeg_sparse_test_output") | |
| FULL_DIR = OUTPUT_ROOT.join("full") | |
| VARIANTS_DIR = OUTPUT_ROOT.join("variants") | |
| FileUtils.rm_rf(OUTPUT_ROOT) | |
| FileUtils.mkdir_p(FULL_DIR) | |
| FileUtils.mkdir_p(VARIANTS_DIR) | |
| def monotonic | |
| Process.clock_gettime(Process::CLOCK_MONOTONIC) | |
| end | |
| def parse_playlist(path) | |
| segments = [] | |
| current_duration = nil | |
| File.readlines(path).each do |line| | |
| line = line.strip | |
| if line.start_with?("#EXTINF:") | |
| current_duration = | |
| line | |
| .sub("#EXTINF:", "") | |
| .sub(",", "") | |
| .to_f | |
| elsif !line.start_with?("#") && !line.empty? | |
| segments << { | |
| file: line, | |
| duration: current_duration | |
| } | |
| current_duration = nil | |
| end | |
| end | |
| segments | |
| end | |
| def ffprobe_json(path, entries: nil) | |
| cmd = ["ffprobe", "-v", "quiet", "-of", "json"] | |
| cmd += ["-show_entries", entries] if entries | |
| cmd << path.to_s | |
| stdout, stderr, status = Open3.capture3(*cmd) | |
| abort("ffprobe failed:\n#{stderr}") unless status.success? | |
| JSON.parse(stdout) | |
| end | |
| def monitor_ffmpeg(command, output_dir) | |
| puts(command.join(" ")) if VERBOSE | |
| first_write = nil | |
| known_files = {} | |
| start_time = monotonic | |
| _stdin, _stdout, stderr, wait_thr = Open3.popen3(*command.map(&:to_s)) | |
| monitor_thread = Thread.new do | |
| until wait_thr.join(0.05) | |
| Dir.glob("#{output_dir}/**/*").each do |path| | |
| next unless File.file?(path) | |
| next if known_files[path] | |
| known_files[path] = true | |
| first_write ||= monotonic | |
| end | |
| end | |
| end | |
| ffmpeg_log = +"" | |
| stderr.each_line do |line| | |
| ffmpeg_log << line | |
| puts line if VERBOSE | |
| end | |
| status = wait_thr.value | |
| monitor_thread.join | |
| finish_time = monotonic | |
| unless status.success? | |
| puts ffmpeg_log unless VERBOSE | |
| abort("ffmpeg failed") | |
| end | |
| { | |
| runtime: finish_time - start_time, | |
| first_write: first_write ? first_write - start_time : nil | |
| } | |
| end | |
| def build_ffmpeg_command(input:, output_dir:, seek_time: nil, copyts: false) | |
| playlist = output_dir.join("stream.m3u8") | |
| init_seg = output_dir.join("init.mp4") | |
| seg_pattern = output_dir.join("seg_%05d.m4s") | |
| cmd = [ | |
| "ffmpeg", | |
| "-y", | |
| "-fflags", "+bitexact", | |
| "-flags:a", "+bitexact", | |
| "-avoid_negative_ts", "disabled" | |
| ] | |
| cmd << "-copyts" if copyts | |
| if seek_time | |
| cmd += ["-ss", format("%.6f", seek_time)] | |
| end | |
| cmd += [ | |
| "-i", input.to_s, | |
| "-map", "0:a:0", | |
| "-c:a", "libopus", | |
| "-b:a", "192k", | |
| "-vbr", "on", | |
| "-compression_level", "10", | |
| "-application", "audio", | |
| "-f", "hls", | |
| "-hls_time", SEGMENT_DURATION.to_s, | |
| "-hls_playlist_type", "vod", | |
| "-hls_segment_type", "fmp4", | |
| "-hls_flags", "independent_segments", | |
| "-hls_fmp4_init_filename", init_seg.basename.to_s, | |
| "-hls_segment_filename", seg_pattern.to_s, | |
| "-movflags", "+frag_keyframe+empty_moov+default_base_moof", | |
| playlist.to_s | |
| ] | |
| cmd | |
| end | |
| def audio_sample_rate(path) | |
| json = ffprobe_json(path, entries: "stream=sample_rate") | |
| stream = json.fetch("streams").fetch(0) | |
| Integer(stream.fetch("sample_rate")) | |
| end | |
| def audio_stream_info(path) | |
| json = ffprobe_json(path, entries: "stream=sample_rate,channels") | |
| stream = json.fetch("streams").fetch(0) | |
| { | |
| sample_rate: Integer(stream.fetch("sample_rate")), | |
| channels: Integer(stream.fetch("channels")) | |
| } | |
| end | |
| def ffprobe_packets(path, playlist: false) | |
| cmd = ["ffprobe", "-v", "quiet"] | |
| if playlist | |
| cmd += [ | |
| "-allowed_extensions", "ALL", | |
| "-protocol_whitelist", "file,crypto,data" | |
| ] | |
| end | |
| cmd += [ | |
| "-select_streams", "a:0", | |
| "-show_packets", | |
| "-show_entries", "packet=pts_time,duration_time", | |
| "-of", "json", | |
| path.to_s | |
| ] | |
| stdout, stderr, status = Open3.capture3(*cmd) | |
| abort("ffprobe failed:\n#{stderr}") unless status.success? | |
| JSON.parse(stdout).fetch("packets", []) | |
| end | |
| def decode_pcm_samples(path, sample_rate:, channels:, playlist: false) | |
| cmd = ["ffmpeg", "-v", "error"] | |
| if playlist | |
| cmd += [ | |
| "-allowed_extensions", "ALL", | |
| "-protocol_whitelist", "file,crypto,data" | |
| ] | |
| end | |
| cmd += [ | |
| "-i", path.to_s, | |
| "-map", "0:a:0", | |
| "-f", "s16le", | |
| "-acodec", "pcm_s16le", | |
| "-ar", sample_rate.to_s, | |
| "-ac", channels.to_s, | |
| "-" | |
| ] | |
| stdout, stderr, status = Open3.capture3(*cmd) | |
| abort("ffmpeg decode failed:\n#{stderr}") unless status.success? | |
| stdout.unpack("s<*") | |
| end | |
| def decoded_audio_md5(init_path, segment_path) | |
| Tempfile.create(["ffmpeg_sparse_decode", ".mp4"]) do |file| | |
| file.binmode | |
| file.write(File.binread(init_path)) | |
| file.write(File.binread(segment_path)) | |
| file.flush | |
| stdout, stderr, status = Open3.capture3( | |
| "ffmpeg", | |
| "-v", "error", | |
| "-i", file.path, | |
| "-map", "0:a:0", | |
| "-f", "md5", | |
| "-" | |
| ) | |
| abort("ffmpeg decode failed:\n#{stderr}") unless status.success? | |
| stdout.strip.sub(/\AMD5=/, "") | |
| end | |
| end | |
| def rewrite_tfdt(segment_path, output_path, base_decode_time) | |
| data = File.binread(segment_path) | |
| bytes = data.bytes | |
| patched = false | |
| parse_boxes = lambda do |start_pos, end_pos, recurse| | |
| pos = start_pos | |
| while pos + 8 <= end_pos | |
| size = bytes[pos, 4].pack("C*").unpack1("N") | |
| type = bytes[pos + 4, 4].pack("C*") | |
| header_size = 8 | |
| if size == 1 | |
| size = bytes[pos + 8, 8].pack("C*").unpack1("Q>") | |
| header_size = 16 | |
| elsif size == 0 | |
| size = end_pos - pos | |
| end | |
| box_end = pos + size | |
| if type == "tfdt" | |
| version = bytes[pos + header_size] | |
| value_offset = pos + header_size + 4 | |
| replacement = | |
| if version == 1 | |
| [base_decode_time].pack("Q>").bytes | |
| else | |
| [base_decode_time].pack("N").bytes | |
| end | |
| replacement.each_with_index do |byte, index| | |
| bytes[value_offset + index] = byte | |
| end | |
| patched = true | |
| elsif %w[moof traf].include?(type) | |
| recurse.call(pos + header_size, box_end, recurse) | |
| end | |
| pos = box_end | |
| end | |
| end | |
| parse_boxes.call(0, bytes.length, parse_boxes) | |
| abort("tfdt box not found in #{segment_path}") unless patched | |
| output_path.binwrite(bytes.pack("C*")) | |
| end | |
| def region_diff_metrics(reference_samples, test_samples, channels:, start_frame:, frame_count:) | |
| start_index = start_frame * channels | |
| sample_count = frame_count * channels | |
| reference_slice = reference_samples.slice(start_index, sample_count) | |
| test_slice = test_samples.slice(start_index, sample_count) | |
| abort("sample window too short for seam analysis") unless reference_slice && test_slice | |
| abort("sample window size mismatch for seam analysis") unless reference_slice.length == test_slice.length | |
| sum_abs = 0.0 | |
| sum_sq = 0.0 | |
| max_abs = 0 | |
| reference_slice.zip(test_slice) do |ref_sample, test_sample| | |
| delta = (test_sample - ref_sample).abs | |
| sum_abs += delta | |
| sum_sq += delta * delta | |
| max_abs = delta if delta > max_abs | |
| end | |
| count = reference_slice.length | |
| { | |
| mean_abs: sum_abs / count, | |
| rms: Math.sqrt(sum_sq / count), | |
| max_abs: max_abs | |
| } | |
| end | |
| def seam_jump(samples, channels:, seam_frame:) | |
| previous_index = (seam_frame - 1) * channels | |
| current_index = seam_frame * channels | |
| channels.times.sum do |channel| | |
| (samples[current_index + channel] - samples[previous_index + channel]).abs | |
| end.fdiv(channels) | |
| end | |
| def analyze_seam(reference_playlist:, merged_playlist:, seam_frame:, sample_rate:, channels:) | |
| reference_samples = | |
| decode_pcm_samples( | |
| reference_playlist, | |
| sample_rate: sample_rate, | |
| channels: channels, | |
| playlist: true | |
| ) | |
| merged_samples = | |
| decode_pcm_samples( | |
| merged_playlist, | |
| sample_rate: sample_rate, | |
| channels: channels, | |
| playlist: true | |
| ) | |
| total_frames = [ | |
| reference_samples.length / channels, | |
| merged_samples.length / channels | |
| ].min | |
| reference_samples = reference_samples.first(total_frames * channels) | |
| merged_samples = merged_samples.first(total_frames * channels) | |
| seam_window_frames = (sample_rate * SEAM_WINDOW_SECONDS).round | |
| baseline_window_frames = (sample_rate * BASELINE_WINDOW_SECONDS).round | |
| baseline_gap_frames = (sample_rate * BASELINE_GAP_SECONDS).round | |
| seam_half_frames = seam_window_frames / 2 | |
| pre_start = seam_frame - baseline_gap_frames - baseline_window_frames | |
| seam_start = seam_frame - seam_half_frames | |
| post_start = seam_frame + baseline_gap_frames | |
| abort("not enough decoded audio for seam analysis") if pre_start.negative? | |
| abort("not enough decoded audio after seam for analysis") if post_start + baseline_window_frames > total_frames | |
| abort("seam window exceeds decoded audio") if seam_start.negative? || seam_start + seam_window_frames > total_frames | |
| pre_metrics = | |
| region_diff_metrics( | |
| reference_samples, | |
| merged_samples, | |
| channels: channels, | |
| start_frame: pre_start, | |
| frame_count: baseline_window_frames | |
| ) | |
| seam_metrics = | |
| region_diff_metrics( | |
| reference_samples, | |
| merged_samples, | |
| channels: channels, | |
| start_frame: seam_start, | |
| frame_count: seam_window_frames | |
| ) | |
| post_metrics = | |
| region_diff_metrics( | |
| reference_samples, | |
| merged_samples, | |
| channels: channels, | |
| start_frame: post_start, | |
| frame_count: baseline_window_frames | |
| ) | |
| baseline_rms = (pre_metrics[:rms] + post_metrics[:rms]) / 2.0 | |
| baseline_mean_abs = (pre_metrics[:mean_abs] + post_metrics[:mean_abs]) / 2.0 | |
| reference_jump = seam_jump(reference_samples, channels: channels, seam_frame: seam_frame) | |
| merged_jump = seam_jump(merged_samples, channels: channels, seam_frame: seam_frame) | |
| buckets = | |
| TRANSIENT_BUCKETS_MS.map do |from_ms, to_ms| | |
| start_frame = seam_frame + (sample_rate * from_ms / 1000.0).round | |
| end_frame = seam_frame + (sample_rate * to_ms / 1000.0).round | |
| bucket_frames = end_frame - start_frame | |
| next if start_frame.negative? || end_frame > total_frames || bucket_frames <= 0 | |
| [ | |
| "#{from_ms}..#{to_ms}ms", | |
| region_diff_metrics( | |
| reference_samples, | |
| merged_samples, | |
| channels: channels, | |
| start_frame: start_frame, | |
| frame_count: bucket_frames | |
| ) | |
| ] | |
| end.compact.to_h | |
| early_transient_rms = ((buckets["0..5ms"] || {})[:rms].to_f + (buckets["5..10ms"] || {})[:rms].to_f) / 2.0 | |
| late_transient_rms = (buckets["20..40ms"] || {})[:rms].to_f | |
| { | |
| pre: pre_metrics, | |
| seam: seam_metrics, | |
| post: post_metrics, | |
| buckets: buckets, | |
| baseline_rms: baseline_rms, | |
| baseline_mean_abs: baseline_mean_abs, | |
| seam_rms_ratio: baseline_rms.zero? ? 1.0 : seam_metrics[:rms] / baseline_rms, | |
| seam_mean_abs_ratio: baseline_mean_abs.zero? ? 1.0 : seam_metrics[:mean_abs] / baseline_mean_abs, | |
| reference_jump: reference_jump, | |
| merged_jump: merged_jump, | |
| jump_ratio: reference_jump.zero? ? 1.0 : merged_jump / reference_jump, | |
| early_transient_rms_ratio: baseline_rms.zero? ? 1.0 : early_transient_rms / baseline_rms, | |
| transient_decay_ratio: late_transient_rms.zero? ? 1.0 : early_transient_rms / late_transient_rms | |
| } | |
| end | |
| def classify_seam(seam_analysis) | |
| if seam_analysis[:seam_rms_ratio] <= 1.25 && | |
| seam_analysis[:seam_mean_abs_ratio] <= 1.25 && | |
| seam_analysis[:jump_ratio] <= 1.5 | |
| "no seam-specific artifact detected" | |
| elsif seam_analysis[:jump_ratio] >= 2.0 | |
| "hard discontinuity / click-like jump at the seam" | |
| elsif seam_analysis[:early_transient_rms_ratio] >= 2.0 && | |
| seam_analysis[:transient_decay_ratio] >= 1.5 && | |
| seam_analysis[:jump_ratio] < 1.5 | |
| "short seam-local transient (likely encoder-state mismatch), not a single-sample click" | |
| else | |
| "seam-region mismatch detected" | |
| end | |
| end | |
| def mode_target_segment_index(mode, suffix) | |
| mode.fetch(:"#{suffix}_selected_segment_index", 0) | |
| end | |
| def mode_preroll_segments(mode, suffix) | |
| mode.fetch(:"#{suffix}_preroll_segments", 0) | |
| end | |
| def fragment_packet_stats(init_path, segment_path, tmp_path) | |
| File.binwrite( | |
| tmp_path, | |
| File.binread(init_path) + File.binread(segment_path) | |
| ) | |
| packets = ffprobe_packets(tmp_path) | |
| packet_stats(packets) | |
| end | |
| def packet_stats(packets) | |
| abort("no packets found") if packets.empty? | |
| first_pts = packets.first.fetch("pts_time").to_f | |
| last_pts = packets.last.fetch("pts_time").to_f | |
| last_duration = packets.last.fetch("duration_time").to_f | |
| deltas = | |
| packets.each_cons(2).map do |left, right| | |
| right.fetch("pts_time").to_f - | |
| (left.fetch("pts_time").to_f + left.fetch("duration_time").to_f) | |
| end | |
| { | |
| first_pts: first_pts, | |
| end_pts: last_pts + last_duration, | |
| packet_count: packets.length, | |
| min_delta: deltas.min || 0.0, | |
| max_delta: deltas.max || 0.0 | |
| } | |
| end | |
| def write_merge_playlist(path, init_path:, segment_paths:) | |
| lines = [ | |
| "#EXTM3U", | |
| "#EXT-X-VERSION:7", | |
| "#EXT-X-TARGETDURATION:#{SEGMENT_DURATION}", | |
| "#EXT-X-PLAYLIST-TYPE:VOD", | |
| "#EXT-X-MAP:URI=\"#{init_path.expand_path}\"" | |
| ] | |
| segment_paths.each do |segment_path| | |
| lines << "#EXTINF:#{format('%.6f', SEGMENT_DURATION.to_f)}," | |
| lines << segment_path.expand_path.to_s | |
| end | |
| lines << "#EXT-X-ENDLIST" | |
| path.write(lines.join("\n") + "\n") | |
| end | |
| def reference_offset_for(segments, index) | |
| segments | |
| .take(index) | |
| .sum { |segment| segment[:duration] } | |
| end | |
| reference_metrics = monitor_ffmpeg( | |
| build_ffmpeg_command( | |
| input: INPUT, | |
| output_dir: FULL_DIR | |
| ), | |
| FULL_DIR | |
| ) | |
| reference_segments = parse_playlist(FULL_DIR.join("stream.m3u8")) | |
| abort("not enough segments generated") if reference_segments.size < 4 | |
| first_index = reference_segments.size / 2 | |
| second_index = first_index + 1 | |
| abort("not enough trailing segments for merge test") if second_index >= reference_segments.size | |
| reference_a_start = reference_offset_for(reference_segments, first_index) | |
| reference_b_start = reference_offset_for(reference_segments, second_index) | |
| full_tmp_a = OUTPUT_ROOT.join("full_segment_a.mp4") | |
| full_tmp_b = OUTPUT_ROOT.join("full_segment_b.mp4") | |
| reference_a_stats = | |
| fragment_packet_stats( | |
| FULL_DIR.join("init.mp4"), | |
| FULL_DIR.join(reference_segments[first_index][:file]), | |
| full_tmp_a | |
| ) | |
| reference_b_stats = | |
| fragment_packet_stats( | |
| FULL_DIR.join("init.mp4"), | |
| FULL_DIR.join(reference_segments[second_index][:file]), | |
| full_tmp_b | |
| ) | |
| sample_rate = audio_sample_rate(FULL_DIR.join("init.mp4")) | |
| stream_info = audio_stream_info(FULL_DIR.join("init.mp4")) | |
| abort("not enough leading segments for preroll test") if first_index < 1 | |
| modes = [ | |
| { key: "rebased", label: "rebased", copyts: false }, | |
| { key: "copyts", label: "copyts", copyts: true }, | |
| { key: "copyts_patched", label: "copyts+tfdt", copyts: true, patch_tfdt: true }, | |
| { | |
| key: "preroll_discard", | |
| label: "preroll+discard", | |
| copyts: true, | |
| patch_tfdt: true, | |
| a_preroll_segments: 1, | |
| b_preroll_segments: 1, | |
| a_selected_segment_index: 1, | |
| b_selected_segment_index: 1 | |
| } | |
| ] | |
| results = | |
| modes.map do |mode| | |
| segment_results = [] | |
| [ | |
| ["a", first_index, reference_a_start], | |
| ["b", second_index, reference_b_start] | |
| ].each do |suffix, segment_index, reference_start| | |
| preroll_segments = mode_preroll_segments(mode, suffix) | |
| selected_segment_index = mode_target_segment_index(mode, suffix) | |
| sparse_start_index = segment_index - preroll_segments | |
| output_dir = VARIANTS_DIR.join("#{mode[:key]}_#{suffix}") | |
| FileUtils.mkdir_p(output_dir) | |
| seek_time = reference_offset_for(reference_segments, sparse_start_index) | |
| metrics = monitor_ffmpeg( | |
| build_ffmpeg_command( | |
| input: INPUT, | |
| output_dir: output_dir, | |
| seek_time: seek_time, | |
| copyts: mode[:copyts] | |
| ), | |
| output_dir | |
| ) | |
| sparse_segments = parse_playlist(output_dir.join("stream.m3u8")) | |
| selected_sparse_segment = sparse_segments.fetch(selected_segment_index) | |
| tmp_path = OUTPUT_ROOT.join("#{mode[:key]}_#{suffix}.mp4") | |
| packet_summary = | |
| fragment_packet_stats( | |
| output_dir.join("init.mp4"), | |
| output_dir.join(selected_sparse_segment[:file]), | |
| tmp_path | |
| ) | |
| segment_results << { | |
| suffix: suffix, | |
| output_dir: output_dir, | |
| metrics: metrics, | |
| stats: packet_summary, | |
| reference_start: reference_start, | |
| sparse_segments: sparse_segments, | |
| selected_segment_index: selected_segment_index, | |
| selected_segment_path: output_dir.join(selected_sparse_segment[:file]), | |
| reference_segment_index: segment_index | |
| } | |
| end | |
| merge_playlist_path = OUTPUT_ROOT.join("#{mode[:key]}_merge.m3u8") | |
| first_segment_path = segment_results[0][:selected_segment_path] | |
| second_segment_path = segment_results[1][:selected_segment_path] | |
| if mode[:patch_tfdt] | |
| patched_first_segment_path = OUTPUT_ROOT.join("#{mode[:key]}_seg_a.m4s") | |
| rewrite_tfdt(first_segment_path, patched_first_segment_path, 0) | |
| first_segment_path = patched_first_segment_path | |
| patched_segment_path = OUTPUT_ROOT.join("#{mode[:key]}_seg_b.m4s") | |
| tfdt_offset = | |
| ((reference_b_stats[:first_pts] - reference_a_stats[:first_pts]) * sample_rate).round | |
| rewrite_tfdt(second_segment_path, patched_segment_path, tfdt_offset) | |
| second_segment_path = patched_segment_path | |
| end | |
| write_merge_playlist( | |
| merge_playlist_path, | |
| init_path: segment_results[0][:output_dir].join("init.mp4"), | |
| segment_paths: [ | |
| first_segment_path, | |
| second_segment_path | |
| ] | |
| ) | |
| merged_packets = ffprobe_packets(merge_playlist_path, playlist: true) | |
| { | |
| label: mode[:label], | |
| copyts: mode[:copyts], | |
| a: segment_results[0], | |
| b: segment_results[1], | |
| merged: packet_stats(merged_packets), | |
| merge_playlist_path: merge_playlist_path | |
| } | |
| end | |
| reference_merge_playlist_path = OUTPUT_ROOT.join("reference_merge.m3u8") | |
| write_merge_playlist( | |
| reference_merge_playlist_path, | |
| init_path: FULL_DIR.join("init.mp4"), | |
| segment_paths: [ | |
| FULL_DIR.join(reference_segments[first_index][:file]), | |
| FULL_DIR.join(reference_segments[second_index][:file]) | |
| ] | |
| ) | |
| patched = results.find { |result| result[:label] == "copyts+tfdt" } | |
| preroll = results.find { |result| result[:label] == "preroll+discard" } | |
| seam_frames = ((reference_a_stats[:end_pts] - reference_a_stats[:first_pts]) * sample_rate).round | |
| [patched, preroll].compact.each do |result| | |
| result[:seam_analysis] = | |
| analyze_seam( | |
| reference_playlist: reference_merge_playlist_path, | |
| merged_playlist: result[:merge_playlist_path], | |
| seam_frame: seam_frames, | |
| sample_rate: stream_info[:sample_rate], | |
| channels: stream_info[:channels] | |
| ) | |
| end | |
| if preroll | |
| warmup_next_run = preroll[:b] | |
| warmup_init = warmup_next_run[:output_dir].join("init.mp4") | |
| warmup_first_segment = warmup_next_run[:output_dir].join( | |
| warmup_next_run[:sparse_segments].fetch(0)[:file] | |
| ) | |
| warmup_second_segment = warmup_next_run[:selected_segment_path] | |
| preroll[:warmup_segment_checks] = { | |
| first_segment_matches_reference: | |
| decoded_audio_md5( | |
| warmup_init, | |
| warmup_first_segment | |
| ) == decoded_audio_md5( | |
| FULL_DIR.join("init.mp4"), | |
| FULL_DIR.join(reference_segments[first_index][:file]) | |
| ), | |
| second_segment_matches_reference: | |
| decoded_audio_md5( | |
| warmup_init, | |
| warmup_second_segment | |
| ) == decoded_audio_md5( | |
| FULL_DIR.join("init.mp4"), | |
| FULL_DIR.join(reference_segments[second_index][:file]) | |
| ) | |
| } | |
| end | |
| puts "Boundary under test: seg #{first_index} -> seg #{second_index}" | |
| puts( | |
| "Reference timeline: " \ | |
| "#{format('%.3f', reference_a_stats[:first_pts])}-#{format('%.3f', reference_a_stats[:end_pts])}s, " \ | |
| "#{format('%.3f', reference_b_stats[:first_pts])}-#{format('%.3f', reference_b_stats[:end_pts])}s" | |
| ) | |
| puts "Reference runtime: #{format('%.3fs', reference_metrics[:runtime])}" | |
| puts | |
| puts "Timing continuity" | |
| puts "-----------------" | |
| puts [ | |
| "Mode".ljust(16), | |
| "Seg A".ljust(21), | |
| "Seg B".ljust(21), | |
| "A->B".rjust(9), | |
| "Merged".rjust(9) | |
| ].join(" ") | |
| puts "-" * 79 | |
| results.each do |result| | |
| a_stats = result[:a][:stats] | |
| b_stats = result[:b][:stats] | |
| a_window = "#{format('%.3f', a_stats[:first_pts])}->#{format('%.3f', a_stats[:end_pts])}" | |
| b_window = "#{format('%.3f', b_stats[:first_pts])}->#{format('%.3f', b_stats[:end_pts])}" | |
| standalone_delta = b_stats[:first_pts] - a_stats[:end_pts] | |
| row = [ | |
| result[:label].ljust(16), | |
| a_window.ljust(21), | |
| b_window.ljust(21), | |
| format("%+.3fs", standalone_delta).rjust(9), | |
| format("%+.3fs", result[:merged][:min_delta]).rjust(9) | |
| ] | |
| puts row.join(" ") | |
| end | |
| puts | |
| rebased = results.find { |result| result[:label] == "rebased" } | |
| copyts = results.find { |result| result[:label] == "copyts" } | |
| if preroll && preroll[:merged][:min_delta] > -0.001 && | |
| preroll[:seam_analysis] && | |
| classify_seam(preroll[:seam_analysis]) == "no seam-specific artifact detected" | |
| puts "Conclusion: sparse merging is reliable with a 1-segment preroll, discarding the first encoded segment, and rewriting tfdt onto the shared timeline." | |
| elsif patched && patched[:merged][:min_delta] > -0.001 | |
| puts "Conclusion: sparse merging is timing-correct with tfdt rewriting, but seam artifacts remain unless the encoder has enough warm-up." | |
| elsif copyts | |
| puts( | |
| "Conclusion: -copyts fixes per-segment timing, but merged HLS/fMP4 output still resets at the stitch point " \ | |
| "(merged delta #{format('%+.3fs', copyts[:merged][:min_delta])})." | |
| ) | |
| else | |
| puts "Conclusion: sparse segment timing did not stay mergeable." | |
| end | |
| puts | |
| puts "Seam artifact check" | |
| puts "-------------------" | |
| puts [ | |
| "Mode".ljust(16), | |
| "Seam RMS".rjust(10), | |
| "Post RMS".rjust(10), | |
| "Jump".rjust(8), | |
| "Classification" | |
| ].join(" ") | |
| puts "-" * 88 | |
| [patched, preroll].compact.each do |result| | |
| seam_analysis = result[:seam_analysis] | |
| puts [ | |
| result[:label].ljust(16), | |
| format("%9.2f", seam_analysis[:seam][:rms]), | |
| format("%9.2f", seam_analysis[:post][:rms]), | |
| format("%7.3fx", seam_analysis[:jump_ratio]), | |
| classify_seam(seam_analysis) | |
| ].join(" ") | |
| end | |
| if preroll && preroll[:warmup_segment_checks] | |
| puts | |
| puts "Warm-up segment check" | |
| puts "---------------------" | |
| puts( | |
| "First segment from the warmed sparse run matches reference current segment: " \ | |
| "#{preroll[:warmup_segment_checks][:first_segment_matches_reference] ? 'yes' : 'no'}" | |
| ) | |
| puts( | |
| "Second segment from the warmed sparse run matches reference next segment: " \ | |
| "#{preroll[:warmup_segment_checks][:second_segment_matches_reference] ? 'yes' : 'no'}" | |
| ) | |
| end | |
| puts "Artifacts: #{OUTPUT_ROOT}" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment