LIVE Commerce Analytics / Builder session 2 of 8
Learn LIVE Commerce Analytics with Phoebe · Builder session 2 of 8

Ingest and align

Session 1 decided the grain. Tonight the recording actually gets there. Three FFmpeg commands cut a two-hour MP4 into minute chunks, pull one frame a second for the detectors, and strip the audio to the 16 kHz mono WAV the transcribers want; PySceneDetect adds a list of camera cuts. Then the part nobody budgets for: the stream reached its viewers seconds after the host spoke, so the seller's ledger runs on a later clock than the video. You measure that delay from the data itself and apply it as one shift, because every content-to-sales number in the rest of the track is wrong by exactly that much if you do not.

🟡 Core Data scientists and analytics engineers Anyone who has joined two timestamps and regretted it 45 min
0-3 · Welcome 3-20 · Segments, frames, audio, one clock 20-40 · Build-along: align a recording 40-45 · Q&A
Part 0

Where we are in the builder track

Session 1 built the 7,200-row minute table and reconciled its funnel to the cent. Tonight is the ingest layer under it: how a real recording becomes the frames session 3 reads and the audio session 4 transcribes, and how the ledger's clock is brought into line with the recording's clock before any of those columns are joined. It is the least glamorous session in the track and the one whose mistakes are hardest to see later, because a misaligned ledger does not throw an error. It just makes the wrong minute look good.

Live = presented in the 45 minutes Self-study = the full depth, after class 🔍 Every diagram zooms

★ What you walk out with tonight. A repeatable ingest script that turns a 2-hour MP4 into 120 minute chunks, 7,200 frames at 1 fps and one 16 kHz mono WAV, with a scene-cut count per minute as the first content column. And a measured stream delay, found by scanning lags between what the host said and what the ledger recorded, applied as one shift so that "minute 43" means the same thing in every column of the table.

LiveWhy the recording and the ledger disagree by seconds3 min▶

A LIVE is not live. The host speaks, the phone encodes, the platform ingests, transcodes and distributes, and the viewer's player buffers. The words reach the room some seconds after they left the host's mouth. The viewer then taps the card and buys, and the platform logs the purchase on its own clock: the time it received the order.

So a purchase logged at 20:43:07 is an answer to words spoken earlier, and the seller's per-minute ledger is a record of what the room did, shifted late by the delivery delay. Your recording of the host is on the earlier clock. Join the two raw and you are pairing what the host said at minute 43 with what viewers did about minute 42. If the delay straddles a minute boundary, and with a delay of several seconds it often does, a chunk of every minute's sales is credited to the following minute.

Real world

Lumen Glow's first join found that filler minutes converted best. The pattern was real in the table and false in the room: a CTA at the end of minute 43 produced orders that the ledger logged in minute 44, and minute 44 was the host catching her breath. Once the delay was measured and the ledger shifted, the CTA minutes took the credit back.

Self-studyThe vocabulary tonight uses4 min▶
  • Keyframe - a frame the codec stored whole, not as a difference from its neighbours. A stream-copied file can only be cut cleanly at one.
  • Segment - one output chunk of the recording, here one minute of video, so that every downstream job has a natural unit of work that matches the table's grain.
  • fps sampling - keeping one frame per chosen interval. 1 fps gives 60 frames a minute, enough for a share-of-frames column and cheap enough to run a detector on.
  • PCM 16 kHz mono - uncompressed audio, 16,000 samples a second, one channel. The input format Whisper-family transcribers were trained on.
  • Offset - seconds from the start of the recording, the recording's own clock. Every source is converted to it.
  • Lag - a shift of one series against another by a number of steps. Scanning lags is how the delay is found.
  • Event time vs recording time - when something happened for the viewer versus when the recording says it happened. The ledger is on event time; the video is on recording time; the delay d is the gap.
Part 1 · covers the FFmpeg segment muxer, the fps filter, the audio strip and PySceneDetect

