API, Videos: recording options, kept originals and the 4K cap

The editor recording now opens a dialog, and the server keeps what was
uploaded next to the MP4.

- Videos describes Start recording... with its options, Convert and
  Import after conversion, and that Stop always uploads.
- Download gives the original, a file left unconverted shows in the
  picker with a Convert button, and the cap is 3840x2160.
- API names convert=0 on upload and POST videos/{file}/convert.
- Development names 2045 e2e checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
adminandClaude Opus 5.5 committed 2026-10-12 17:02:05 +02:00
1 parent 0598e2d27d
commit 34ae82ef8a
3 files changed
+58 -22

No files matched your search

+6 -4
@@ -28,9 +28,10 @@ All routes go through `api/index.php?r=…` (the site's nginx does not pass `PAT
| GET | `sounds` | `[{file,name,bytes,updatedAt,owner,deletable}]` — what the sound picker offers |
| POST | `sounds&name=jump.wav` | **raw sound bytes** → `{file,name,bytes}` (201); **422** for bytes that are no sound |
| DELETE | `sounds/{file}` | 204, **403**, **409** like an image |
| GET | `videos` | `[{file,name,status,…,bytes,updatedAt,owner,deletable}]` — what the video picker offers. `status` is `queued`, `converting` (with `progress`, 0 to 1), `failed` (with `error`) or `ready` (with `width`, `height`, `duration`, `fps`, `audio` and `poster`, the `.jpg` beside the MP4) |
| POST | `videos&name=holiday.mov` | **raw video bytes** → the same record (201). The name `{folder}/{sha256}.mp4` is the hash of the upload and known at once; the worker converts it later (see [Videos](Videos)). The same bytes again answer with what is there, a failed one is tried again. **422** for bytes that cannot be a video (the start of an ISO/MP4, Matroska, AVI or MPEG-TS file), **413** above 2 GB. Unlike every other upload it is streamed to disk, so the 256 MB ceiling of the other routes does not apply |
| DELETE | `videos/{file}` | 204, **403**, **409** like an image; also for one that waits or failed, which drops the upload |
| GET | `videos` | `[{file,name,status,…,original,bytes,updatedAt,owner,deletable}]` — what the video picker offers. `status` is `queued`, `converting` (with `progress`, 0 to 1), `failed` (with `error`), `unconverted` (kept without converting it) or `ready` (with `width`, `height`, `duration`, `fps`, `audio` and `poster`, the `.jpg` beside the MP4). `original` is the upload as it came, `{folder}/{sha256}.source.{ext}` beside the MP4, null for a video uploaded before uploads were kept |
| POST | `videos&name=holiday.mov[&convert=0]` | **raw video bytes** → the same record (201). The name `{folder}/{sha256}.mp4` is the hash of the upload and known at once; the upload is kept as it came, and the worker converts it later (see [Videos](Videos)). With `convert=0` it is only kept, `unconverted`. The same bytes again answer with what is there, a failed one is tried again. **422** for bytes that cannot be a video (the start of an ISO/MP4, Matroska, AVI or MPEG-TS file), **413** above 2 GB. Unlike every other upload it is streamed to disk, so the 256 MB ceiling of the other routes does not apply |
| POST | `videos/{file}/convert` | → the record: an `unconverted` upload is queued, a failed one is tried again, anything else answers as it is. **403** for somebody else's, **404** for no such video |
| DELETE | `videos/{file}` | 204, **403**, **409** like an image; also for one that waits, failed or is only kept. The upload as it came goes with it |
| POST | `pixels` | **raw cel bytes** → `{file,bytes}` (201) — the pixels of one layer on one frame of [pixel art](Pixel-Art); **422** for anything that is not a cel. No listing and no delete: a cel is found by the name in the project |
| GET | `plugins` | `[{file,bytes,updatedAt}]` — every `.js` in `data/plugins/`, sorted by file name |
| GET | `plugins/{file}` | the module source as `text/javascript` (`nosniff`, `no-cache`); 404 for anything but a bare file name there — see [Plugins](Plugins) |
@@ -126,7 +127,8 @@ pickers show; an upload that carries no `name` leaves the known one alone rather
A file uploaded before names were kept simply has `""`, and reads as it always did.
**What the web server hands out as files** is only images, fonts, sounds, videos (with their
posters and details) and cels. A video waiting in `data/videos/queue/` is hidden like a project. Everything else
posters, details and the uploads as they came) and cels. The jobs in `data/videos/queue/` are
hidden like a project. Everything else
under `data/` (projects, snapshots, releases, accounts, sessions) is written readable for PHP alone
(`0640`, `AtomicFile::HIDDEN`), so nginx answers **403** for it and the sign-in cannot be walked
around. Every file the API writes goes through `AtomicFile`: a temp file beside it, its mode, then a
+1 -1
@@ -21,7 +21,7 @@ php tests/php/releases.test.php # the release stor
php tests/php/files.test.php # uploaded files in a folder per account (#344), the same way
php tests/php/videos.test.php # the video store, its queue and the worker (#402), with the
# real ffmpeg: the CI image and game-maker-video have it
node tools/seed-e2e.mjs && node tools/e2e.mjs # 2043 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 2045 editor checks, screenshots in /tmp/gm-e2e-*.png
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 — a subset while working on one step
./e2e.sh start [GM_STEPS] # the same run detached, see below
./e2e.sh status # live progress, or the last run's result
+51 -17
@@ -11,18 +11,26 @@ the name it was uploaded under and what it is ("1920x1080 · 12.5 s · 29.97 fps
`.avi`, told apart by the extension).
**An upload is converted first.** The server turns every video into one format, whatever came in:
MP4 with H.264 and AAC, `yuv420p`, at a constant frame rate, at most 1920x1080 (1080x1920
MP4 with H.264 and AAC, `yuv420p`, at a constant frame rate, at most 3840x2160 (2160x3840
standing up, never made larger), with a keyframe every 15 frames, so jumping to a clip start is
quick. A phone video recorded sideways comes out standing up. The picker shows a video on its way
quick. Until 2026-10-12 the most was 1920x1080; a video converted before stays as it came out.
A 4K video is heavy in a game: every frame is four times the pixels of 1080p on their way to the GPU. A phone video recorded sideways comes out standing up. The picker shows a video on its way
as "Waiting to be converted…" or "Converting… 42 %", asks the server again every second, and
makes the asset by itself once a video uploaded from it is ready. One that cannot be converted
says why ("Could not be converted: …"); **Delete** takes it away, uploading it again tries again.
Uploading the same file twice converts it once.
**The upload is kept as it came.** **Download** in the picker and in the Video tab saves that
file, not the MP4, under the name it was uploaded with. A video uploaded before 2026-10-12 has
none kept, and the Video tab saves its MP4. An editor recording can be uploaded **without
converting it** ("Not converted – Convert makes it usable"): it can be downloaded, but no project
can wear it until **Convert** in the picker sends it to the worker.
**Where it goes.** The upload is stored under the hash of its bytes in your folder, like every
other file (see [API](API)): `data/videos/{folder}/{sha256}.mp4`, beside it the poster
`{sha256}.jpg` and the details `{sha256}.meta.json`. The name is known the moment the upload is
in, long before the MP4 exists. A video can be up to 2 GB. It goes from the request straight to
`{sha256}.jpg`, the details `{sha256}.meta.json` and the upload `{sha256}.source.{ext}` (the ending
of its name, else the one its first bytes tell). Every video so takes about twice the room it
did. The name is known the moment the upload is in, long before the MP4 exists. A video can be up to 2 GB. It goes from the request straight to
disk, so the API holds none of it in memory: 300 MB went up with php-fpm at 14 MB.
## The asset
@@ -59,7 +67,8 @@ The clip gestures are the same as in the [Sound tab](Sounds), with the track in
[Sounds](Sounds#master-routing)). So a recording of the editor has it.
- The wheel zooms the time, the middle button pans, **Fit** shows the whole video. A wheel step
and Fit are shown as the short zoom transition of the other tabs (see [Concepts](Concepts)).
- **Download** saves the MP4 the server made, under the asset's name.
- **Download** saves the video as it was uploaded, under the asset's name: a recording comes out
as the `.mkv` the editor made. One uploaded before uploads were kept saves its MP4.
A clip's **Start (s)** and **End (s)** are in the inspector as well; an end of 0 is the end of
the video. A clip is a frame long at least, its edges are stored to the millisecond.
@@ -210,15 +219,34 @@ below shares.
## Recording the editor
**Start recording** in the main menu (top left) records the whole editor, for a tutorial or a bug
**Start recording…** in the main menu (top left) records the whole editor, for a tutorial or a bug
report. While it records, the entry reads **Stop recording**, and a red dot with the time stands
beside the menu button.
- **No options:** the name is `Recording <date> <time>`, the size is the editor's canvas in device
pixels (an odd row or column is cut off), 60 fps. The sound is the master, as for the game view.
- **The mouse pointer is drawn in.** The system draws it over the page, so it is not in the canvas;
the recording draws its own in the shape the editor shows (arrow, hand, resize arrows, I-beam,
crosshair).
The dialog it opens starts on the last choice made in this browser (the name excepted):
| Option | |
|---|---|
| **Name** | empty: `Recording <date> <time>` |
| **Resolution** | *Screen*: the canvas in device pixels. *1080p*, *1440p*, *4K*: fitted into 1920x1080, 2560x1440 or 3840x2160 with the window's shape. The line under it says the size this window comes out at |
| **FPS** | 60 or 30 |
| **Mouse pointer** | drawn in or not |
| **Sound** | the master, as for the game view, or none |
| **Convert** | the server makes an MP4 of it, which a project can wear. Off: it is only kept |
| **Import after conversion** | the MP4 becomes an asset of the project once it is ready. Grey while Convert is off |
The recording is always uploaded; **Download** in the video picker saves it as it was recorded.
- **A larger frame is drawn for, not scaled up to.** For 4K the editor draws finer while it
records (from a 1920x1080 window twice as fine), so text and lines stay sharp; the screen shows
the same as before. A window too small for its frame even at four times as fine is scaled up the
rest of the way. A smaller frame keeps the screen's sharpness and is scaled down. Exactly
3840x2160 needs a window of that shape: open the editor with `?w=1920&h=1080`.
- **Cost:** 4K at 60 fps is about 40 Mbit/s, so the 2 GB a file may have last about 7 minutes. The
encoder is whatever Chrome has; frames it cannot keep up with are dropped. 30 fps halves both.
- **The mouse pointer** is drawn in when asked: the system draws it over the page, so it is not in
the canvas; the recording draws its own in the shape the editor shows (arrow, hand, resize
arrows, I-beam, crosshair).
- **The red dot is not in it.** It is an HTML element over the canvas, the one thing on the page
outside it, so the copy of the canvas never holds it; the room it needs beside the menu stays
empty in the recording. Clicks go through it.
@@ -229,16 +257,21 @@ beside the menu button.
- **A resized window** is fitted into the size the recording started with, centred, the rest dark.
- **A size of its own:** open the editor with `?w=1920&h=1080` and its canvas is laid out at that
size, whatever the window. A smaller window shows it shrunk to fit, a larger one centred at full
size. The recording is that size times the screen's scaling (up to 2): at 100 % exactly 1920*1080.
size. With *Screen* the recording is that size times the screen's scaling (up to 2): at 100 %
exactly 1920*1080.
- It runs on through Play and Stop, beside a recording of the game view if a game starts one.
Saving is the same: up to the server, then a video asset once the editor is not playing.
The code is `src/editor/EditorRecorder.ts`, the pointer shapes are `src/core/recording/cursors.ts`.
The code is `src/editor/EditorRecorder.ts` with its dialog `src/editor/EditorRecordingDialog.ts`;
the sizes and the options are plain functions in `src/core/recording/editorRecording.ts`, the
pointer shapes are `src/core/recording/cursors.ts`.
## The worker
Converting is a job, not a request. The API puts the upload into `data/videos/queue/` (hidden
from the web server), and a worker takes one after the other, oldest first:
Converting is a job, not a request. The API keeps the upload beside where the MP4 will be and puts
a job for it into `data/videos/queue/` (hidden from the web server), and a worker takes one after
the other, oldest first. A failed job leaves the upload where it is, so it can still be downloaded.
A job from before uploads were kept has its bytes hidden in the queue, and they go once it is done:
- On stein2 that is the container **`game-maker-video`**: PHP and ffmpeg on Debian 12, the
checkout mounted, running as `www-data` without a network, on at most 3 of the 4 cores. It is
@@ -250,5 +283,6 @@ from the web server), and a worker takes one after the other, oldest first:
The worker is `tools/video-worker.php` over `api/lib/VideoWorker.php`; the rules (size, frame
rate, the ffmpeg arguments, the progress) are plain functions in `api/lib/VideoFormat.php`. A job
cut off by a stopped worker is started again when it comes back. Measured on stein2: 5 s of
2560x1440 at 60 fps came out as 1080p60 in 5.0 s.
cut off by a stopped worker is started again when it comes back. Measured on stein2, still with
the 1080p ceiling: 5 s of 2560x1440 at 60 fps came out as 1080p60 in 5.0 s. A 4K video takes
longer; that is not measured.