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.
★ 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.
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.
Three FFmpeg commands and one detector
9 min liveThe 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.
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:
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:
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.
One clock
8 min liveTwo 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.
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.
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.
Align a recording
★ 20 min · everyone buildsFive 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.
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 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.
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.
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.
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.
Before session 3
30 min- 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.
- 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.
- 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.
- 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.
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.