Three FFmpeg commands and one detector

9 min live

The whole ingest layer is three FFmpeg invocations and one Python call, and each one produces output at a different grain. Knowing the grain of each output is what lets you aggregate it to the minute later without a second thought.

One recording, four outputs, four grains stream.mp4 2 hours 30 fps video stereo audio Segment muxer -f segment -segment_time 60 -reset_timestamps 1 cuts only at keyframes Frames at 1 fps -vf fps=1 frames/%06d.jpg 7,200 JPEGs for 2 h what the detectors read Audio to WAV -vn -ac 1 -ar 16000 -c:a pcm_s16le 16 kHz mono PCM what Whisper expects PySceneDetect 0.7.1 AdaptiveDetector() rolling average over content scores: fewer false cuts under motion grain: minute grain: second grain: sample, 16,000/s grain: cut chunks/min-043.mp4 unit of work = row t 60 frames per minute share-of-frames columns, b3 words with start, end segment and wpm columns, b4 count(cuts) per minute scene_cuts column, tonight Stream copy cuts at keyframes only. Without forced keyframes the "60 s" chunks drift and chunk 43 is not minute 43.
🔍 Click to zoom - three FFmpeg outputs and one detector output, each with its grain and where it lands in the minute table.
LiveMinute chunks with the segment muxer, and the keyframe catch3 min▶

FFmpeg 9.0.2 ships a segment muxer that splits one input into many output files. The documented example is a ten-second split with a list file:

★ The doc example, verbatim (ffmpeg-formats, segment muxer) ffmpeg -i in.mkv -c copy -map 0 -f segment -segment_list out.list -segment_time 10 'out-%03d.mkv'

Change -segment_time 10 to 60 and the file pattern to chunks/min-%03d.mp4 and you have minute chunks. Add -reset_timestamps 1 so every chunk starts at zero, which is what you want when a per-chunk job reports "second 17" and means second 17 of that minute.

The catch is -c copy. Stream copy does not decode the video, so it can only split where the encoder left a keyframe, and a live-stream recording has keyframes wherever the encoder decided, typically every few seconds and not on the minute. The muxer waits for the next keyframe after the 60 second mark, so each chunk runs a little long and the drift accumulates: chunk 43 no longer starts at minute 43. Two documented fixes. Re-encode once with -force_key_frames "expr:gte(t,n_forced*60)", which places a keyframe on every minute boundary, then stream-copy the segments from that file. Or accept -break_non_keyframes 1, which cuts exactly on time and leaves the first frames of each chunk undecodable until the next keyframe. For analysis frames the first is the one to use, because the detectors in session 3 need every frame of the minute.

LiveFrames at 1 fps and audio at 16 kHz mono3 min▶

Two more commands, both one line. The fps filter resamples the frame rate, and pointed at an image-sequence output it writes one JPEG per second: ffmpeg -i stream.mp4 -vf fps=1 frames/%06d.jpg. Frame number n is second n of the recording, which makes the frame-to-minute mapping a floor division and nothing more.

The audio strip drops the video, downmixes to one channel, resamples to 16,000 Hz and writes 16-bit PCM: ffmpeg -i stream.mp4 -vn -ac 1 -ar 16000 -c:a pcm_s16le audio.wav. Why exactly that: the Whisper-family models session 4 uses were trained on 16 kHz mono and resample anything else to it internally, so handing them that format skips a conversion, removes a source of tiny timestamp drift, and makes the file a fifth of the size of the stereo 48 kHz original. It is also what the hosted transcription APIs bill on, so the smaller file is the cheaper call.

Seek before you read. Put -ss before -i to jump to an offset with a fast input seek, and -t to limit duration. Testing the pipeline on minutes 40 to 45 of a recording takes seconds instead of the twenty minutes a full pass takes.

LivePySceneDetect and the first content column3 min▶

