Skip to content
quickthumb
All documentation

Animation timing reference

For a concise guide, see Animation timing.

Use seconds for authored motion, slide durations, holds, video placement and caption cues. sample() observes the canonical timeline; export() reports the visual timeline actually encoded. Those measurements serve different purposes.

Choose the clock

Output or observation Timing contract
sample() with no time or fps One authored-static frame per Canvas/page, matching raster output without seeking an animation endpoint; duration=0, frame time=None.
sample(time=...) Observe explicit document times. Slide windows are [start, end); an exact boundary belongs to the incoming slide. Times beyond the end show the settled final scene.
sample(fps=...) Observe a uniform grid 0, 1/fps, 2/fps, … before the timeline end. A zero-length timeline is still observed once at zero.
GIF Silent, variable frame durations in 10 ms steps; identical frames can coalesce. Default sampling rate 20 fps; supported rate is greater than zero and at most 100 fps.
MP4 / WebM / MOV Constant-rate encoded frames, including repeated held frames. Default 30 fps; supported rate is greater than zero and at most 120 fps.
HTML Browser-driven, interactive cues and slide navigation. No encoded fps or fixed total playback duration, and no Deck narration playback.

GifOptions.fps and VideoOptions.fps request the sampling density. Moving spans are sampled within their own timing boundaries; static spans become held shots. Video allocates repeated frames on a cumulative frame clock, while GIF quantizes cumulative durations to centiseconds. A canonical uniform sample grid is therefore not an index into the encoded file: arbitrary fractional spans, palette/lossy encoding and duplicate-frame coalescing can change frame positions or counts.

Read ExportResult.timing_metrics after an animated export:

Field Meaning
fps The selected sampling rate; for GIF this is not stored frames divided by playback duration.
frame_count Frames actually encoded, after GIF coalescing or video hold repetition.
duration Encoded visual playback seconds: summed GIF delays or video frame count divided by fps. Audio container padding can differ.

FrameSequence.duration instead describes the unquantized canonical timeline. HTML returns no fixed playback timing in timing_metrics; its zero duration is not a claim that its interactive animations take no time.

from quickthumb import AnimationSpec, Canvas, GifOptions

canvas = Canvas(160, 90).background(color="#000000").shape(
    "rectangle", (40, 20), 80, 50, "#FF0000",
    animation=AnimationSpec.fade(duration=1),
)
samples = canvas.sample(fps=7, hold=0.37)
result = canvas.export("timed.gif", animation=GifOptions(fps=7, hold=0.37))
print(samples.duration, len(samples.frames))
print(result.timing_metrics.model_dump())

Temporal exposure

Opt into centered motion blur when exporting a Canvas or Deck:

from quickthumb import MotionBlur, VideoOptions

canvas.export(
    "exposure.mp4",
    animation=VideoOptions(fps=30, motion_blur=MotionBlur(samples=8, shutter_angle=180)),
)

GifOptions and ImageSequenceOptions accept the same setting. None is the only off setting; MotionBlur accepts 2–64 samples and a positive shutter angle up to 360 degrees. The shutter spans (angle / 360) / fps seconds around each rendered frame. Instants outside the document timeline use its boundary state. Hard cuts bound the exposure to the slide on the center's side of the cut; an exact cut time belongs to the incoming slide. Smooth transitions participate in the exposure, including samples on either side of their start time. Video and image-sequence exposure centers follow the output grid index / fps; GIF centers follow its variable-duration shot clock. The encoded-sRGB premultiplied channels are averaged before returning straight RGBA, preserving saturated colors in transparent trails.

Exposure also integrates timed captions and appearances. They can contribute within half a shutter window of a cue boundary. Authored cue times and playback length remain unchanged; GIF delays and video frame counts still follow their codec clocks. With zero hold, the last frame is an exposure of motion. Add a hold to show an entirely settled frame.

This setting supports Canvas and Deck GIF/video exports and image sequences, including workers. Each worker computes a complete exposure with the same sample times and alpha accumulation as serial rendering. Direct byte helpers do not support this setting. sample() and render_frame() remain instantaneous canonical observations. Export receipts identify the requested temporal exposure in their capability report; source inspection cannot infer an export-only setting.

Support and receipts

capabilities_for(target)["motion_blur"] declares temporal-exposure support:

