Skip to content

Commit 7d273c2

Browse files
authored
Record the advert footage in a simulator, like the screenshots (#6)
The moving-picture sibling of the screenshot pipeline, sharing its one good idea: a beat is the real app put into a known state. Both this and lib/src/screenshots.dart call sceneFor(), so a change to the interface reaches the advert and the store screenshots together. Three beats, played by the app itself in lib/src/advert.dart -- hold, drag, tap, hold -- launched with `simctl launch` exactly as shoot.py does. The gestures are real: Choreography posts them through GestureBinding.handlePointerEvent. An earlier integration_test version rendered at 55fps and drove real gestures too, but LiveTestWidgetsFlutterBinding paints a crosshair at the pointer and it landed in the middle of the money shot. WHAT THIS CANNOT DO, and no change to it will: the Apple Maps payoff shot. A simulator ignores a maps://guide payload and sends the https form to Safari. Measured both ways in ten languages -- SCREENSHOTS-RUNBOOK.md section 3. That shot is a device capture. FOUR INSTRUMENTS WERE WRONG BEFORE THE FOOTAGE EVER WAS. Each looked like evidence, and three of them made good work appear broken: * mdls reads Spotlight metadata that an ephemeral runner never builds. It reported 0.0s for a 402-second file and failed three good clips. Durations now come out of the movie's own QuickTime atoms. * Average frame rate measures how much of a beat is a deliberate hold, not whether it stutters. advert-which-city averaged 8fps and its scroll ran at 59. The gate is now the busiest two seconds. * A contact sheet sampled every three seconds showed no dialog and looked like proof the beat was broken. The dialog fell between samples. * flutter analyze and the test suite both pass without checking formatting, so a local routine that felt thorough missed what CI looks at first. The real fault, once those were cleared, was that the first frame is not the first frame the recorder sees: the app draws for about two and a half seconds before the compositor presents anything. Beats now wait a lead-in, and record.py reads that constant from the Dart file. 647 tests pass. Verified on run 33268539804: three clips at 1290x2796, peaks of 60, 62 and 66 fps, no crosshair.
1 parent d80488d commit 7d273c2

6 files changed

Lines changed: 937 additions & 1 deletion

File tree

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
name: Advert footage
2+
3+
# Records the app footage for the advert in a simulator, one clip per beat.
4+
#
5+
# The sibling of screenshots.yml, and it exists for the same reason: footage
6+
# taken by hand on a phone is in one language, at whatever size that phone is,
7+
# and has to be retaken from scratch for a one-word change. This produces it
8+
# again for nothing whenever the interface moves, in any language the app
9+
# speaks.
10+
#
11+
# gh workflow run advert-footage.yml # every beat, en-GB
12+
# gh workflow run advert-footage.yml -f beat="which city" # one beat
13+
# gh workflow run advert-footage.yml -f locale=fr-FR # in French
14+
#
15+
# Deliberately not on push, like screenshots.yml: it boots a simulator, and the
16+
# interface changes far less often than the code does. Not a money question --
17+
# this repo is public, so macOS runners are free -- but a wall-clock one.
18+
#
19+
# WHAT THIS CANNOT PRODUCE, and neither can any change to it:
20+
#
21+
# The Apple Maps payoff shot. A simulator ignores a `maps://guide` payload
22+
# (Maps opens and does not change, delta 0.0) and sends the https form to
23+
# Safari, because Maps does not claim the domain there. Measured both ways in
24+
# ten languages -- store/SCREENSHOTS-RUNBOOK.md section 3. The link works on a
25+
# real device. That shot is a device capture, permanently, and the advert is
26+
# cut on the assumption that it is.
27+
#
28+
# AND THE THING TO ACTUALLY CHECK: a green job means the clips are the right
29+
# length and are not frozen. It says nothing about whether they are smooth. A CI
30+
# runner has no GPU, the simulator renders in software, and a list animating at
31+
# fifteen frames a second looks fine to every check in this repo and obviously
32+
# wrong to a person. Download the artifact and watch it before it is cut into
33+
# anything public.
34+
35+
on:
36+
workflow_dispatch:
37+
inputs:
38+
beat:
39+
description: 'One beat by name (blank = all of them)'
40+
type: string
41+
default: ''
42+
locale:
43+
description: 'Device language for the recording'
44+
type: string
45+
default: 'en-GB'
46+
verbose:
47+
description: 'Log every command and its output'
48+
type: boolean
49+
default: true
50+
51+
env:
52+
FLUTTER_VERSION: '3.44.8'
53+
54+
jobs:
55+
record:
56+
name: Record the footage
57+
# iOS 26 SDK, and the only runner with a simulator at all.
58+
runs-on: macos-26
59+
timeout-minutes: 60
60+
steps:
61+
- uses: actions/checkout@v5
62+
- uses: subosito/flutter-action@v2
63+
with:
64+
flutter-version: ${{ env.FLUTTER_VERSION }}
65+
channel: stable
66+
cache: true
67+
68+
- run: flutter pub get
69+
70+
# Two guards before anything boots, for the reason screenshots.yml has
71+
# one: every beat builds on a scene, and a renamed scene or a moved
72+
# button should fail in seconds rather than after a simulator boot, a
73+
# Flutter build and a recording of a black screen.
74+
#
75+
# The probe runs the beats themselves on the fake clock — same file,
76+
# same sequence, no device — so it catches everything about this run
77+
# except whether the picture moves.
78+
- name: Check every scene renders
79+
run: flutter test test/scene_render_test.dart --reporter compact
80+
81+
- name: Check the beats can be performed
82+
run: flutter test test/advert_choreography_test.dart --reporter compact
83+
84+
# `-u` so the log arrives as it happens. A previous run of the screenshot
85+
# pipeline emitted thirty-one minutes of output in a single burst at the
86+
# end, and there was no telling how far it had got.
87+
- name: Record
88+
run: |
89+
python3 -u store/record.py \
90+
${{ inputs.beat && format('--beat "{0}"', inputs.beat) || '' }} \
91+
--locale ${{ inputs.locale || 'en-GB' }} \
92+
${{ inputs.verbose && '--verbose' || '' }}
93+
94+
# Per-clip verdict in the run summary, so a short or stuttering clip is
95+
# visible without downloading anything. Read out of each file's own
96+
# QuickTime atoms: the first version asked `mdls`, which reads Spotlight
97+
# metadata an ephemeral runner never builds, and it reported 0.0s for a
98+
# 402-second file, failing three good clips.
99+
- name: Measure every clip
100+
if: always()
101+
run: |
102+
python3 - <<'PY' >> "$GITHUB_STEP_SUMMARY"
103+
import pathlib, sys
104+
sys.path.insert(0, 'store')
105+
from record import probe
106+
print('| clip | seconds | frames | peak fps | avg fps | size |')
107+
print('| --- | --- | --- | --- | --- | --- |')
108+
for f in sorted(pathlib.Path('store/footage').rglob('*.mov')):
109+
secs, frames, ok, peak = probe(f)
110+
avg = frames / secs if secs else 0
111+
flag = '' if ok else ' **NOT FINALISED**'
112+
print(f'| {f.relative_to("store/footage")}{flag} | {secs:.1f} '
113+
f'| {frames} | {peak:.0f} | {avg:.0f} '
114+
f'| {f.stat().st_size / 1e6:.1f} MB |')
115+
print()
116+
print('Peak is the busiest two seconds. **Average is meaningless here**')
117+
print('-- a beat is mostly deliberate holds, and simctl writes a frame')
118+
print('only when the screen changes. Neither number is composition.')
119+
print('Watch the clips.')
120+
PY
121+
122+
# Always, including on failure: a half-finished set is the most useful
123+
# thing to look at when working out why it failed.
124+
- name: Keep the footage
125+
if: always()
126+
uses: actions/upload-artifact@v4
127+
with:
128+
name: advert-footage-${{ inputs.locale || 'en-GB' }}
129+
path: store/footage/**/*.mov
130+
if-no-files-found: warn

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,9 @@ real/*
6363
!real/README.md
6464
screenshots/*
6565
!screenshots/README.md
66+
67+
# Recorded advert footage. Regenerated by store/record.py or advert-footage.yml,
68+
# and a few tens of megabytes per beat, so it is kept as a CI artifact rather
69+
# than in the history.
70+
footage/*
71+
!footage/README.md

lib/main.dart

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import 'package:image_picker/image_picker.dart';
55
import 'package:url_launcher/url_launcher.dart';
66

77
import 'l10n/app_localizations.dart';
8+
import 'src/advert.dart';
89
import 'src/entitlement.dart';
910
import 'src/file_source.dart';
1011
import 'src/guide_expand.dart';
@@ -44,7 +45,16 @@ void main() {
4445
// Read back off the device log by shoot.py after every launch, so the log
4546
// says which route delivered the scene even on a run that succeeds.
4647
debugPrint(request.logLine);
47-
runApp(WrenApp(home: sceneFor(request.name) ?? UnknownScene.from(request)));
48+
// Advert beats first: they are scenes with a script played over them, and
49+
// their names are prefixed so they cannot collide with a plain scene.
50+
runApp(
51+
WrenApp(
52+
home:
53+
advertFor(request.name) ??
54+
sceneFor(request.name) ??
55+
UnknownScene.from(request),
56+
),
57+
);
4858
return;
4959
}
5060
runApp(const WrenApp());

0 commit comments

Comments
 (0)