Scene cuts are the cheapest thing a camera tells you. A cut is a hard change between one frame and the next: the host switched from the front camera to the product camera, the operator cut to the price card, the phone was picked up. PySceneDetect 0.7.1, BSD-3 licensed, finds them in one call:

★ The PyPI quick start from scenedetect import detect, AdaptiveDetector scene_list = detect('stream.mp4', AdaptiveDetector())

Why AdaptiveDetector and not the plain ContentDetector: ContentDetector compares each frame with the last in HSV space and calls a cut whenever the difference crosses a fixed threshold. A handheld live-commerce camera crosses that threshold constantly, every time the host waves the serum or the phone tilts. AdaptiveDetector runs the same scores and then compares each one with a rolling average of its neighbours, so a sustained wobble is not a cut and a real switch is. Fewer false cuts under camera motion is exactly the property this footage needs.

The output is a list of scenes with start and end timecodes. A cut is where one scene ends; floor its seconds by 60 and count per minute, and scene_cuts becomes a column. In the Lumen Glow table it already is one, and the AI-avatar streams sit at 1 to 3 cuts a minute against 2 to 7 for the human hosts, which session 3 comes back to.

Self-studyCost: what 1 fps buys and what it costs4 min▶

A two-hour stream is 7,200 frames at 1 fps, and the whole 60-stream corpus is 432,000. Every detector in session 3 runs once per frame, so this number is the multiplier on every per-frame cost you take on later. Some rules of thumb for the sampling budget:

  • 1 fps is enough for share-of-frames columns. Product in hand, host on camera and price on screen change on a scale of seconds, not frames. Sixty samples a minute give a share with a resolution of about 1.7 points, well below anything the analysis in session 6 can distinguish.
  • Scene cuts need more. A cut is a single frame-to-frame event, so PySceneDetect reads the full-rate video, not the 1 fps sample. Run it on the MP4, not on the JPEGs.
  • Sample down, never up. If a detector is slow, run it on every other frame and mark the minute's usable-frame count. Session 3 nulls any minute with fewer than 30 usable frames rather than reporting a share built on a handful.
  • Keep the chunks. When a detector improves, you re-run on the minute chunks that changed, not on the two-hour file. That is what the segment muxer bought you.
Part 2 · covers the stream delay, how to measure it from the data, and what skipping it does

One clock

8 min live

Two of your sources are on the recording's clock: the frames and the words, because they were cut from the same file. The comment feed and the seller ledger are on the platform's clock, and the platform saw everything a delay d later than the host did. The fix is one shift by d. The work is finding d.

Two clocks, one delay, one shift Host's words recording clock minute 42 minute 43 minute 44 demo price CTA filler Ledger orders platform clock minute 42 minute 43 minute 44 sale lands in the wrong minute shift back by d Finding d from the data: scan the lags, take the peak corr(cta_minutes.shift(lag), orders) for lag in 0..4; the lag with the highest correlation is the delay in minutes lag 0 lag 1 lag 2 lag 3 lag 4 peak = d corr Then verify against one known event A flash-sale start has a timestamp in LIVE Manager and a moment in the recording when the host announces it. Their gap should be d. If it is not, the scan found a coincidence. On tonight's course data the scan returns lag 0: the table is already aligned. Run it on your own recording to find yours.
🔍 Click to zoom - the ledger runs d seconds behind the host; the lag scan finds d, one shift removes it.
LiveMeasuring d from the data itself4 min▶

You rarely get told the delay. The platform does not publish it, it varies by region and by the viewer's connection, and nobody on the production side is measuring it. But it is sitting in your data, because the host's words cause the room's actions and causes precede effects. Whatever the host does that reliably produces a response (a call to action is the strongest one in this data, at +6.50 points of CTOR within a stream) will produce that response in the ledger after the delay, not before.