Target family Support Meaning
video full Canvas/Deck MP4, WebM, MOV and image sequences integrate the exposure.
raster partial, static fallback Animated GIF integrates it; static raster files have no exposure window.
html, pptx unsupported, static fallback Exposure is ignored; the authored browser/PowerPoint motion remains unchanged.

VideoOptions/GifOptions belong to their animated output families; passing them to HTML/PPTX raises an option-family error. The capability table does not make those options silently accepted. inspect_motion() reports source-authored features and does not claim that export-only blur was enabled. After an actual export, find the motion_blur row in result.capability_report for the applied sample count and angle; timing_metrics retains the encoded playback facts. Image-sequence results carry the same applied row and their frame/duration facts.

Run the moving-marker example to write matching OFF/8-sample GIFs. Its scene also drives the controlled sample-cost benchmark.

Slide duration and transitions

Layer motion and the incoming transition start on the same slide clock. For noninteractive media, on_click cues run in sequence like after_previous; with_previous cues overlap. Delays are part of those clocks. Transitions play over the previous slide's settled scene; a slide without an explicit transition starts instantly on the first slide and uses a 0.5-second crossfade thereafter. Use Cut() for an immediate boundary.

A slide's exit is determined by the latest applicable boundary:

  • With neither a duration override nor advance_after, transition and layer motion finish, then the settled scene holds for hold seconds (default 3).
  • For Deck video, slide(duration=...) sets a total slide window, including its transition and motion. Without an explicit duration, narration supplies that window. Neither trims a longer visual animation; the slide lasts long enough for both. Narration itself is trimmed or padded to its scheduled window.
  • A transition's advance_after replaces hold: its boundary is counted after the incoming transition. The exit is the latest of that boundary, completed motion and any narration/explicit duration. For example, a one-second animation with Cut(advance_after=0.1) ends at one second even when hold=3; it does not add a three-second tail.

GIF ignores Deck narration and slide(duration=...); it uses motion, transitions, advance_after and hold. Deck.sample() uses the narration-aware video schedule, so a narrated Deck's GIF need not match its sampled duration.

The encoder shows the settled state even with hold=0, replacing the final motion shot when needed rather than always appending a full hold. A static or sub-frame slide still needs a representable frame. GIF and video durations can therefore differ slightly from the mathematical timeline. Deck video currently requires a positive hold for slides with neither narration nor an explicit positive duration; a zero hold remains valid for Canvas and Deck GIF.

Audio, captions and reduced motion

MP4/WebM/MOV mix supported clip audio, Deck narration and an optional soundtrack. Narration starts at its slide's absolute offset. The soundtrack is trimmed to the visual timeline and can loop; audio padding from the codec does not extend the reported visual duration. GIF and image sequences carry no audio. Use video when the soundtrack or narration must accompany the motion. MP4 exports play layer animations and transitions while retaining per-slide narration. For still-slide narration, author static canvases and set deck.transition(Cut()).

A video layer's start is slide-local. Its trim_start, trim_end and speed select source media time; caption start/end are relative to the placed clip, not to the original source file. Captions are active over [start, end) and are burned into the foreground of supported raster/video output. They remain timed when reduced motion freezes the clip's picture.

ExportPolicy(reduced_motion=True) suppresses authored layer motion and authored slide transitions. It retains holds, narration and caption timing; default Deck crossfades can remain between slides. Use the actual export receipt for the resulting duration, rather than the original motion duration.

HTML keeps on_click cues interactive and after_previous cues automatic. advance_after enables conditional auto-advance after the incoming transition; it does not turn the presentation into fixed-duration video. Video layers use a static fallback in HTML, so their playback/caption timing is not reproduced. Check validate_export() and the returned fallback diagnostics for the target. GIF/video option families, unsupported transparency and codec rate limits fail with actionable errors rather than silently selecting another format.

Verify playback

The three-slide integration scene in tests/test_video_layers.py checks actual GIF, MP4 and WebM output: selected pixels against canonical samples, incoming transitions, caption start/end windows, encoded timing, and decoded soundtrack and narration frequencies. Reduced-motion cases check frozen clip imagery and timed captions. Run it with FFmpeg and ffprobe installed:

uv run --locked pytest tests/test_video_layers.py -k motion_package --no-cov

Existing HTML timeline tests execute the JavaScript in Node stage fixtures; target-specific browser fidelity remains a separate check.