Skip to content

Advert footage

Advert footage #6

name: Advert footage
# Records the app footage for the advert in a simulator, one clip per beat.
#
# The sibling of screenshots.yml, and it exists for the same reason: footage
# taken by hand on a phone is in one language, at whatever size that phone is,
# and has to be retaken from scratch for a one-word change. This produces it
# again for nothing whenever the interface moves, in any language the app
# speaks.
#
# gh workflow run advert-footage.yml # every beat, en-GB
# gh workflow run advert-footage.yml -f beat="which city" # one beat
# gh workflow run advert-footage.yml -f locale=fr-FR # in French
#
# Deliberately not on push, like screenshots.yml: it boots a simulator, and the
# interface changes far less often than the code does. Not a money question --
# this repo is public, so macOS runners are free -- but a wall-clock one.
#
# WHAT THIS CANNOT PRODUCE, and neither can any change to it:
#
# The Apple Maps payoff shot. A simulator ignores a `maps://guide` payload
# (Maps opens and does not change, delta 0.0) and sends the https form to
# Safari, because Maps does not claim the domain there. Measured both ways in
# ten languages -- store/SCREENSHOTS-RUNBOOK.md section 3. The link works on a
# real device. That shot is a device capture, permanently, and the advert is
# cut on the assumption that it is.
#
# AND THE THING TO ACTUALLY CHECK: a green job means the clips are the right
# length and are not frozen. It says nothing about whether they are smooth. A CI
# runner has no GPU, the simulator renders in software, and a list animating at
# fifteen frames a second looks fine to every check in this repo and obviously
# wrong to a person. Download the artifact and watch it before it is cut into
# anything public.
on:
workflow_dispatch:
inputs:
beat:
description: 'One beat by name (blank = all of them)'
type: string
default: ''
locale:
description: 'Device language for the recording'
type: string
default: 'en-GB'
verbose:
description: 'Log every command and its output'
type: boolean
default: true
env:
FLUTTER_VERSION: '3.44.8'
jobs:
record:
name: Record the footage
# iOS 26 SDK, and the only runner with a simulator at all.
runs-on: macos-26
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- uses: subosito/flutter-action@v2
with:
flutter-version: ${{ env.FLUTTER_VERSION }}
channel: stable
cache: true
- run: flutter pub get
# Two guards before anything boots, for the reason screenshots.yml has
# one: every beat builds on a scene, and a renamed scene or a moved
# button should fail in seconds rather than after a simulator boot, a
# Flutter build and a recording of a black screen.
#
# The probe runs the beats themselves on the fake clock — same file,
# same sequence, no device — so it catches everything about this run
# except whether the picture moves.
- name: Check every scene renders
run: flutter test test/scene_render_test.dart --reporter compact
- name: Check the beats can be performed
run: flutter test test/advert_choreography_test.dart --reporter compact
# `-u` so the log arrives as it happens. A previous run of the screenshot
# pipeline emitted thirty-one minutes of output in a single burst at the
# end, and there was no telling how far it had got.
- name: Record
run: |
python3 -u store/record.py \
${{ inputs.beat && format('--beat "{0}"', inputs.beat) || '' }} \
--locale ${{ inputs.locale || 'en-GB' }} \
${{ inputs.verbose && '--verbose' || '' }}
# Per-clip verdict in the run summary, so a short or stuttering clip is
# visible without downloading anything. Read out of each file's own
# QuickTime atoms: the first version asked `mdls`, which reads Spotlight
# metadata an ephemeral runner never builds, and it reported 0.0s for a
# 402-second file, failing three good clips.
- name: Measure every clip
if: always()
run: |
python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY"
import pathlib, sys
sys.path.insert(0, 'store')
from record import probe
print('| clip | seconds | frames | peak fps | avg fps | size |')
print('| --- | --- | --- | --- | --- | --- |')
for f in sorted(pathlib.Path('store/footage').rglob('*.mov')):
secs, frames, ok, peak = probe(f)
avg = frames / secs if secs else 0
flag = '' if ok else ' **NOT FINALISED**'
print(f'| {f.relative_to("store/footage")}{flag} | {secs:.1f} '
f'| {frames} | {peak:.0f} | {avg:.0f} '
f'| {f.stat().st_size / 1e6:.1f} MB |')
print()
print('Peak is the busiest two seconds. **Average is meaningless here**')
print('-- a beat is mostly deliberate holds, and simctl writes a frame')
print('only when the screen changes. Neither number is composition.')
print('Watch the clips.')
PY
# Always, including on failure: a half-finished set is the most useful
# thing to look at when working out why it failed.
- name: Keep the footage
if: always()
uses: actions/upload-artifact@v4
with:
name: advert-footage-${{ inputs.locale || 'en-GB' }}
path: store/footage/**/*.mov
if-no-files-found: warn