So build two per-minute series on the recording clock: a count of CTA minutes from the transcript, and the ledger's orders. Shift the CTA series forward by 0, 1, 2, 3 and 4 minutes and correlate each with orders. The lag with the highest correlation is the delay, rounded to the grain. Most stream delays are a handful of seconds, so the answer is usually 0 or 1, and the value of the scan is not the number but the certainty: you know which one, and you can show your working.

Then verify against one event you know the time of on both clocks. A LIVE Flash Sale has a start timestamp in LIVE Manager and a moment in the recording when the host says "the flash sale is live". The gap between them is the delay in seconds, independently of the correlation scan. If the two methods agree, apply the shift. If they do not, the scan found a coincidence and the known event wins.

The delay is stable within a stream and not across them. Encoders, regions and platform load change between nights. Measure d per stream, store it in the streams table next to the start time, and never assume last week's value.

LiveWhat happens if you skip it4 min▶

Nothing errors. The join succeeds, the row counts match, the totals reconcile to the cent because a shift moves values between minutes without changing their sum. And every content-to-sales number in sessions 5 and 6 is wrong in the same direction.

  • Effects attenuate toward zero. If part of each CTA minute's orders is credited to the following minute, the CTA minute looks weaker than it was and the estimate of its effect shrinks. The within-stream +6.50 points of CTOR for a CTA is what you see when the clocks agree. Misalign them and it comes out smaller, and the confidence interval widens because the signal is smeared over two minutes.
  • Credit moves to the following segment. The minute after a CTA is often filler or a breath, and it now carries the CTA's orders. So a CTA looks worthless and the filler after it looks golden. In the aligned Lumen Glow data filler runs at 1.40 orders per thousand viewer-minutes against 11.57 for CTA; an unshifted join flattens that gap and can invert it for short CTAs.
  • Short things suffer most. A twenty-minute demo loses a few seconds off its end and nobody notices. A forty-second call to action can lose most of its orders to the next row. The segments the leader track cares about most are precisely the short, sharp ones.
  • The forecast learns the wrong lead. Session 7's nowcast reads the last few minutes of content to predict the next five of orders. Trained on a misaligned table it learns that filler predicts orders, and cues the host accordingly.
Real world

Lumen Glow's first content-to-sales join reported "filler minutes convert best" and a coaching deck was drafted around letting the room breathe. One analyst asked why a CTA would sell worse than silence, scanned the lags, found a one-minute shift, and the deck was rewritten before it reached the hosts. The table had reconciled perfectly the whole time.

Self-studyEvent time, recording time, and the general version of this problem4 min▶

What you did tonight is a special case of something every streaming system has to handle: the time an event happened and the time it was observed are different, and the difference is not constant across sources. Stream processors solve it with event-time windows and watermarks, a running estimate of how late an event can still arrive before the window closes. The streaming data course builds that machinery properly, with late arrivals, out-of-order events and the accuracy-versus-latency trade the watermark encodes.

Here the problem is simpler for one reason: the delay is stable within a stream. Every ledger row in a two-hour recording is late by roughly the same d, so one measured shift solves what a watermark would otherwise track continuously. The moment that stops being true, for instance if you ingest a running stream in session 7 where the delay can drift as the room grows, you are back in the general case and the watermark is the right tool. Knowing which case you are in is most of the skill.

One more thing to write down: which clock each column is on, in the table's documentation. Frames and words are on the recording clock by construction. The ledger and the comment feed are on the platform clock and were shifted by d. A column added later by someone who did not attend tonight will be on whatever clock its source used, and the note is what stops it being joined raw.

Build-along

Align a recording

★ 20 min · everyone builds

Five steps. The first two run on a real recording if you brought one from the homework, and on any two-hour MP4 if you did not. The last three run on the Lumen Glow minute table, where the lag scan returns 0 because the table is already aligned; that is the point, you see the method give the right answer on a known case before you trust it on your own recording.

