How we made arbitrary MP4 files Wowza-compatible for SCTE-35 ad insertion.

A debugging journey through MP4 timestamps, MPEG-TS timebases, and the subtle difference between “works in any player” and “works in a Wowza ad-insertion pipeline.”

~2,400 words · 10 min readTech covered: OTT,HLS,Wowza,FFmpegStack: SRT → Wowza → HLS

The setup

We've been running a live SRT → Wowza pipeline that injects SCTE-35 markers into a movie stream. Wowza picks up the markers and substitutes a 10-second filler video at every ad break. The architecture works — markers fire, Wowza switches stream output to the ad file, and the player gets the ad followed by clean return to the main programme.

Until we tried to use a custom ad video.

The symptom

The main stream rendered cleanly across all HLS profiles. The source profile (streamWithAdInsertionx) played the ad cleanly too. But the 240p profile (streamWithAdInsertionx_240p) consistently produced visible artifacts during ad playback — block noise, freezes, occasional desyncs. Most strange of all: sometimes the same ad would play cleanly. Non-deterministic.

We had two reference files at this point:

  • Working.mp4 — known to work flawlessly in the same pipeline
  • ad_filler.mp4 — our newly-created file with artifacts

Both played fine in VLC. Both had the same resolution, codec, frame rate. But only one survived Wowza's transcoder.

The investigation

Step 01Capturing the actual HLS output

We needed to see what was actually going out from Wowza, not what we thought should be going out. We polled the live HLS playlist and downloaded segments for ~140 seconds, long enough to capture multiple ad breaks:

bash · capture-hls.sh
BASE="https://server.example.com/scte2/streamWithAdInsertionx_240p"
declare -A downloaded
START=$(date +%s)

while [ $(($(date +%s) - START)) -lt 140 ]; do
  curl -sL --max-time 5 "$BASE/chunklist.m3u8" -o chunklist.m3u8
  for seg in $(grep -v '^#' chunklist.m3u8); do
    if [ -z "${downloaded[$seg]}" ]; then
      curl -sL --max-time 10 "$BASE/$seg" -o "$seg"
      downloaded[$seg]=1
    fi
  done
  sleep 1.8
done

The polling matters. A single ffmpeg -i playlist.m3u8 capture would disconnect once it hit the end of the current chunklist; we needed to continuously refresh the playlist to follow the live edge.

Step 02Spotting the segment-duration anomaly

The playlist consistently advertised #EXTINF:2.4 for media_576.ts. But ffprobe told a different story for the same file: duration=6.421333.

A 2.7× mismatch. The HLS player downloads a chunk it thinks is 2.4 seconds long, gets 6.4 seconds of video, and has to figure out what to do — buffer it, skip frames, re-init the decoder, or present whatever it can in the budget the playlist gave it. Different players make different choices, and all of them look like artifacts.

The source profile didn't have this problem. Its segments matched their declared durations precisely.

Step 03Probing the codec parameters

Looking at the master playlist, the difference jumped out:

master.m3u8 · codec strings
# 240p (problem)
CODECS="avc1.42c01e"   # H.264 Constrained Baseline @ 3.0

# Source (works)
CODECS="avc1.640015"   # H.264 High @ 2.1

Wowza's 240p transcode template was set to Baseline, while our input was High profile with B-frames. Every time Wowza switched its source from the live stream to the ad file, the transcoder had to renegotiate codec parameters, strip B-frames, recompute the GOP boundaries — and on segment boundaries during ad-break transitions, that's where things went wrong.

Step 04The Working file comparison

This was the breakthrough. We diffed ffprobe -show_streams output between the two files. Everything else matched: same resolution, same codec, same profile, same frame rate, same yuv420p, no B-frames in either, single reference frame in both. But the timestamp metadata was completely different:

Working.mp4survives Wowza
codec_name=h264
profile=High
level=21
width=1920
height=1080
pix_fmt=yuv420p
r_frame_rate=25/1
has_b_frames=0
refs=1
time_base=1/90000
start_pts=0
start_time=0.000000
duration=10.000000
ad_filler.mp4artifacts on 240p
codec_name=h264
profile=High
level=21
width=1920
height=1080
pix_fmt=yuv420p
r_frame_rate=25/1
has_b_frames=0
refs=1
time_base=1/16000
start_pts=1890
start_time=0.021000
duration=10.000000

Three rows differ:

ParameterWorking.mp4ad_filler.mp4
video.time_base1/900001/16000
video.start_pts01890
video.start_time0.0000000.021000

1/90000 is the canonical timebase for MPEG-TS — every PTS in transport streams is expressed as ticks of a 90 kHz clock. When a file presents timestamps in 1/16000 (an FFmpeg quirk depending on the source), Wowza's transcoder has to convert. On the High-profile main stream, the conversion is fine. But when Wowza is transcoding to Baseline at 240p, with rate control kicking in and segment boundaries to honor, the timebase conversion introduces small rounding errors that compound into the segment-duration mismatch we observed.

The 21-millisecond start offset was a separate issue: FFmpeg's MP4 muxer inserts a CTS offset (an “edit list”) on the first frame to align video and audio. Harmless in normal playback. But Wowza, when it switches its output stream to a new source, doesn't honor the edit list during the switch. It starts emitting the file's raw PTS values, the first 21 ms have video PTS ahead of audio PTS, the player buffers, and the segment boundary slips.

Step 05How Working.mp4 was actually made

We learned from the team that Working.mp4 wasn't created with a special FFmpeg recipe. It had been recorded out of Wowza itself — someone streamed an arbitrary MP4 into the Wowza pipeline, captured the resulting HLS stream into a file, and used that file as the new ad asset. The round trip through Wowza's encoder normalized the timestamps to the MPEG-TS canonical 90 kHz timebase, removed the edit list, and started PTS at zero. Wowza accepted its own output back as a clean source.

Puzzle solved. To make any MP4 Wowza-compatible, we needed to simulate that round trip locally.

The solution

A two-pass FFmpeg pipeline. Encode to MPEG-TS first (this is how Wowza would carry the data internally), then remux back to MP4 with the correct video track timescale and a small -output_ts_offset to push start_pts down to zero.

Pass 1: MP4 → MPEG-TS

bash · pass-1.sh
INPUT="$HOME/Downloads/source-ad.mp4"

ffmpeg -y -i "$INPUT" \
  -f lavfi -i "anullsrc=cl=stereo:r=48000" \
  -map 0:v:0 -map 1:a:0 \
  -t 10 \
  -c:v libx264 -preset slow -profile:v high -level 2.1 \
  -b:v 400k -maxrate 400k -bufsize 400k \
  -g 25 -keyint_min 25 -sc_threshold 0 -bf 0 -refs 1 \
  -pix_fmt yuv420p \
  -vf "fps=25,setsar=1" \
  -c:a aac -b:a 64k -ar 48000 -ac 2 -profile:a aac_low \
  -fps_mode cfr -r 25 \
  -muxdelay 0 -muxpreload 0 \
  -avoid_negative_ts make_zero \
  -fflags +genpts \
  pure.ts

Constant frame rate, one keyframe per second, no B-frames, single reference frame. The anullsrc lavfi input generates silent stereo audio in case the source has no audio track. If the source already has audio, drop anullsrc and use -map 0:a:0 instead.

Pass 2: MPEG-TS → MP4 (the timestamp surgery)

bash · pass-2.sh
ffmpeg -y -i pure.ts \
  -map 0:v:0 -map 0:a:0 \
  -c copy \
  -bsf:a aac_adtstoasc \
  -video_track_timescale 90000 \
  -output_ts_offset -0.021 \
  -movflags "+faststart" \
  ad_filler.mp4

Three flags carry the work: -video_track_timescale 90000 sets the MP4 video track timebase to match MPEG-TS canonical. -output_ts_offset -0.021 shifts all timestamps backward by 21 ms so start_pts lands at exactly 0. -bsf:a aac_adtstoasc converts AAC from ADTS framing (TS) to raw AAC (MP4).

