QA: acceptance checklists and the critic pass
Read this when a video is about to be rendered or delivered: the pre-render and post-render checklists
and the showtime qa rules and expect block. The critic protocol is in review.md.
Nothing ships on "it rendered". Ship on evidence. Run the pre-render list before the final render, the post-render list on the MP4, then the critic pass. Fix, re-render the affected range, and re-check. Report failures honestly. Don't retime audio or cut duration to hide a sync problem.
Tools: showtime check <project> (runtime errors, overflow, contrast from real pixels, determinism
warnings), showtime snap <project> --at t1,t2,... and --sheet --every 1s (stills and contact sheets),
showtime render <project> --preview (fast draft), showtime audio meter <file> (loudness and peaks).
For stream inspection, use the bundled ffprobe (showtime paths shows where it is). Never call a bare system ffmpeg/ffprobe.
Batch visual checks: one showtime look per phase, not dozens of single frames (looking.md).
Essentials#
- Never call a video ready without a
showtime qaverdict from the current turn, quoted with its numbers, and a look at the file (showtime look <job>). Ship on evidence, not on "it rendered" (§ Tools) - Pre-render list, final render, post-render list on the MP4, critic pass; never retime audio or cut duration to hide a sync problem (§1, §2, §3)
- The "This video tells ___ that ___" sentence exists and every scene traces to it; hook readable by 0.3 s, first change by 2 s, frame 0 neither black nor a logo sting (§1)
- Every claim, number, quote and logo is from the source; label illustrative data; no secrets, internal hostnames or real customer or personal data anywhere, share copy included (§1)
showtime check <project>: 0 errors, ends withphone check: PASS; contrast ≥4.5:1 (≥3:1 large display) on real pixels, mid-transition frames included; nolabels_crowded(§1)- Snap
cut − 0.1 sandcut + 0.2 sat every cut; no unplanned 1 s dead zone; ≤3 flashes in any 1 s and ≤2 flash frames per video; the final frame is a designed hold (§1) - No
Date.now, unseededMath.random, CSS transitions or timers;--at 3.2,3.2gives identical images (§1) - File: H.264 High,
yuv420p, BT.709 tags, faststart, AAC 48 kHz stereo, duration =showtime.json±1 frame (§2) - Audio: −14 LUFS ±1 (or the requested target), true peak ≤ −1.0 dBTP, LRA ≤8 LU (≤11 music-first), music
18–25 dB under the voice;
loudnessWARNs past 1 LU off target, FAILs past 3 LU (§2, § Tools) - Hearing (WARN, with times): a voice line under 8 dB over the music (
voice_masked, from the narration stem), near silence over 2 s mid-video (quiet_stretch), a jump over 6 LU at a cut (level_jump), sound still playing on the last frame (abrupt_end), a name or number of the voice-over heard differently from the script (readback, FAIL for a title, brand or lexicon name a second recognizer also hears differently), a mix that plays far quieter on a phone speaker than its loudness says (speaker_loudness: over 10 LU under above 300 Hz, FAIL over 18);review-packhands the critic the full measurements (§2, § Tools) - Frame 0 is the thumbnail: a hook complete at t=0 (
"poster": 0) or a baked poster (--poster-bake auto); EDL and external videos:showtime deliver poster <video> --at <t> --bake. Shipshare.txt(1–3 sentences, no claim not in the video), burned captions for social plus a sidecar, and credits for any CC-BY asset (§2) showtime qa [video|job] [--platform name] [--captions file] [--expect file]: always name the platform; exit 1 on FAIL (--strict: also WARN); after a WARN run the "Next command" it prints (§ Tools)- Size caps (
github,chat,web): keep the master, qa the capped export with--platform github(§ Tools) - Captions: ≤2 lines, ≤42 characters a line (32 vertical, 4:5 included), ≤20 characters/s for 3+ word cues, no
cue under 0.4 s; judge a variant on its own sidecar with
--captions FILE(§ Tools) - Phone check floors: 5 pt (16:9), 10 pt (1:1), 11 pt (4:5), 15 pt (9:16); reading time
0.3 s + max(characters / cps, words / wps), ≥1 s. qa quotes the lastshowtime check, so run check first or it says PARTIAL (§ Tools) frozenWARNs from 2.5 s without visible change (launch films 3.5 s), FAILs from 6 s. Launch films also WARN past 5 hard cuts or 6 scenes in 60 s, a 5.5 s still, and a voiceless mix moving under 3 dB (§ Tools)- Quality mode (the default), publish-bound or studio work:
showtime review-pack <job>(qa's "review pending" line names it), thenreview.mdwith the pack'sCRITIC.md; lean: publish-bound only (§3) - Quality floor (WARN): player controls in the footage, soft or upscaled footage, stepped caption boxes, a
small picture in big flat borders; the critic's
WOULD I POST THIS: noholds delivery in quality mode, and each blocker and should-fix it raises is fixed or waived before delivery (§3, § Tools) - Only a
final*.mp4is the latest final; promote a variant:job note <job> --output final=<file>(§ Tools)
| Section | Lines |
|---|---|
| 1. Pre-render (on the project) | 68-114 |
| 2. Post-render (on the MP4) | 116-162 |
| 3. The critic pass | 164-174 |
| Tools | 176-391 |
1. Pre-render (on the project)#
Story
- [ ] The "This video tells ___ that ___" sentence exists and every scene traces back to it.
- [ ] The hook is readable by 0.3 s, and the first change happens by 2 s. Frame 0 is not black and not a logo sting.
- [ ] Every claim, number, quote and logo is from the source (story.md §6). Illustrative data is labeled when it could be mistaken for real.
- [ ] No secrets, internal hostnames or real customer or personal data appear anywhere, including in share copy.
- [ ] The distinctness check (story.md §8) passed.
Text and layout (sheet at each scene start and midpoint)
- [ ]
showtime checkreports 0 errors. Warnings are read and either fixed or explained. - [ ] Every text hold meets pacing.md §1 (settled time, not entrance time).
- [ ] Sizes are at or above the floor in typography.md §3. Text sits inside the safe box for every target aspect.
- [ ]
showtime checkends withphone check: PASS(type size in points at phone width, every text held long enough to read, nothing under platform UI; the numbers and their sources are under "Phone check" below). - [ ] Contrast is ≥4.5:1 (≥3:1 for large display) on real pixels, including frames mid-transition.
- [ ] No
low_contrast: check judges a fading text at the frame where it is most opaque, so a long fade-in needs no shortening. - [ ] No overflow, clipping, collisions or orphaned single-word lines. Nothing sits in the caption band when captions exist.
- [ ] No
caption_zone: nothing visible is drawn where the caption cards sit while a caption shows (data-st-caption-okif intended). - [ ] No
webgpu: scenes draw with WebGL or 2D; a WebGPU page renders a flat ground on a machine without a GPU. - [ ] No
blur_*warning: a shutter blur (data-st-blur) sits only on 1-3 snap beats, on the element that snaps, never on text being read, slow moves or a whole scene; snap a blurred frame and the frame it lands on (sharp) to look at both. - [ ] No
labels_crowded(warning): SVG labels in one graphic (chart values and axes, map names) that touch or sit closer than 0.15em side by side or stacked (label_gap_eminruntime/thresholds.json). Fix: fewer bars (aggregate, or label only the highlight and extremes), the chart's defaultvalueLabels(auto-thinning, not"all"), or a larger plot. Seen only while a chart grows or morphs it is a note; the settled frame is judged. - [ ] There are no off-palette colors and at most 2 font families. All fonts load from local files (no fallback in the check report).
Motion
- [ ] Cuts: snapshot at
cut − 0.1 sandcut + 0.2 sfor every cut. Elements that continue across a cut match in position, scale and color. A crossfade between two busy layouts must not look like a muddy double exposure. Stagger it (old out, then new in) or dip through the ground. - [ ] No dead zones of 1 s or more without motion, unless it's the planned held beat or the final hold.
- [ ] No bounce on text blocks, no looping "breathing" on readable text, and no lingering slow push in the back half of a scene.
- [ ] Flash limit: ≤3 flashes in any 1 s window, and ≤2 flash frames in the whole video.
- [ ] The final frame is a designed hold (logo, CTA), not the tail end of an exit animation.
Audio plan
- [ ] Music starts on frame 1 (no silent intro) and ends on a button that lands on the logo (no fade-out mid-phrase).
- [ ] The voice script fits the word budget, and scene durations were synced from the measured voice clips.
- [ ] SFX density matches the tone, with one sound family. The reveal gets an impact, and there is no whoosh on every cut.
Determinism
- [ ] No
Date.now, unseededMath.random, CSS transitions, infinite animations or timers driving visuals (showtime checkwarns). Randomness goes throughST.rand(seed)/ST.noise. - [ ] Snap the same time twice (e.g.
--at 3.2,3.2). The two images must be identical. - [ ] Snap a late time first and then an early one. Seeking backward must give the same image as seeking forward.
2. Post-render (on the MP4)#
File
- [ ] H.264 High,
yuv420p, BT.709 tags present, faststart, AAC 48 kHz stereo, and the expected width, height and fps. - [ ] Duration =
showtime.jsonduration ±1 frame. Frame count =round(duration × fps). Audio duration within 1 frame of video. - [ ] Within every target's length and size cap (platforms.md §2).
Visual (contact sheet at 1 s intervals + stills at the hook, reveal, each cut, the final second)
- [ ] Frame 0 is the intended poster or hook frame. There are no black or frozen runs over 0.5 s that weren't planned.
Camera footage of a speaker holding still is not frozen:
qasamples a long hold and, when every part of the frame keeps camera noise and a few places move (lips, blinks, hands) while there is sound, reports it asheld_shot(INFO) instead; the same live picture without sound is afrozenWARN. A hold in a project whose page plays no<video>is always judged as frozen. - [ ] No banding in dark gradients when viewed at 200% (color.md §4). No encoding blockiness on grain or fast motion.
- [ ] Brand colors sampled from a flat area are within about 2 levels of the source hex.
- [ ] Text is legible when the sheet is viewed at phone size (about 360 px wide for 16:9, 270 px for 9:16).
Audio metering (showtime audio meter)
| Measure | Target |
|---|---|
| Integrated loudness | −14 LUFS ±1 (or the requested target) |
| True peak | ≤ −1.0 dBTP |
| Loudness range | ≤8 LU (≤11 for music-first pieces) |
| Voice vs music under it | Music 18–25 dB below the voice. Voice is always intelligible |
| Start | Audio present within the first 0.1 s when music is planned from frame 1 |
| End | Clean button or tail, with no cut-off decay in the last 0.3 s (abrupt_end) |
| Across cuts | No jump over 6 LU in the bed or the voice that the story does not call for (level_jump) |
| Heard on a phone | The mix above 300 Hz at most 10 LU under the full mix (speaker_loudness; posted films 1-8 LU) |
- [ ] Listen once on small speakers or earbuds, at least the hook, the reveal and the end. No clicks at edits,
no harsh sibilance, and no SFX louder than the voice. The critic cannot listen: its hearing pass judges
only the numbers in the pack's
audio.txt(review.md §1), so these stay yours.
Deliverables
- [ ] Frame 0 is the thumbnail and flows into frame 1: a hook complete at t=0 (
"poster": 0), or a poster thatshowtime renderbaked because it looks like the opening (--poster-bake auto).poster.jpgis the cover to upload where a platform takes one. EDL and external videos:showtime deliver poster <video> --at <t> --bake(then<video>.poster.mp4ships). qa WARNsposter_flashwhen frame 0 jumps to a different frame 1. - [ ]
share.txtexists, is 1–3 sentences in the video's tone, and contains no claim that isn't in the video. - [ ] Captions are burned in for social targets, and a sidecar
.srt/.vttexists where the platform supports one. - [ ] A credits file ships whenever any CC-BY asset was used (
credits.txtbesidefinal.mp4, or<stem>.credits.txt; render writes it), and the user has been told to paste it into the description. - [ ] Platform exports each pass their own caps and safe zones.
- [ ] A joined final (
render --join, render.jsonkind: "joined") or a spliced one: qa's seam section passes. It reads the frames again (count = the render's, a keyframe at each seam), checks the audio stream is the render's master bit for bit (no loudness step, no shift), and reports the join's own seam check (the seam frames against a fresh capture from the page, PSNR >= 40 dB or at most 3 dB under a frame inside the same piece (never under 25 dB); loudness at each seam = the master's +-0.1 dB; sync lag 0 +-1 sample). A failed join leaveswork/chapters/joined-failed.mp4, never a final. - [ ] The report to the user lists paths, actual duration, the angle in one sentence, the contact sheet, and scene ids for targeted changes.
3. The critic pass#
Every finished video in quality mode (the default; qa prints "review pending" with the command until a round
has a verdict), and publish-bound or studio work in lean mode: showtime review-pack <job>, then follow
review.md. The critic's
brief is the CRITIC.md the pack writes (severity scale, citation rule, answer format, three rounds
at most, pairwise from round 2, the absolute WOULD I POST THIS line, the first-viewer pass); do not write a brief
of your own. Lean work that is not publish-bound gets the self-review in review.md section 1. Once a critic has
answered, showtime job note <job> --stage deliver waits until each of its blockers and should-fixes is fixed or
waived (showtime review-respond, review.md section 4). It also refuses while the final's latest qa verdict is
FAIL (WARN passes): fix, re-render and run showtime qa <job> again.
Tools#
The checklists above are judged by eye; these commands produce the evidence. Do not call a video ready
without a showtime qa verdict from the current turn, quoted with its numbers, and without a look at
the file (showtime look <job>, looking.md).
showtime qa [video|job] [--project dir] [--expect file] [--platform name] [--captions file] [--json]
checks the delivered file itself. Given a job (folder or name), or nothing inside a job, it checks the
job's latest final (else its latest draft) and prints using <path> (latest final); an explicit older
file is still checked, with a note naming the newer one. Name the platform whenever there is one:
--platform, else showtime.json expect.platform, else showtime.json "platform", else the job's
(showtime job init|note --platform reels); the output says which (platform reels (from job.json)),
and with none it runs generic checks only (no length or aspect limits).
Which caption files it checks (it prints them; qa.json captions):
--captions FILE(repeatable): exactly those files, nothing found automatically. Use it to judge a variant on its own sidecar (qa shorts-9x16.mp4 --captions shorts-9x16.ass).- In a job folder: the video's own sidecars,
<stem>.srt/.vtt/.assbeside it (final.srtalso counts for a bakedfinal.poster.mp4), plus the job's latest captions (recorded byedit render, byshowtime captions --srtinto the job folder, orjob note --output captions=<file>) only when the file is the job's latest final or draft, has no sidecar of its own and the captions were made for the same aspect (an.assPlayRes, or the video the.srtis named after); with no captions recorded, the project's own caption files of that aspect. A 9:16 variant or an export never inherits the 16:9 captions. - Outside a job:
<stem>.*,captions.*/subs.*andcaptions/beside the video, and the project's.
Output: PASS / WARN / FAIL lines, each with a rule id, a timestamp, the frame at that time
(work/qa/<video>/frames/) and a fix; qa.json and sheet.jpg (FAIL/WARN frames outlined). Every long
caption line and every too-fast cue is listed (up to 12 each, the rest counted), so one run shows them all.
The hearing measurements (voice over the music per line, words per minute, quiet stretches, the level across
each cut, effects, the ending, the read-back) are in qa.json hearing, summed up in the hearing line; their 100 ms block
levels are in loudness.json blocks, from which review-pack writes the critic's audio.txt and hearing.png.
Exit code 1 on FAIL (--strict: also on WARN). When the video sits in a job folder, the verdict is logged
per file in job.json (qa_files); the job's verdict (qa, what status and SHOWTIME.md show) follows
the job's latest final (else draft) only, so checking an export or a variant never makes the final look
unchecked. After a WARN, "Next command" names the rules to fix first (e.g.
showtime check <project> --find-first frozen), then the re-render and showtime qa <job>.
Size-capped platforms (github, chat, web, or expect.max_size_mb): keep a full-quality master and
deliver a capped copy. When deliver exports already wrote one beside the master (exports/<stem>.github.mp4,
<stem>.<N>mb.mp4) under the cap, the master's file_size is INFO and names the command that checks the
export itself (showtime qa <export> --platform github); without one it FAILs with the exact
deliver exports command.
| Rule | Severity | Fires when |
|---|---|---|
unreadable, no_video |
FAIL | ffprobe cannot read the file (or its audio cannot be decoded) / the file has no video stream |
odd_dimensions |
FAIL | width or height is odd |
codec, pix_fmt, variable_fps, faststart, color_tags |
WARN | not H.264 / yuv420p / constant standard fps / moov first / BT.709 tagged |
duration |
FAIL | off the expect block (± tolerance) or the project/render length (± 1.5 frames) |
no_audio |
WARN (FAIL if expect.audio) |
no audio stream |
audio_codec |
WARN (INFO for a sample rate other than 48 kHz) | the audio codec is not AAC |
av_length |
WARN | audio and video lengths differ by more than 1.5 frames (and 0.06 s) |
silent_audio |
FAIL | the audio stream is digital silence |
loudness |
WARN > 1 LU off target, FAIL > 3 LU | integrated LUFS vs the platform target (default -14) |
true_peak |
WARN above ceiling + 0.3, FAIL above 0 dBTP | 8x oversampled true peak |
clipping |
FAIL | runs of full-scale samples (a squared-off waveform) |
leading_silence / trailing_silence |
WARN | sound starts after 0.5 s / the last 2 s are silent |
silent_gap |
WARN ≥ 1 s, FAIL ≥ 3 s | silence inside the video |
voice_masked |
WARN | a voice line sits under 8 dB (hearing.voice_over_bed_min_db) over the music under its words. Measured only when the render's narration stem lines up with the file's audio (render keeps work/audio/mix.voice.wav; an edit render its speech stem; a voice-only mix over an ST.score bed): the stem is aligned (±50 ms) and scaled to the delivered audio, the rest is the bed. Without a stem review-pack still estimates it for the critic, never a WARN |
quiet_stretch |
WARN | near silence for 2 s or more mid-video: 100 ms blocks more than 20 LU under the integrated loudness (quiet_stretch_s, quiet_under_lu), not already a silent_gap |
level_jump |
WARN | the level changes more than 6 LU (level_jump_lu) across a cut (planned scene starts and hard cuts), 1 s each side, like with like: the bed with the bed (stem), else both sides speech or both not; a cut where speech starts or stops, an effect within 1 s or a music section change at the cut does not count |
readback |
WARN; FAIL for a name in the title, brand or project lexicon that a second installed recognizer (Whisper small.en for English) also hears differently | the project's voice/vo.wav transcribed again locally and lined up with the script sound by sound: a name, acronym or number heard differently ("Open A I" heard as "OpenI"). Reuses voice/readback.json when voice script wrote it for that vo.wav; else transcribes the line clips (cached), or vo.wav itself. INFO when the recognizer is not installed. A word someone listened to and found right is cleared with "readback": {"ok": ["JSON"]} in showtime.json. A word someone listened to, whose voice is right and only the recognizer's spelling differs, is listed in "readback": {"note": [...]} (the project's claim, shown as such): INFO, never FAIL. Fix: voice.md § Pronunciation fixes |
speaker_loudness |
WARN over 10 LU, FAIL over 18 (speaker_gap_lu, speaker_gap_fail_lu) for a showtime mix (a mix report or an ST.score bed); only WARN for sound showtime did not mix (your own song, a footage edit's sound); INFO when the mix turned speaker_safe off |
heard on a phone: the integrated loudness of the mix above 300 Hz (and above 1 kHz), measured like ffmpeg -af "highpass=f=300,highpass=f=300,ebur128", against the full mix. Phone and laptop speakers play little under 300 Hz, so a mix of kick, bass and sub at -14 LUFS plays quiet there. Calibrated on 7 Oct 2026: every posted film 0.7-8.2 LU, a bass-led showreel preview 13.6 LU (-27.6 LUFS above 300 Hz, about 9 dB quieter on a phone than the voice-led films). The fix: showtime audio mix (its speaker-safe step, on by default, cuts the bed's lows; audio.md § Heard on a phone), a bed with more in the mids, a mid transient on sub hits. The numbers are in every qa run (qa.json hearing.speaker, the PASS line, the hearing line) |
abrupt_end |
WARN | the last 0.2 s (of the bed, with a stem) are within 10 dB (abrupt_end_db) of the 3 s before and audible: music, or a word, cut off on the last frame. A seamless loop may ignore it |
first_frame_black |
FAIL | frame 0 is black (no poster baked) |
first_frame_flat |
WARN | frame 0 is one flat colour |
poster_flash |
WARN | frame 0 differs sharply from frames 1-2 (a baked poster over an opening that builds from empty): a one-frame flash on autoplay and every loop |
poster_mismatch |
WARN | the render's poster still (render.json poster) differs from its video frame by more than 4 mean luma levels, or its PNG carries colour chunks (gAMA, cHRM, cICP, iCCP) that make browsers draw it darker than the video |
black_segment |
WARN ≥ 0.25 s, FAIL ≥ 1 s | black inside the video (ends_black: WARN for a black ending ≥ 1 s) |
frozen |
WARN ≥ 2.5 s (launch films, showtime.json "kind": "launch": ≥ 3.5 s, launch_hold_s), FAIL ≥ 6 s or half the video |
no visible change (a few typed characters or a thin moving line still count as a hold); the thresholds, freeze_noise_db included, live in runtime/thresholds.json, shared with showtime check, so check finds the same holds before the render. A padded or blurred export is judged inside its picture (<export>.export.json), not on its bars. See "Dark themes and slow pushes" below |
dead_stop |
WARN | a fast move (6+ frames of real change) whose last frames still move at half its peak or more, then nothing for 4+ frames: it lands with no settle. The message names the frame; fix: ease the last 6-10 frames out (power3.out, premium) or land earlier and hold (st.qa.motion, on the rhythm's frame differences) |
held_shot |
INFO | a long hold that is live camera footage with sound (a speaker holding still), not a frozen picture; see the Visual checklist above |
final_hold |
INFO ≤ 4 s, WARN above | the ending is a still hold (fine for an end card) |
captions_past_end, captions_timing |
FAIL | sidecar cues outside the video or reversed |
caption_flash |
WARN | cues on screen for under 0.4 s (fast speech split into one-word blinks); the summary prints the shortest cue |
captions_overlap, caption_lines, caption_line_long, caption_fast, caption_bounds |
WARN | two cues at once, > 2 lines, > 42 characters (32 vertical, 4:5 included), > 20 characters/s (cues of 3+ words), outside the safe box. showtime captions and voice script write within these same limits |
phone_size, phone_reading, phone_zone |
WARN | the phone check's findings, copied from the project's last showtime check with their timestamps (and frames): text under the minimum in points at phone width / on screen for less than it takes to read / under platform UI, a player control strip or at the frame edge. A phone-check failure is a WARN, never a FAIL |
phone_unverified |
INFO | qa could not verify type size, reading time or UI zones: no showtime check report with a phone block for this project, or it ran at another size. Captions are still judged (below) |
captions_missing |
FAIL/WARN | expect.captions but no sidecar and no caption text in the check report |
captions_empty |
WARN | a caption file has no cues or cannot be read |
missing_credits |
FAIL | a mix report or .license.json sidecar requires attribution and no credits file (credits.txt in any case, <stem>.credits.txt, or the job's credits) ships with the video |
credits_incomplete |
WARN | the credits file lacks a required line |
too_long, too_short, file_size |
FAIL | outside the platform or expect limits |
aspect |
WARN (FAIL for expect.width/height/aspect) |
aspect differs from the platform |
resolution |
WARN | the aspect fits but the frame has under 97 % of the platform's pixels (540x960 for Reels): render the final at full size |
upscale |
WARN (INFO when the EDL sets "output": {"allow_upscale": true}) |
an EDL render enlarged a source more than 1.5x (from <video>.report.json, also for a baked final.poster.mp4 and for exports, which carry the report with factors scaled to their size); the fix names a smaller output size or --fit blur, or says when it is unavoidable (1080p to 1080x1920) |
caption_face |
WARN | an edit with cards (or captions.avoid_face) has captions over the tracked face (eyes to chin) with no room above or below it (from <video>.report.json) |
card_problem |
WARN | an edit's cards (EDL cards) overlap, sit on the captions or out of the safe box, lack room beside the face, or are too short to read (from <video>.report.json; edit check lists the same) |
behind_hidden |
WARN | a behind card's word is over 45 % behind the speaker, or a stretch over 40 % of its width is hidden (measured by the render from the word's ink and the speaker's matte; edit check estimates it from the face) |
matte_flicker |
WARN | the speaker's cut-out under a behind card flickers: over 2 % of the speaker's area jumps on a still picture (the render's matte measure) |
player_chrome |
WARN (footage) | a thin bar across over half the width, low in the frame or in a recorded player, with small glyphs at both ends (play, time, fullscreen), in the same place on 2+ sampled frames: a web player's controls recorded with the page |
soft_footage |
WARN (footage) | edge sharpness (sum |Laplacian| / sum |gradient| per detailed 64 px cell, at delivery size) under 0.75 on 60 % of the detailed cells, on half the sampled frames: crisp screen text is ~1.5, a 2x upscale ~1.0, 3x ~0.7. Fix: re-record at 2x device scale |
caption_boxes |
WARN | two caption boxes stacked around one centre with widths differing by over 10 % (one box per line: the stepped look), on 2+ sampled frames. Fix: one plate (captions --style boxed) |
empty_borders |
WARN (footage) | a solid picture filling under 70 % of the frame (width x height) inside flat borders, on over 30 % (and 2+) of the sampled frames |
must_show |
FAIL | a must-show text is missing from the on-screen text list of the last showtime check |
must_show_unverified |
WARN | found only in the project source (run showtime check, then qa again) |
expect_invalid |
WARN | an unknown key in the expect block, or an unknown platform name (checked without a platform target) |
reference_copy |
FAIL | the render copies a style reference of its job: 25 % of sampled frames match reference frames, or 10 % with the same cut rhythm (reference.md section 3) |
reference_close |
WARN | 5 % or more of the sampled frames look like reference frames |
reference_credit |
WARN | "Style reference: ..." is missing from credits.txt or share.txt (showtime reference credit <job>) |
edit_choppy |
WARN (launch films) | more than 5 hard cuts in a film of up to 60 s |
too_many_scenes |
WARN (launch films) | more than 6 scenes or layouts in up to 60 s |
dead_hold |
WARN (launch films) | nothing moves for more than 5.5 s |
flat_music |
WARN (launch films without a voice) | the mix's loudness moves less than 3 dB (10th-90th percentile of 0.5 s windows) |
showreel_sparse |
WARN (showreel tone) | fewer than 12 shots per 15 s (scenes, layouts, hard cuts or the showreel shot count, whichever counts most; scaled to the length): a reel that cuts like a launch film loses its brief. The launch rules above do not apply in the showreel tone |
showreel_long_end |
WARN (showreel tone) | the end card holds still (0.25 s windows moving under 1 level) for more than 10% of the reel, or the last shot runs over 20%: the losing reels of the 2026-10-05 rematch held their name 2.5-3.5 s of 15. Land the name on the last beats with its reading time and keep it moving |
showreel_repeats |
WARN (showreel tone) | two shots of 0.4 s or more, not neighbours and not the end card, look alike: colour histogram distance under 0.3 and coarse layout correlation over 0.7 (the same ground, palette and composition: one technique twice) |
flash_risk |
WARN (every video) | more than 3 general flashes in any second: a frame-to-frame change of 0.1 or more in linear luminance over a quarter of the frame or more, the darker side under 0.8, a pair of opposing changes counted as one flash (the WCAG general flash threshold, measured on 256x144 frames). Fix: space strobes and glitch bursts 0.33 s apart, shrink them, or lower their contrast (pacing.md §5) |
The optional expect block in showtime.json (or a file passed with --expect) states the brief's
measurable targets up front:
"expect": {"duration": 15, "tolerance": 0.5, "platform": "reels", "lufs": -14, "true_peak": -1,
"audio": true, "captions": true, "max_size_mb": 50,
"must_show": ["Northwind", {"text": "v2 is here", "at": 3.0, "tol": 1.0}]}
Platforms: youtube x linkedin reels tiktok shorts square (as in deliver exports) plus web (silent loop,
15 MB), github and chat (10 MB), broadcast (-23 LUFS).
Edit rhythm (rhythm in qa.json, one line in the output) is measured for every video up to 3 minutes:
layouts (stretches with one composition) and how each layout change happens (cut, fast: a push,
wipe or whip under 0.6 s that replaces the frame, move: 0.6 s or more of continuous motion: a camera
move, a match, a dissolve), hard cuts, shot lengths, the share of frames where nothing moves and the
longest such stretch, the mix's dynamics, and per planned scene change whether it was a hard cut
and whether the music moves there (an onset or a +1.5 dB rise). It is judged (the four rules above) for
launch, promo, release and trailer films: showtime.json "kind": "launch" (the launch template sets it),
expect.style, or a job goal that says launch, promo, trailer, teaser or release video. The numbers
come from the premium grammar in workflows/launch-video.md. In the showreel tone (showtime.json "tone": "showreel", the job's tone, or a brief that says showreel, demo reel, hype reel, go all out or show off;
tones.md) the launch rules are replaced by the showreel checks (showreel_sparse, showreel_long_end,
showreel_repeats; lib/st/qa/reel.py measures the shots, the end card's still hold, look-alike pairs and
energy dips into qa.json's rhythm.reel), and qa.json has a showreel block saying where the tone came from. Flashes (flash_risk) are judged on every video.
Phone check (the audience complaint: text moves too fast to read and is too small on a phone). One named check, printed as one line by both tools:
phone check: PASS (smallest text 8.1 pt (minimum 5 pt for 16:9); every text held to its reading time at 17 characters/s or 3 words/s (en); nothing under platform UI; captions: 12 cues, longest line 34 characters, fastest 15 characters/s)
phone check: FAIL - type 4.1 pt "Terms apply" at 0:12.4; reading "Sign up today" 0.8s of 2.1s at 0:05.0; under platform UI "Link in bio" at 0:09.0
phone check: PARTIAL (captions: 12 cues, longest line 34 characters; type size, reading time and UI zones need `showtime check <project>` ...)
Showreel flash words. In the showreel tone only, a text marked data-st-flash (canvas: {flash: true})
of at most 3 words and 24 characters, on screen at least 0.2 s, may leave before its reading time: check notes
it as flash_text instead of short_text, and the PASS line says "except N flash word(s) (showreel tone)". The
exemption is that narrow on purpose: an unmarked text, a longer marked line or a 1-frame blip is still
short_text; with no other text held its reading time (the hero line) check warns no_hero_line, a reading
part of the phone check; size, contrast, overlaps and platform UI zones are judged as always. Outside the tone the
mark changes nothing.
showtime check measures it before the render and writes the per-frame findings plus a phone block in
report.json (aspect, minimum, smallest texts, items with timestamps). showtime qa cannot read pixels back
into text, so it quotes that block (work/check/report.json) and says PARTIAL when there is none, when it ran at
another size, or when it is older than the project sources; captions are always judged on the sidecar files.
Parts:
| Part | Rule (finding codes) | Limit | Where the number comes from |
|---|---|---|---|
| Reading time | short_text |
every text (not numbers alone, not data-caption text read along with the voice) is on screen for 0.3 s + max(characters / cps, words / wps), at least 1 s; per language in runtime/thresholds.json reading: default 17 characters/s and 3 words/s, ja 4 chars/s, zh 9, ko 12 (no words/s for languages written without spaces). The language is showtime.json lang, else <html lang>, else en |
Checked against the published sources on 2026-09-29, no number changed. Netflix timed-text style guides, reading speed for adult programs: English 20 characters/s (17 for children's programs; en-US), Japanese 4 (ja; 7 for SDH, no separate children's figure), Simplified Chinese 9 (7 for children's; zh), Korean 12 (9 for children's; ko). So ja 4, zh 9 and ko 12 are exactly the adult limits. The default 17 characters/s is the English children's limit, i.e. deliberately under the adult 20 (on-screen text is read while the picture also moves). 3 words/s = 180 words/min, the top of the BBC's 160-180 wpm recommendation (0.33-0.375 s per word, at least about 0.3 s per word on screen), quoted from the BBC Subtitle Guidelines as reproduced on clevercast.com (the BBC page itself could not be fetched when this was checked); it is about 76 % of the 238 wpm adults read silently (English non-fiction; 260 fiction, 183 aloud): Brysbaert 2019, Journal of Memory and Language 109, 104047 (open PDF: gwern.net). Tune the file, not the code |
| Type size | tiny_text |
on-screen size in points at phone width: px x 390 / frame width (an iPhone 14/15-class screen, the video filling its width in a feed). Minimum by aspect (phone_min_pt): 16:9 5 pt, 1:1 10 pt, 4:5 11 pt, 9:16 15 pt; tiny_text_frac (2.2 % of the frame height) stays as a second floor, so 1920x1080 needs 25 px, 1080x1920 42 px, 1080x1080 28 px, 1080x1350 31 px. Decor (data-st-decor, {decor: true}) is exempt. Judged on the median size over the text's life, so an entrance that starts small is not a small text; text under the minimum is one warning per text, grouped when there are many |
calibrated on the shipped examples: their smallest 16:9 labels are 5.1-6.5 pt (26-33 px at 1080p), their vertical ones 15.6-15.9 pt, the square one 11.2 pt, so the minimums are the smallest sizes that let the examples pass. That is a floor, not a target: aim for typography.md's floors (36 px at 1080p = 7.3 pt, 48 px at 1080x1920 = 17.3 pt), and small_text notes still mark text under ~3.3 % |
| UI zones | safe_zone, edge_margin, control_strip |
frames taller than wide (9:16, 4:5): text inside x 64-916, y 220-1440 at 1080x1920 (platforms.md §3, scaled); landscape and square: not within 3 % of an edge, and no small text in the bottom 8 % (player controls) | platforms.md §3, typography.md §6 |
| Captions | caption_line_long, caption_fast, caption_flash, caption_lines, caption_bounds (qa, on the sidecar) |
at most 2 lines, 42 characters per line (32 vertical), 20 characters/s for cues of 3+ words, no cue under 0.4 s, inside the safe box (st/captions_rules.py, shared with the writers) |
subtitle practice as above; the summary line quotes the cue count, the longest line and the fastest cue |
--no-timeline skips reading time (the line says so); type size and zones come from the sample frames then.
Burned-in captions that are drawn as page text (data-caption) get the type-size and zone checks like any text,
but not the reading-time rule (they follow the voice).
Quality floor (st/qa/floor.py). Four looks-cheap patterns that pass every technical check: the four
rows above from player_chrome on, measured on 3-10 frames sampled across the video (grey, at delivery size up to
1920 px wide). They are WARNs only, tuned to stay quiet on designed frames (a title on a flat ground, a divider
line, one caption plate); the thresholds are constants at the top of the file. player_chrome, soft_footage
and empty_borders run only when the video may hold footage (not for a project whose page plays no <video>),
and a padded export is judged inside its picture. qa.json floor has the counts.
Dark themes and slow pushes. Black and frozen stretches are measured on absolute pixel change, so two honest designs can trip them:
black_segmenton a dark theme: a near-black ground (#0b0b0f and similar) counts as black. A 0.25-1 s WARN where a crossfade lands on the empty ground is usually real dead air; start the incoming content earlier (or on the cut) rather than lightening the ground.frozenon a slow push or drift over dark frames: an 8 % push over 7 s, eased at both ends, changes dark pixels too little in its slow ends. Look at the two frames the finding names (orshowtime snap <video> --at <start>,<end>): if the framing visibly moved, the hold is a slow camera move, not a still; either speed the move (or drop the ease on the long side), add a moving element, or accept the WARN knowingly (showtime job note <job> --verified "frozen at 12.4 s is a slow push"). A typing beat on a still terminal is a real hold; give it a cursor or a camera move.
showtime check <project> also audits canvas films through Film.frameInfo() (text off frame, safe
zone, overlap, contrast against the surrounding pixels, fonts, reading time), flags Math.random() during
playback (unseeded_random), and records every on-screen text with its first/last time in
report.json (texts), which qa uses for must_show. Extra modes:
--determinism: 12 probe frames plus a second page load; frame hashes land inreport.json.--find-first black|frozen|nondeterministic|error [--from s --to s --min s]: bisects to the first bad frame and saves it with the last good one (seedebugging-renders.md).
showtime review-pack [job|video] (a job means its latest final) builds the critic's folder (review/round-N/: sheets, key and scene
frames, loudness graph, qa run, context copies, CRITIC.md with the eight judging questions). Scene
frames follow the planned scenes: showtime.json "scenes", then "chapters", then a film's CUE.acts
(every key: number pair in CUE counts), then the page's scene clips (<section> / .scene clips when
there are two or more, else top-level clips minus overlays: a clip shown inside another clip's window, an
open-ended layer under later clips, or data-overlay), voice/timeline.json slots or the EDL report; only
without any of these does it detect cuts in the pixels. It prints (N scenes, M cuts, from <source>); the
manifest has scenes_source. Its fresh qa run uses --platform/--lufs when given, else the ones the last
showtime qa of the same file was given (a -16 LUFS tutorial stays judged at -16). For an edit render, qa judges loudness against what the render report says it was mastered to (-14 by default; a kept source level is noted, not failed). The protocol is in
review.md.
Which render is the job's latest final. showtime qa <job>, review-pack <job> and deliver use the
job's latest final. A render, edit render, captions --burn or deliver poster --bake inside a job
becomes it when the file is an .mp4 named final* (or a derived copy of the current final, e.g.
launch.poster.mp4 of launch.mp4); render --job always writes such names. Anything else (an alpha
.webm/.mov overlay, bumper.mp4, a second aspect named shorts-9x16.mp4) is logged as a variant and
the output says how to promote it: showtime job note <job> --output final=<file>. Check a variant with
showtime qa <file> (plus --captions for its own sidecar).
Planted-defect fixtures in tests/fixtures/defects/ prove each rule fires; add one before adding a rule.