Three FFmpeg commands. Force keyframes on the minute once, then segment by stream copy, pull 1 fps frames, and strip the audio. The whole ingest is this block; put it in a script and version it.

★ ingest.sh - the whole ingest layer # 1. keyframes on every minute boundary, so stream-copy segments cut exactly on time ffmpeg -i stream.mp4 -force_key_frames "expr:gte(t,n_forced*60)" -c:a copy stream_kf.mp4 # 2. minute chunks, each starting at t=0, with a csv list of what went where ffmpeg -i stream_kf.mp4 -c copy -map 0 -f segment -segment_time 60 -reset_timestamps 1 \ -segment_list chunks.csv -segment_list_type csv 'chunks/min-%03d.mp4' # 3. one frame a second for the detectors; frame n is second n ffmpeg -i stream.mp4 -vf fps=1 frames/%06d.jpg # 4. 16 kHz mono 16-bit PCM for the transcriber ffmpeg -i stream.mp4 -vn -ac 1 -ar 16000 -c:a pcm_s16le audio.wav

Scene cuts per minute. PySceneDetect on the full-rate MP4, not the 1 fps frames. A cut is where a scene ends; floor by 60 and count.

★ The first content column import pandas as pd from scenedetect import detect, AdaptiveDetector scenes = detect("stream.mp4", AdaptiveDetector()) # list of (start, end) timecodes cut_s = [end.get_seconds() for _, end in scenes[:-1]] # a cut is where one scene ends cuts = pd.Series([int(s // 60) + 1 for s in cut_s], name="t") # minute t is [t-1, t), from 1 scene_cuts = (cuts.value_counts().reindex(range(1, 121), fill_value=0) .sort_index().rename("scene_cuts")) scene_cuts.describe()

The lag scan. CTA minutes from the segment column against the ledger's orders, at lags 0 to 4. On the Lumen Glow table pick one stream, Maya's stream 17, and expect the peak at 0.

★ Find d m = pd.read_csv("data/streams.csv") one = m[m.stream_id == 17].set_index("t").sort_index() # Maya, Wed 21:00, giveaway open cta = (one.segment == "cta").astype(int) # 1 in a CTA minute, else 0 scan = {lag: cta.shift(lag).corr(one.orders) for lag in range(0, 5)} d = max(scan, key=scan.get) print({k: round(v, 3) for k, v in scan.items()}, "peak at lag", d) assert d == 0, "the Lumen Glow table is already on one clock" # your own recording may say 1

Apply the shift and re-run the session 1 checks. The ledger's row at t+d belongs to the words at t. A shift moves values, it never creates or destroys them, so the sum over the minutes you kept must equal the sum over the minutes they came from.

★ One shift, then the assertions ledger = ["arrivals", "room", "impressions", "clicks", "orders", "gmv", "comments", "likes", "replies"] counts = ["impressions", "clicks", "orders", "gmv"] aligned = one.copy() aligned[ledger] = one[ledger].shift(-d) # ledger at t+d answers words at t kept = aligned.dropna(subset=counts) for col in counts: # totals unchanged by a shift assert kept[col].sum() == one[col].iloc[d:].sum(), col assert kept.orders.sum() == one.orders.sum() if d == 0 else True print(len(kept), "minutes kept; only the minute assignment moved")

Save the aligned table. Derived, next to the raw files, with d recorded in the file so the next reader knows which clock the ledger columns are on.

★ Derived, never raw kept = kept.assign(delay_min=d) kept.reset_index().to_csv("data/derived/minute_aligned.csv", index=False)

Store d, not the shifted file, as the source of truth. The raw ledger export stays untouched with its platform timestamps. The delay lives in the streams table as one number per stream, and the aligned minute table is rebuilt from raw plus d whenever either changes. If someone later measures the delay better, the correction is one cell, not a re-export.

Homework

Before session 3

