Decision
The recorder owns time. It installs Playwright’s clock before the page loads, lets the page load, become ready and paint, waits until every animation the page set playing has started, and then holds the page clock still. For each framek of the recording it moves the page’s timers and
animation frames on by one frame, calls
window.__decktalkProbe.step(k / fps, wait), and captures what the page shows over CDP as one
lossless image once the step answers. Every image goes down one pipe into one lossless encode at video.fps.
step(t) hands the second to the runtime’s DeckTalk.stepTo(t). That runs every cue and task due by
t in the order it was due, calls every frame hook with t, and holds every CSS animation, CSS
transition and Web Animation at the second since its birth. An animation is born at the second its
cue was due, not at the frame that ran it, so a reveal is in the same place at the same second
whatever rate the film is recorded at. Every animation is also held at a task’s own second just
before the task runs, so a transition a cue interrupts turns from where it stands at that cue.
step answers only once the page has drawn that second. It waits on the browser’s own animation
frame, which Playwright’s clock does not hold because the probe takes it before the clock is
installed, until a whole rendering update has laid out, painted and committed the step, and then
for every font the page shows and every image on it, rendering again after each arrives. A capture
taken the moment the runtime returned would race the browser’s own rendering, so what a frame
showed would depend on how busy the machine was. A page that
has not drawn a step within record.page_deadline_seconds, the deadline every call into a page has,
stops the recording at that frame, so a frame is never captured half drawn. A page that cannot even
say so, because it spins its main thread, is closed from outside at the same deadline.
Frame k of the recording is therefore the page at section second k / fps by construction. A
person’s preview in their own browser still plays in real time, and only the recorder steps.
Still frames
Most frames of a narrated slide are the frame before: in the shipped projects about nine in ten. Sostep also answers whether anything the page draws may have changed since the step before, and the
recorder writes the frame before into the encode again, without a capture, for a frame where no step
since the last capture said so. The frame written again is the frame a capture would have taken, so
the recording is the same frames either way and only takes less time.
A step says the picture changed when any of these holds:
- The runtime ran a task, such as a cue, a reveal or a mount.
- A frame hook drew.
- An animation’s progress moved, or an animation started or left, which the timeline reads from every animation it holds.
- A declared motion is between its two cues, or a declared loop is on the page.
- The DOM changed or something scrolled, which a
MutationObserverthe probe starts before the page loads sees, so a plainsetTimeoutthat sets a style is seen too. The probe observes every shadow root a script attaches or reaches the same way, open or closed. - A script wrote a style sheet through its object model, such as
insertRule,replaceSyncor a rule’sstyle, or the document or a shadow root adopted other sheets. The probe wraps every writer of the object model before the page loads, and compares each adopted list at every step. - The step waited for a font or an image.
- The page shows a
<canvas>, a<video>, an<iframe>, an<embed>or an<object>, which draw where the observer cannot see, so such a page is captured on every frame. One the page hides, makes transparent, gives no size or places outside the viewport draws no pixel and turns nothing off, andrecordlogs a debug line naming the element that does. - The page has no runtime.
record reports
PAGE_CHANGE_UNDECLARED with the element, the first
second and the number of frames. A change no pixel shows is not reported, and neither is one beside a cue that drew
the frame’s only difference somewhere else.
An animated image plays on the browser’s own time rather than the section clock, so which of its
frames a recording shows depends on how long the recording takes, with the skip or without it.
The test suite’s builds step to and capture every fifth skipped frame anyway and stop the section if
one differs from the frame before, and a golden builds every packaged project with the skip on and
off and requires the same decoded frames and the same verify numbers.
Still stretches
A still frame written again still cost a step, and a step waits for a whole rendering update, which the browser paces at the display’s rate when nothing was captured. Sostep also answers
stillUntil, the earliest second anything the page draws can next change, and the recorder writes
every frame before that second again without stepping the page to each. It steps the page once
across the stretch, to the stretch’s last frame, which moves the page’s timers and its own seconds
to where the frames are, and then to the frame the change lands on, as before. A stretch is
crossed in one step however long it is.
The runtime answers the earliest of:
- the second the next task is due
- the second an animation waiting out its delay starts to move
- the first cue of a declared motion still to depart
setTimeout lands on its own frame however long the stretch it sits
in. It answers the step’s own second when the page holds something that draws on its own, has no
runtime, or heard of a font, an image or a DOM change while the step was being drawn, because that
change came from an event the runtime never ran and may have started something it has not seen.
It answers its own second, too, when Playwright’s clock cannot be read, because nothing the clock
promises names the next timer.
The recorder leaves nothing of its own on the page’s clock: a wait it races inside the page clears
its timer once it is answered, and the first step cancels the frame the runtime’s own loop was
waiting on.
The frames written again go into the encode from a thread of its own. The pipe keeps them as runs,
each a frame and how many times in a row it shows, so a repeat costs the stepper a count and the
encode reads every frame of a stretch while the stepper goes on to the next step.
Frame k depends on k alone, never on which frames were drawn before it, so the recorder can
reach it by stepping only the seconds a task is due before it. Three things hold that:
- A task sees every animation at its own second, as above, rather than at whichever second the page showed last.
- Nothing the runtime draws changes on an animation’s end event, because the browser sends that event from a frame it draws on its own wall clock. An entrance comes off its element, and an outgoing slide leaves, at a second owed to the section clock.
- Every Chromium is started with
--disable-partial-raster, so a tile the compositor draws again is drawn whole. A partial redraw paints only the changed rectangle over the tile’s old pixels, and the anti-aliased edges it draws there differ from a whole tile’s, so a frame would carry what the frames before it painted.
--disable-partial-raster, with --disable-frame-rate-limit
and --disable-gpu-vsync. Without them Chromium holds its frames to the display’s refresh rate, so
the rendering update a step waits for and the capture after it each wait for a refresh. With them
neither is held to that rate, and a frame takes about half the time. The switches change speed only
and never pixels, and that is the equivalence they are held to: a recording made with them decodes
to exactly the frames of a recording made without them, every pixel of every frame.
tests/e2e/test_frame_addressing.py records every page section of every packaged project again in
pieces in a Chromium launched without them, and fails on one pixel that differs from the build’s own
recording.
Why
A real-time screencast starts at a moment nobody can predict, and the delay differs by platform by hundreds of milliseconds. It stamps each frame by when the compositor swapped it rather than by the second the page meant to show, so a busy frame and an idle one land on the grid differently, steady motion judders, and two runs of the same page differ. Cutting such a recording against a section clock would need a mark in the frames to find the start by, motion to keep frames coming while a still page waits, a retry for a section whose frames stall, and a measured bias to correct the stamps. A stepped clock needs none of it. Frame 0 is second 0, so there is no start to find. The recorder asks for every frame, so no frame can be late and nothing can stall. Each frame is the exact state of the page at its second, so two recordings of one page decode to the same frames on one machine. Continuous motion becomes recordable too: a frame hook that draws a canvas, a typed line or a count from the second it is handed draws the same picture in a recorded frame as in a preview frame for the same second. The encode is lossless because a recording is an intermediate thatassemble encodes once more at
the film’s own quality, and a lossy pass here would put the encoder’s ringing into every frame
verify compares.
What it costs
- A frame that changed costs a step, a capture and an encode, and a still stretch costs two steps and an encode of each of its frames, which runs beside the steps. A heavy page records slower than real time rather than wrongly, and a starved machine takes longer rather than dropping a frame.
- A Worker keeps its own clock, and a
<video>or an<audio>element plays at its own rate. The stepped clock holds neither, so a deck that needs footage uses a clip section or a render section, and a page that holds a video element isPAGE_REALTIME_MEDIA. - Anything the page moves for longer than a moment is declared as a motion, a loop or a frame hook’s region, because verify masks only what it was told moves. Things that move shows each.
What it rules out
- A page cannot run on the wall clock while it is recorded. Every timer, every animation frame and
every animation it starts moves only when the recorder steps, so a page that reads
Date.now()reads the stepped time. An animation’s or a transition’s end event is the exception, because the browser sends it from a frame it draws when it likes, so a page that changes what it shows on one records a frame that depends on the frames before it. - A recording is never searched for its start and never corrected for a machine’s presentation
timing. A page that stops answering is stopped at
record.page_deadline_secondswith aTOOLerror rather than retried, and only a chunk whose renderer crashed is recorded again in a fresh page,record.renderer_retriestimes, which gives the same frames because a frame is a function of its index. A recording log with no checks is a recording that never finished, and the next run records it again. - The rate a section is stepped at is
video.fps, so every page range is written in frames of the slowest rate a film may use,SLOWEST_FPS, which is 25.