Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions .claude/skills/screenshots/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ description: >-
(Tools/ipad-shots.rb, Tools/macos-shots.rb, Tools/visionos-shots.rb), the
flatten-and-optimise pass
(Tools/screenshots.rb), what a sendable capture has to be, and the traps that
make a screenshot tool fail silently. Load this before reshooting, before
adding a shot or a platform, and whenever a capture looks wrong.
make a screenshot tool fail silently. Also the website's teaser video
(Tools/teaser/). Load this before reshooting, before adding a shot or a
platform, and whenever a capture looks wrong.
---

# Screenshots
Expand Down Expand Up @@ -258,6 +259,20 @@ Seven ways the Mac differs from the iPad, all handled but all worth knowing:
what makes 1280×800pt — 2560×1600px — reproducible. Keep that `defaultSize`:
a capture at any other size would need cropping or resampling.

## The teaser video

The website's two-minute video is made the same way: `ruby Tools/teaser/teaser.rb`
records `TortoiseBlocksUITests/TeaserTests.swift` on the 11-inch iPad simulator
and cuts it into a silent 1080p film; music goes on by hand afterwards. The
test is the script, and it logs every press, caption and camera move for the
editor to work from. `Tools/teaser/README.md` has why each piece is as it is.
It shares DerivedData with the rigs above, so it counts as one of them: one at
a time.

Judge it the way the stills are judged, but on the film: a contact sheet
shows what is in it (`ffmpeg -i teaser.mp4 -vf fps=1/3,scale=320:-1,tile=6x9`),
and only playing it shows whether it moves well.

## Judging the result

Look at the pictures. `metadata_check` proves a capture is *sendable*, not that
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ xcrun simctl launch <device> space.hiraku.tortoiseblocks \
xcrun simctl io <device> screenshot shot.png # 3840x2160, with an alpha channel
xcrun simctl io <device> recordVideo walk.mov # Ctrl-C to stop

# The website's teaser video: records TeaserTests on the iPad simulator, then
# cuts it to a silent 1080p film (Tools/teaser/README.md). ~15 minutes.
ruby Tools/teaser/teaser.rb # --compose re-cuts the last recording

# The App Store listing (appstore/). The check needs no key and no bundle;
# the other two need ASC_ISSUER_ID / ASC_KEY_ID / ASC_PRIVATE_KEY_PATH.
ruby Tools/screenshots.rb # after ANY reshoot: strip alpha, optimise, rebuild site/shots and docs/
Expand Down
107 changes: 107 additions & 0 deletions Tools/teaser/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# The teaser video

The website's video — a program built by trial and error, from one line to a
spiral of stars — is made here, not filmed by hand.

```bash
ruby Tools/teaser/teaser.rb # record on the iPad simulator, then compose
ruby Tools/teaser/teaser.rb --compose # compose the last recording again
```

The film comes out silent, 1920×1080 at 30fps, about two minutes, in the
work directory the script prints (`$TMPDIR/tortoise-teaser/teaser.mp4`), with
the raw recording beside it. Music is added afterwards, by hand.

Three pieces:

- **`TortoiseBlocksUITests/TeaserTests.swift` is the script.** Every press,
when each caption comes up, and where the camera looks, in the order they
happen. Changing the story is editing this file. The test writes a log of
what it did and when (`events.jsonl`); it does not decide anything about the
film.
- **`teaser.rb` is the crew and the editor.** It records the simulator while
the test runs, then cuts, frames, zooms, captions and draws the touches from
the log.
- **`text.swift`** renders captions and titles in SF Pro Rounded, because
ImageMagick cannot ask the system's variable font for a weight.

It needs Xcode, `ffmpeg` and ImageMagick (`magick`). A full run takes about a
quarter of an hour, nearly all of it the recording; composing again takes two
minutes. Don't run it while a screenshot rig is
running: they share DerivedData (see the `screenshots` skill).

## Things that look like mistakes and are not

**The shoot is on the 11-inch iPad**, not the 13-inch one the App Store
captures use. It has the same layout in fewer points, so every block is about
a quarter larger in a 1080p frame. At 13 inches the palette's text came out a
few pixels tall.

**Most of the recording is thrown away.** A UI test is slow: every press
waits for the app to go idle, finding an element takes a snapshot, and a
typed digit takes seconds. The raw run is almost four minutes. The simulator
writes a frame only when the screen changes, so the gaps between frames are
exactly the still stretches. Each one is cut down to `HEAD` seconds, except
where the log asks for time: a caption to read, a camera move to finish, the
moment around a touch, and `linger` (a finished drawing, the code at the end).
The test's own `pause`s therefore don't set the pace. They only give the app
time to settle. Two refinements: while a number is being typed the field's
caret blinks, and every blink is a frame, so the test logs the typing span and
only the keys inside it are kept. And a drawing longer than `LONG_DRAWING`
plays at `FAST`×: the spiral takes nine seconds at the app's own tempo.

**A drop that did not land is cut out.** A drag in the simulator now and then
parts the rows and inserts nothing. The test checks that the block arrived,
tries again if not, and logs the failed take as a `cut`, which the film leaves
out.

**Touches are drawn afterwards.** The simulator's recording shows no finger,
and the log already knows where each press went. A tap is logged just after it
returns, and the screen reacts 0.1–0.26s before that (measured against the
first changed frame), so the finger is drawn landing `TAP_LEAD` earlier. A drag
is worked back from when it returned: its hold, its travel at the velocity it
was given, and its linger.

**The camera is part of the log, not the edit.** `camera(.blocks)` in the test
records a rectangle in screen points. The film eases to it, zooming so that
the rectangle fills the frame. The zoom is `zoompan` on the full-resolution
source, so the text stays sharp at 2×.

**Presses are coordinates, so nothing scrolls for them.** `XCUIElement.tap()`
scrolls its element into view first; a coordinate tap below the fold presses
whatever is there. On the 11-inch iPad that includes Repeat, and the first run
put a Start Fill where the repeat should have been. `palette(_:)` scrolls the
palette with a finger until the entry is on screen. Coordinates are used anyway
because the log needs the point that was pressed.

**The palette entry is the leftmost button with that label.** "Forward" is
also the transport's step button. It is a separate element with the same name,
and a query that walks its matches one by one lost one between counting and
fetching.

**A hardware keyboard is attached for the run**, by switching the Simulator's
`ConnectHardwareKeyboard` preference on and restarting the device to pick it
up. Xcode 27 has no Simulator.app to attach one by hand. The
preferences are exported first and imported back when the run ends. Without
it, a number field raises the full on-screen keyboard over half the screen. The maintainer's own recording
used a pointer and a keyboard, so none shows there either. The value is typed
after a tap in the middle of the field, which puts the caret at the end, so
the old digits go by backspace. `⌘A` is not used: selecting brought iPadOS
27's small number keypad up over the field, and sometimes the full keyboard
under it.

**"Drawing finished" means the scrubber settled after it moved or had time
to.** It does not require a new end value: changing an angle leaves the step
count alone, so the run ends on exactly the value the previous one did, and
waiting for a different value waited forever. It does not require seeing the
scrubber move either: a press returns only once the app is idle, which once
took nine seconds, and an eight-step triangle is drawn in 0.8.

**The recording is sideways and variable-rate.** `simctl io recordVideo`
writes the framebuffer as it is held (portrait, the landscape app on its side)
and only when something changes. `transpose=2` stands it up, and `fps=30`
fills the still stretches before anything is cut.

**The system language is English for the run** as well as the app, because
the status bar writes its date in the system's language. It is put back
afterwards, as `ipad-shots.rb` does.
Loading
Loading