30 min
  1. Run ingest.sh on a two-hour recording you are allowed to use. Check that chunk 043 starts at exactly 42:00 with and without the forced-keyframe pass, and write down the drift you saw without it.
  2. Run PySceneDetect on the same recording with ContentDetector and with AdaptiveDetector. Count cuts per minute under each and find the minute where they disagree most. Watch that minute and decide which detector was right.
  3. If you have a ledger export for the recording, run the lag scan and find d. Then find one flash-sale or coupon start in LIVE Manager and confirm d against it. Bring both numbers.
  4. Pick any Lumen Glow stream and shift its ledger columns by one minute on purpose. Recompute orders per thousand viewer-minutes by segment and compare with the aligned numbers. Write one sentence about which segment gained and which lost.
Check yourself

Three questions

You segment a live-stream recording with -c copy -f segment -segment_time 60 and chunk 043 starts at 42:04, not 42:00. Why?

Without decoding, FFmpeg cannot start a file mid-group-of-pictures. Force a keyframe on every minute with -force_key_frames "expr:gte(t,n_forced*60)" first, or accept break_non_keyframes and lose the first frames of each chunk.

The ledger is joined to the transcript without measuring the stream delay. What does that do to the estimated effect of a call to action?

The ledger runs late, so a CTA minute's orders land in the next row. The totals still reconcile, which is exactly why nothing warns you. The within-stream +6.50 points of CTOR for a CTA is what the aligned table shows; an unshifted join reports less and gives the remainder to the minute after.

Why strip the audio to 16 kHz mono PCM rather than hand the transcriber the original 48 kHz stereo track?

The model's input is 16 kHz mono either way. Converting up front with -vn -ac 1 -ar 16000 -c:a pcm_s16le is deterministic, cheaper to upload to a hosted API that bills on audio minutes, and keeps word timestamps on the recording clock.

Sources covered

What tonight maps to

✓ FFmpeg 9.0.2: segment muxer, fps filter, audio resample and downmix The documented segment example adapted to 60 s with reset_timestamps, the keyframe caveat and both fixes, 1 fps frames, 16 kHz mono PCM.
✓ PySceneDetect 0.7.1 AdaptiveDetector Quick-start call, why the rolling average suits handheld footage, and cuts per minute as the first content column.
✓ The clock shift and its estimation Recording clock vs platform clock, the lag scan over CTA minutes and orders, verification against a known event, and what an unshifted join does to every later effect.
◐ Watermarks and event-time windows Named as the general form of tonight's problem. Built properly in the streaming data course; here one measured shift suffices because the delay is stable within a stream.
◐ Detectors on the frames Tonight produces the 1 fps frames and the usable-frame rule. Session 3 runs the landmark and open-vocabulary detectors on them and aggregates to the minute.
· Live ingestion of a running stream Not covered by design. The course works from recordings; ingesting a stream while it runs brings back the drifting-delay case that watermarks exist for.

Builder session 2 cheat sheet

Minute chunks-c copy -f segment -segment_time 60 -reset_timestamps 1. Force keyframes on the minute first or the chunks drift.
Keyframe fix-force_key_frames "expr:gte(t,n_forced*60)" once, then stream-copy segments. break_non_keyframes is the lossy alternative.
Frames-vf fps=1 frames/%06d.jpg. Frame n is second n; minute is n // 60 + 1. 7,200 frames for two hours.
Audio-vn -ac 1 -ar 16000 -c:a pcm_s16le. What Whisper-family models expect; smaller, deterministic, drift-free.
Scene cutsdetect(mp4, AdaptiveDetector()) on the full-rate video. Rolling average beats a fixed threshold under camera motion.
Two clocksFrames and words are on the recording clock. Ledger and comments are on the platform clock, late by d.
Find dcorr(cta.shift(lag), orders) for lag 0..4; take the peak. Verify against a flash-sale start in LIVE Manager.
Skip it andeffects shrink toward zero and credit moves to the next segment. Totals still reconcile, so nothing warns you.