Verification

Run this against the output and tick each box off as you confirm it:

bash · verify.sh
ffprobe -v error -select_streams v \
  -show_entries stream=time_base,start_pts,start_time,duration \
  -of default=noprint_wrappers=1 \
  ad_filler.mp4

Takeaways

01

Identical-looking files can differ where it matters.

Both files played in VLC. Both opened in QuickTime. The differences only showed up in ffprobe -show_streams, three fields deep, in metadata most people never look at.

02

MPEG-TS timebases are not optional.

If your file is going through a transcoder that internally uses transport streams, give it timestamps in the canonical 90 kHz scale. Don't make the transcoder do unit conversions on the hot path.

03

Edit lists are invisible — until they aren't.

A 21 ms CTS offset is below human perception in normal playback. During a stream switch in an ad-insertion pipeline, that offset compounds with segment boundary calculations and moves the entire HLS chunking off-grid.

04

Compare playlist EXTINF against ffprobe duration.

When debugging HLS, comparing EXTINF declarations against ffprobe-measured segment durations is one of the most diagnostic things you can do. A mismatch means the player is being lied to.

05

When something works and something doesn't, line them up parameter by parameter.

We spent a lot of time looking at the bad file in isolation. The breakthrough came from diffing it against a working file from the same pipeline. Find the smallest difference — that's where the bug lives.

Full reproducible recipe

Copy-paste, run, drop on the Wowza server. If verification shows time_base=1/90000, start_pts=0, and start_time=0.000000, the file is ready for the next ad break.

bash · full-recipe.sh
INPUT="$HOME/Downloads/your-ad.mp4"
OUTPUT="$HOME/scte35-stream/ad_filler.mp4"

# Pass 1: Normalize through MPEG-TS
ffmpeg -y -i "$INPUT" \
  -f lavfi -i "anullsrc=cl=stereo:r=48000" \
  -map 0:v:0 -map 1:a:0 \
  -t 10 \
  -c:v libx264 -preset slow -profile:v high -level 2.1 \
  -b:v 400k -maxrate 400k -bufsize 400k \
  -g 25 -keyint_min 25 -sc_threshold 0 -bf 0 -refs 1 \
  -pix_fmt yuv420p \
  -vf "fps=25,setsar=1" \
  -c:a aac -b:a 64k -ar 48000 -ac 2 -profile:a aac_low \
  -fps_mode cfr -r 25 \
  -muxdelay 0 -muxpreload 0 \
  -avoid_negative_ts make_zero \
  -fflags +genpts \
  /tmp/pure.ts

# Pass 2: Remux to MP4 with corrected timestamps
ffmpeg -y -i /tmp/pure.ts \
  -map 0:v:0 -map 0:a:0 \
  -c copy \
  -bsf:a aac_adtstoasc \
  -video_track_timescale 90000 \
  -output_ts_offset -0.021 \
  -movflags "+faststart" \
  "$OUTPUT"

# Verify
ffprobe -v error -select_streams v \
  -show_entries stream=time_base,start_pts,start_time,duration \
  -of default=noprint_wrappers=1 \
  "$OUTPUT"

rm /tmp/pure.ts

Hitting something like this in your OTT pipeline?

Free 30-minute code audit. We've been shipping video-streaming and live-streaming systems since 2005 — Wowza, AntMedia, WebRTC, LiveKit, SRT, custom SFUs. Tell us the symptom; we'll tell you the layer.

Specialist software house for video, real-time and AI products. Founded 2005. 50 in-house engineers.

+1 (914) 775-5855
New York · USA
© Fora Soft, 2005–2026
Describe your project and we will get in touch
Enter your message
Enter your email
Enter your name

By submitting data in this form, you agree with the Personal Data Processing Policy.

Your message has been sent successfully
We will contact you soon
Message not sent. Please try again.