diff --git a/README.md b/README.md index f36a9086..fd657ffd 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2043 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2045 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/api/index.php b/api/index.php index bb102a77..eac1729e 100644 --- a/api/index.php +++ b/api/index.php @@ -63,12 +63,16 @@ declare(strict_types=1); * POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes} * DELETE ?r=sounds/{file} → 204 | 403 | 404 | 409 * - * GET ?r=videos → [{file,name,status,…,owner,deletable}] - * POST ?r=videos&name=x.mov raw video bytes → {file,name,status,…} + * GET ?r=videos → [{file,name,status,…,original,owner,deletable}] + * POST ?r=videos&name=x.mov[&convert=0] raw video bytes → {file,name,status,…} + * POST ?r=videos/{file}/convert → {file,name,status,…} | 403 | 404 * DELETE ?r=videos/{file} → 204 | 403 | 404 | 409 * (an upload is converted by the worker in `game-maker-video` (#402): * `status` is queued, converting with `progress`, failed with `error`, - * or ready with width, height, duration, fps, audio and `poster`) + * or ready with width, height, duration, fps, audio and `poster`; + * with `convert=0` it is only kept, `unconverted`, until it is asked + * to be. `original` is the upload as it came, null for a video + * uploaded before uploads were kept) * * Uploads land in the uploader's own folder, and `file` carries it: `{folder}/{sha256}.{ext}` * (#344). A `file` without a folder belongs to nobody. A list shows the files of the one asking and @@ -538,7 +542,8 @@ function routeFiles( // A video is too big for memory and goes from the request straight to disk. if ($store instanceof VideoStore) { $in = fopen('php://input', 'rb'); - Response::json($store->storeStream($in, $maxBytes, $name, $users->folderOf($user, true)), 201); + $convert = ($_GET['convert'] ?? '1') !== '0'; + Response::json($store->storeStream($in, $maxBytes, $name, $users->folderOf($user, true), $convert), 201); } $bytes = Response::rawBody(); if (strlen($bytes) > $maxBytes) { @@ -557,6 +562,20 @@ function routeFiles( Response::error('Method not allowed', 405); } + // A video kept without being converted is converted later, at its owner's word. + if ($store instanceof VideoStore && $method === 'POST' && end($parts) === 'convert') { + $file = implode('/', array_slice($parts, 1, -1)); + $folder = HashedFileStore::folderOf($file); + if (!$isAdmin && ($folder === '' || $folder !== $users->folderOf($user))) { + Response::error('Only its owner may convert this video', 403); + } + $record = $store->convert($file); + if ($record === null) { + Response::error("{$noun} not found", 404); + } + Response::json($record); + } + if ($method === 'DELETE') { $file = implode('/', array_slice($parts, 1)); // Asked before whether there is one: somebody else's folder tells a stranger nothing. @@ -637,10 +656,12 @@ try { } // A project reaches deepest, `projects/{id}/snapshots/{snapshotId}/restore`; a stored file is - // `textures/{folder}/{file}`; everything else stops at two parts. + // `textures/{folder}/{file}`, a video to convert `videos/{folder}/{file}/convert`; everything + // else stops at two parts. $depth = match ($parts[0] ?? '') { 'projects' => 5, - 'textures', 'fonts', 'sounds', 'videos' => 3, + 'videos' => 4, + 'textures', 'fonts', 'sounds' => 3, default => 2, }; if (count($parts) < 1 || count($parts) > $depth) { diff --git a/api/lib/VideoFormat.php b/api/lib/VideoFormat.php index c1e57883..74c29442 100644 --- a/api/lib/VideoFormat.php +++ b/api/lib/VideoFormat.php @@ -7,13 +7,13 @@ declare(strict_types=1); * * The target is MP4 with H.264 and AAC, `yuv420p`, at a constant frame rate and with a keyframe * every 15 frames: the editor seeks to clip starts and loops back to them, and a seek can only land - * on a keyframe quickly. The picture fits into 1920 × 1080 (1080 × 1920 standing up) and is never - * made larger than it came. + * on a keyframe quickly. The picture fits into 3840 × 2160 (2160 × 3840 standing up), so a 4K + * recording of the editor stays 4K, and is never made larger than it came. */ final class VideoFormat { - public const MAX_LONG_SIDE = 1920; - public const MAX_SHORT_SIDE = 1080; + public const MAX_LONG_SIDE = 3840; + public const MAX_SHORT_SIDE = 2160; public const MAX_FPS = 60; public const KEYFRAME_EVERY = 15; /** Used when a file does not say its frame rate at all. */ diff --git a/api/lib/VideoQueue.php b/api/lib/VideoQueue.php index 4ba76658..366226d7 100644 --- a/api/lib/VideoQueue.php +++ b/api/lib/VideoQueue.php @@ -2,15 +2,17 @@ declare(strict_types=1); /** - * The videos waiting to be converted (#402): `data/videos/queue/{folder}-{hash}.src` holds the - * uploaded bytes, `{folder}-{hash}.json` what became of them. The API adds to it, the worker in the + * The videos waiting to be converted (#402): `data/videos/queue/{folder}-{hash}.json` says what + * became of an upload, and its `source` where the upload lies – kept as it came beside the MP4, + * `{folder}/{hash}.source.{ext}`, for downloading it. The API adds to it, the worker in the * container `game-maker-video` takes from it, one at a time, oldest first. * * A job is `queued`, `converting` (with `progress` from 0 to 1) or `failed` (with `error`). A done * one is no job any more: its MP4 lies in the account's folder and the job is gone. A failed one * stays, so the picker can say why, until the file is deleted or uploaded again. * - * Both files are hidden from the web server: the upload is not what anybody should be served. + * A job from before the uploads were kept has no `source`: its bytes wait hidden in the queue as + * `{folder}-{hash}.src` and go once they are converted. */ final class VideoQueue { @@ -27,7 +29,7 @@ final class VideoQueue /** * Copies an upload into a temp file beside the jobs, never more than `$maxBytes` of it: a video - * is too big to be held in memory. The caller hands the file to add() or deletes it. + * is too big to be held in memory. The caller keeps the file or deletes it. * * @param resource $in * @throws VideoTooLargeException when the stream has more than `$maxBytes` @@ -48,15 +50,16 @@ final class VideoQueue return $tmp; } - /** Queues the upload received into `$upload`, which is moved and gone afterwards. */ - public function add(string $folder, string $hash, string $upload, string $name): void + /** + * Queues the upload kept at `$source`, a path inside the videos' directory such as + * `{folder}/{hash}.source.mkv`. The upload stays where it is. + */ + public function add(string $folder, string $hash, string $source, string $name): void { - if (!chmod($upload, AtomicFile::HIDDEN) || !rename($upload, $this->source($folder, $hash))) { - throw new RuntimeException('Cannot write uploaded video'); - } $this->write($folder, $hash, [ 'folder' => $folder, 'hash' => $hash, + 'source' => $source, 'name' => $name, 'state' => self::QUEUED, 'progress' => 0, @@ -134,10 +137,11 @@ final class VideoQueue return $count; } + /** Drops the job, and an old one's hidden upload; a kept upload is the store's to delete. */ public function remove(string $folder, string $hash): bool { $removed = false; - foreach ([$this->source($folder, $hash), $this->jobPath($folder, $hash)] as $path) { + foreach ([$this->hiddenUpload($folder, $hash), $this->jobPath($folder, $hash)] as $path) { if (is_file($path)) { $removed = unlink($path) || $removed; } @@ -145,16 +149,27 @@ final class VideoQueue return $removed; } - /** Drops the upload once it is converted or cannot be; the job itself says what happened. */ + /** + * Drops an old job's hidden upload once it cannot be converted; the job itself says why. A kept + * upload stays: it can still be downloaded as it came. + */ public function dropSource(string $folder, string $hash): void { - $path = $this->source($folder, $hash); + $path = $this->hiddenUpload($folder, $hash); if (is_file($path)) { unlink($path); } } + /** Where the bytes of a job lie, for ffmpeg to read. */ public function source(string $folder, string $hash): string + { + $source = (string) ($this->job($folder, $hash)['source'] ?? ''); + $kept = '#^' . UserStore::FOLDER_PATTERN . '/[a-f0-9]{64}\.source\.[a-z0-9]+$#'; + return preg_match($kept, $source) ? dirname($this->dir) . "/{$source}" : $this->hiddenUpload($folder, $hash); + } + + private function hiddenUpload(string $folder, string $hash): string { return "{$this->dir}/{$folder}-{$hash}.src"; } diff --git a/api/lib/VideoStore.php b/api/lib/VideoStore.php index 9039228c..5bf02191 100644 --- a/api/lib/VideoStore.php +++ b/api/lib/VideoStore.php @@ -2,22 +2,31 @@ declare(strict_types=1); /** - * Videos (#402): `data/videos/{folder}/{hash}.mp4`, beside it `{hash}.jpg` (the poster) and - * `{hash}.meta.json` (size, length, frame rate, sound), all served as they are. + * Videos (#402): `data/videos/{folder}/{hash}.mp4`, beside it `{hash}.jpg` (the poster), + * `{hash}.meta.json` (size, length, frame rate, sound) and `{hash}.source.{ext}`, the upload as it + * came, all served as they are. * - * Unlike an image or a sound a video is not kept as it came: the upload goes into the VideoQueue, - * and the worker writes the MP4 when it is done (see VideoFormat). The hash is the one of the - * uploaded bytes, so the name a project stores is known the moment the upload is in, long before - * the file exists, and uploading the same file twice converts it once. + * What a project plays is the MP4 the worker makes of the upload (see VideoFormat), which waits in + * the VideoQueue until then. The upload itself is kept for downloading it, and one sent up without + * being converted stays only that until it is asked to be. The hash is the one of the uploaded + * bytes, so the name a project stores is known the moment the upload is in, long before the file + * exists, and uploading the same file twice converts it once. Videos uploaded before the uploads + * were kept have none. */ final class VideoStore extends HashedFileStore { + /** An upload kept, waiting for nobody to convert it. */ + public const UNCONVERTED = 'unconverted'; + /** * What an upload has to start with to be sent to ffmpeg at all: the ISO family (MP4, MOV, M4V, * 3GP), Matroska (MKV, WebM), AVI and MPEG transport streams. ffmpeg has the last word. */ private const SIGNATURE_HINT = 'MP4, MOV, WebM, MKV or AVI'; + /** The endings a kept upload may have, taken from its name; anything else is told by its bytes. */ + private const SOURCE_EXTENSIONS = ['mp4', 'mov', 'm4v', '3gp', 'mkv', 'webm', 'avi', 'ts', 'mts', 'm2ts']; + private readonly VideoQueue $queue; public function __construct(private readonly string $root) @@ -38,18 +47,18 @@ final class VideoStore extends HashedFileStore /** * Takes an upload: a file already converted answers as it is, anything else waits in the - * queue, a failed one is tried again. + * queue, a failed one is tried again. Without `$convert` it is only kept. * * @return array * @throws InvalidVideoException when the bytes cannot be a video */ - public function store(string $bytes, string $originalName, string $folder): array + public function store(string $bytes, string $originalName, string $folder, bool $convert = true): array { $in = fopen('php://memory', 'r+b'); fwrite($in, $bytes); rewind($in); try { - return $this->storeStream($in, strlen($bytes), $originalName, $folder); + return $this->storeStream($in, strlen($bytes), $originalName, $folder, $convert); } finally { fclose($in); } @@ -64,11 +73,12 @@ final class VideoStore extends HashedFileStore * @throws VideoTooLargeException when the stream has more than `$maxBytes` * @throws InvalidVideoException when the bytes cannot be a video */ - public function storeStream($in, int $maxBytes, string $originalName, string $folder): array + public function storeStream($in, int $maxBytes, string $originalName, string $folder, bool $convert = true): array { $upload = $this->queue->receive($in, $maxBytes); try { - if (!self::looksLikeVideo((string) file_get_contents($upload, false, null, 0, 256))) { + $head = (string) file_get_contents($upload, false, null, 0, 256); + if (!self::looksLikeVideo($head)) { throw new InvalidVideoException('Not a video file – use ' . self::SIGNATURE_HINT); } if (!preg_match('/^' . UserStore::FOLDER_PATTERN . '$/', $folder)) { @@ -80,12 +90,15 @@ final class VideoStore extends HashedFileStore throw new RuntimeException("Cannot create directory {$dir}"); } OriginalName::write($dir, "{$hash}.mp4", $originalName); - if (!is_file("{$dir}/{$hash}.mp4")) { - $job = $this->queue->job($folder, $hash); - if ($job === null || $job['state'] === VideoQueue::FAILED) { - $this->queue->add($folder, $hash, $upload, OriginalName::sanitize($originalName)); + if ($this->sourceOf($folder, $hash) === null) { + $source = "{$dir}/{$hash}.source." . self::sourceExtension($originalName, $head); + if (!chmod($upload, AtomicFile::SERVED) || !rename($upload, $source)) { + throw new RuntimeException('Cannot write uploaded video'); } } + if ($convert) { + $this->enqueue($folder, $hash); + } return $this->record("{$folder}/{$hash}.mp4"); } finally { if (is_file($upload)) { @@ -94,6 +107,35 @@ final class VideoStore extends HashedFileStore } } + /** + * Converts an upload that was kept without: it waits in the queue like any other, a failed one + * is tried again. Null when there is no such video. + * + * @return array|null + */ + public function convert(string $file): ?array + { + $folder = self::isValidName($file) ? self::folderOf($file) : ''; + if ($folder === '') { + return null; + } + $this->enqueue($folder, basename($file, '.mp4')); + return $this->record($file); + } + + /** Queues a kept upload, unless it is converted or on its way already. */ + private function enqueue(string $folder, string $hash): void + { + $source = $this->sourceOf($folder, $hash); + if ($source === null || is_file("{$this->root}/{$folder}/{$hash}.mp4")) { + return; + } + $job = $this->queue->job($folder, $hash); + if ($job === null || $job['state'] === VideoQueue::FAILED) { + $this->queue->add($folder, $hash, $source, OriginalName::read("{$this->root}/{$folder}", "{$hash}.mp4")); + } + } + /** * One file as the picker shows it, done or not; null when there is neither file nor job. * @@ -115,16 +157,23 @@ final class VideoStore extends HashedFileStore ]; } $job = $this->jobOf($file); - return $job === null ? null : $this->pending($job); + if ($job !== null) { + return $this->pending($job); + } + $folder = self::folderOf($file); + return $folder === '' ? null : $this->unconverted($folder, basename($file, '.mp4')); } - /** A video is there for deleting while it is converted or failed, too. */ + /** A video is there for deleting while it is converted, failed or only kept, too. */ public function exists(string $file): bool { - return parent::exists($file) || $this->jobOf($file) !== null; + return $this->record($file) !== null; } - /** Lists the converted files like every store does, and the ones on their way after them. */ + /** + * Lists the converted files like every store does, then the ones on their way, then the ones + * kept without being converted. + */ public function list(string $folder, bool $seesAll, array $owners): array { $items = parent::list($folder, $seesAll, $owners); @@ -137,6 +186,20 @@ final class VideoStore extends HashedFileStore ]; } } + foreach (glob("{$this->root}/*/*.source.*") ?: [] as $path) { + $owner = basename(dirname($path)); + if (!preg_match('/^([a-f0-9]{64})\.source\.[a-z0-9]+$/', basename($path), $m) || (!$seesAll && $owner !== $folder)) { + continue; + } + $record = $this->unconverted($owner, $m[1]); + if ($record !== null) { + $items[] = $record + [ + 'updatedAt' => gmdate('c', (int) filemtime($path)), + 'owner' => $owners[$owner] ?? '', + 'deletable' => true, + ]; + } + } return $items; } @@ -148,6 +211,10 @@ final class VideoStore extends HashedFileStore $folder = self::folderOf($file); $hash = basename($file, '.mp4'); $removed = $folder !== '' && $this->queue->remove($folder, $hash); + $source = $folder === '' ? null : $this->sourceOf($folder, $hash); + if ($source !== null) { + $removed = unlink("{$this->root}/{$source}") || $removed; + } if (is_file("{$this->root}/{$file}")) { $removed = parent::delete($file) || $removed; $dir = $folder === '' ? $this->root : "{$this->root}/{$folder}"; @@ -177,6 +244,7 @@ final class VideoStore extends HashedFileStore $details = $raw === false ? null : json_decode($raw, true); $details = is_array($details) ? $details : []; $file = substr($path, strlen($this->root) + 1); + $folder = self::folderOf($file); return [ 'status' => 'ready', 'width' => (int) ($details['width'] ?? 0), @@ -185,9 +253,65 @@ final class VideoStore extends HashedFileStore 'fps' => (float) ($details['fps'] ?? 0), 'audio' => (bool) ($details['audio'] ?? false), 'poster' => substr($file, 0, -strlen('.mp4')) . '.jpg', + 'original' => $folder === '' ? null : $this->sourceOf($folder, basename($file, '.mp4')), ]; } + /** + * Where the upload of a video is kept, `{folder}/{hash}.source.{ext}` as it is served; null for + * one uploaded before uploads were kept. + */ + private function sourceOf(string $folder, string $hash): ?string + { + foreach (glob("{$this->root}/{$folder}/{$hash}.source.*") ?: [] as $path) { + if (preg_match('/^[a-f0-9]{64}\.source\.[a-z0-9]+$/', basename($path))) { + return "{$folder}/" . basename($path); + } + } + return null; + } + + /** + * A kept upload that is neither converted nor on its way, as the picker shows it; null when + * the video is either, or there is no upload. + * + * @return array|null + */ + private function unconverted(string $folder, string $hash): ?array + { + $source = $this->sourceOf($folder, $hash); + if ($source === null || is_file("{$this->root}/{$folder}/{$hash}.mp4") || $this->queue->job($folder, $hash) !== null) { + return null; + } + return [ + 'file' => "{$folder}/{$hash}.mp4", + 'name' => OriginalName::read("{$this->root}/{$folder}", "{$hash}.mp4"), + 'status' => self::UNCONVERTED, + 'progress' => 0, + 'error' => '', + 'bytes' => (int) filesize("{$this->root}/{$source}"), + 'original' => $source, + ]; + } + + /** + * The ending a kept upload is served with: the one of its name when that is a video's, else the + * one its first bytes tell. + */ + private static function sourceExtension(string $name, string $head): string + { + $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); + if (in_array($extension, self::SOURCE_EXTENSIONS, true)) { + return $extension; + } + return match (true) { + substr($head, 4, 4) === 'ftyp' => substr($head, 8, 4) === 'qt ' ? 'mov' : 'mp4', + str_starts_with($head, "\x1A\x45\xDF\xA3") => str_contains($head, 'webm') ? 'webm' : 'mkv', + str_starts_with($head, 'RIFF') => 'avi', + default => 'ts', + }; + } + /** @return array|null */ private function jobOf(string $file): ?array { @@ -208,6 +332,7 @@ final class VideoStore extends HashedFileStore 'progress' => (float) $job['progress'], 'error' => (string) $job['error'], 'bytes' => 0, + 'original' => $this->sourceOf((string) $job['folder'], (string) $job['hash']), ]; } diff --git a/src/core/recording/editorRecording.ts b/src/core/recording/editorRecording.ts new file mode 100644 index 00000000..a7d3f340 --- /dev/null +++ b/src/core/recording/editorRecording.ts @@ -0,0 +1,89 @@ +/** + * Recording the whole editor (main menu → Start recording…): what the dialog offers, and the size + * and sharpness a choice comes out at. The browser's side is `editor/EditorRecorder.ts`. + */ +import { editorRecordingSize } from './recording'; + +export type EditorRecordingResolution = 'screen' | '1080p' | '1440p' | '4k'; + +export interface EditorRecordingSettings { + /** Empty: named after the moment it starts (`recordingName`). */ + name: string; + resolution: EditorRecordingResolution; + fps: 30 | 60; + /** Whether the mouse pointer is drawn in; the system draws it over the canvas, not into it. */ + pointer: boolean; + /** Whether the master is recorded with it. */ + sound: boolean; + /** Whether the server converts the upload into an MP4 a project can use; else it is only kept. */ + convert: boolean; + /** Whether the converted video becomes an asset of the project; only with `convert`. */ + adopt: boolean; +} + +/** `box` is the frame the picture is fitted into, keeping the window's shape; none records the canvas as it is. */ +export const EDITOR_RECORDING_RESOLUTIONS: readonly { + value: EditorRecordingResolution; + label: string; + box: { w: number; h: number } | null; +}[] = [ + { value: 'screen', label: 'Screen', box: null }, + { value: '1080p', label: '1080p', box: { w: 1920, h: 1080 } }, + { value: '1440p', label: '1440p', box: { w: 2560, h: 1440 } }, + { value: '4k', label: '4K', box: { w: 3840, h: 2160 } }, +]; + +export const EDITOR_RECORDING_DEFAULTS: EditorRecordingSettings = { + name: '', + resolution: 'screen', + fps: 60, + pointer: true, + sound: true, + convert: true, + adopt: true, +}; + +/** + * The most device pixels a layout pixel is drawn at while recording. A window too small for its + * frame even then is scaled up into it, a little soft. + */ +const MOST_RESOLUTION = 4; + +/** Settings as kept in the browser, whatever was kept: anything unknown is the default. The name is never kept. */ +export function editorRecordingSettingsOf(raw: unknown): EditorRecordingSettings { + const kept = typeof raw === 'object' && raw !== null ? (raw as Record) : {}; + const flag = (key: 'pointer' | 'sound' | 'convert' | 'adopt'): boolean => + typeof kept[key] === 'boolean' ? kept[key] : EDITOR_RECORDING_DEFAULTS[key]; + const resolution = EDITOR_RECORDING_RESOLUTIONS.find((r) => r.value === kept.resolution); + return { + name: '', + resolution: resolution?.value ?? EDITOR_RECORDING_DEFAULTS.resolution, + fps: kept.fps === 30 ? 30 : 60, + pointer: flag('pointer'), + sound: flag('sound'), + convert: flag('convert'), + adopt: flag('adopt'), + }; +} + +/** + * How an editor recording is made of a screen of `screen` layout pixels drawn at `resolution`: + * the size of its frames, even on both sides, and the resolution the editor draws at meanwhile. + * + * `Screen` is the canvas as it is. A larger frame is drawn for, not scaled up to: the editor draws + * more device pixels per layout pixel while it records, so text and lines stay sharp in 4K. On + * screen it looks the same, as the browser shows the larger canvas at its layout size. A smaller + * frame keeps the screen's resolution and is scaled down when it is copied. + */ +export function editorRecordingPlan( + screen: { w: number; h: number }, + resolution: number, + choice: EditorRecordingResolution, +): { size: { w: number; h: number }; resolution: number } { + const box = EDITOR_RECORDING_RESOLUTIONS.find((r) => r.value === choice)?.box ?? null; + const scale = box ? Math.min(box.w / screen.w, box.h / screen.h) : resolution; + return { + size: editorRecordingSize({ w: Math.round(screen.w * scale), h: Math.round(screen.h * scale) }), + resolution: Math.min(MOST_RESOLUTION, Math.max(resolution, scale)), + }; +} diff --git a/src/core/recording/recording.ts b/src/core/recording/recording.ts index a88da139..ff8fc51c 100644 --- a/src/core/recording/recording.ts +++ b/src/core/recording/recording.ts @@ -80,12 +80,9 @@ function even(value: number): number { return Math.max(2, Math.round(value / 2) * 2); } -/** The editor is recorded at 60 fps, whatever the screen draws. */ -export const EDITOR_RECORDING_FPS = 60; - /** - * The size of an editor recording: the whole canvas in device pixels, an odd row or column cut off, - * since the encoders want both sides even. + * The size of an editor recording of a canvas in device pixels: all of it, an odd row or column + * cut off, since the encoders want both sides even. */ export function editorRecordingSize(canvas: { w: number; h: number }): { w: number; h: number } { const cut = (value: number) => Math.max(2, Math.floor(value / 2) * 2); diff --git a/src/core/store/HttpFileStore.ts b/src/core/store/HttpFileStore.ts index 371af9bc..9a30d234 100644 --- a/src/core/store/HttpFileStore.ts +++ b/src/core/store/HttpFileStore.ts @@ -1,5 +1,6 @@ import type { FileStore, StoredFile, UploadedFile } from './FileStore'; import { apiRequest } from './http'; +import type { StoredVideo, UploadedVideo, VideoStore, VideoUploadOptions } from './VideoStore'; /** * The API's file collections, one route each; textures, fonts, sounds and videos differ in nothing @@ -13,15 +14,19 @@ export class HttpFileStore< S extends StoredFile = StoredFile, > implements FileStore { constructor( - private readonly endpoint: string, + protected readonly endpoint: string, private readonly route: FileRoute, ) {} async upload(bytes: Blob | ArrayBuffer, type: string, name = ''): Promise { + return this.send(bytes, type, { name }); + } + + protected async send(bytes: Blob | ArrayBuffer, type: string, query: Record): Promise { return apiRequest(this.endpoint, 'POST', this.route, { headers: { 'Content-Type': type }, body: bytes, - query: { name }, + query, }); } @@ -31,9 +36,31 @@ export class HttpFileStore< /** The reason a refusal gives is meant for the user, so it is thrown as it is, without the route. */ async delete(file: string): Promise { - await apiRequest(this.endpoint, 'DELETE', `${this.route}/${file}`, {}, (status, data) => { - const error = (data as { error?: unknown } | null)?.error; - return status >= 400 && typeof error === 'string' ? new Error(error) : undefined; - }); + await apiRequest(this.endpoint, 'DELETE', `${this.route}/${file}`, {}, refusal); } } + +/** Videos: an upload may be kept without converting it, and converted later. */ +export class HttpVideoStore extends HttpFileStore implements VideoStore { + constructor(endpoint: string) { + super(endpoint, 'videos'); + } + + override async upload( + bytes: Blob | ArrayBuffer, + type: string, + name = '', + options: VideoUploadOptions = {}, + ): Promise { + return this.send(bytes, type, { name, convert: options.convert === false ? '0' : '' }); + } + + async convert(file: string): Promise { + return apiRequest(this.endpoint, 'POST', `videos/${file}/convert`, {}, refusal); + } +} + +function refusal(status: number, data: unknown): Error | undefined { + const error = (data as { error?: unknown } | null)?.error; + return status >= 400 && typeof error === 'string' ? new Error(error) : undefined; +} diff --git a/src/core/store/VideoStore.ts b/src/core/store/VideoStore.ts index 11d6bf50..28f8ff96 100644 --- a/src/core/store/VideoStore.ts +++ b/src/core/store/VideoStore.ts @@ -2,9 +2,10 @@ import type { FileStore, StoredFile, UploadedFile } from './FileStore'; /** * Where an upload is on its way to `data/videos/` (#402): the worker converts one after the other, - * so a video waits, is converted, and is ready or failed. + * so a video waits, is converted, and is ready or failed. One uploaded without converting it stays + * `unconverted` until it is asked to be. */ -export type VideoStatus = 'queued' | 'converting' | 'ready' | 'failed'; +export type VideoStatus = 'queued' | 'converting' | 'ready' | 'failed' | 'unconverted'; /** * What the server says about a video. `file` is known the moment the upload is in, long before the @@ -22,7 +23,27 @@ export interface UploadedVideo extends UploadedFile { audio?: boolean; /** `{folder}/{sha256}.jpg`, beside the MP4. */ poster?: string; + /** + * The upload as it came, `{folder}/{sha256}.source.{ext}` beside the MP4: what Download saves. + * Null for a video uploaded before uploads were kept. + */ + original?: string | null; } export type StoredVideo = StoredFile & UploadedVideo; -export type VideoStore = FileStore; + +export interface VideoUploadOptions { + /** False keeps the upload as it came, `unconverted`, until `convert` asks for it. */ + convert?: boolean; +} + +export interface VideoStore extends FileStore { + upload( + bytes: Blob | ArrayBuffer, + type: string, + name?: string, + options?: VideoUploadOptions, + ): Promise; + /** Converts an upload kept without; answers what the server says of it then. */ + convert(file: string): Promise; +} diff --git a/src/editor/App.ts b/src/editor/App.ts index e1777293..984859ac 100644 --- a/src/editor/App.ts +++ b/src/editor/App.ts @@ -1,13 +1,12 @@ import type { Application } from 'pixi.js'; import type { AuthStore, SignedIn } from '../core/store/AuthStore'; import { HttpAuthStore } from '../core/store/HttpAuthStore'; -import { HttpFileStore } from '../core/store/HttpFileStore'; +import { HttpFileStore, HttpVideoStore } from '../core/store/HttpFileStore'; import { HttpProjectStore } from '../core/store/HttpProjectStore'; import { HttpReleaseStore } from '../core/store/HttpReleaseStore'; import { HttpSnapshotStore } from '../core/store/HttpSnapshotStore'; import { HttpUsersStore } from '../core/store/HttpUsersStore'; import type { StoredTexture, UploadedTexture } from '../core/store/TextureStore'; -import type { StoredVideo, UploadedVideo } from '../core/store/VideoStore'; import { loadPhysics } from '../runtime/physics/engine'; import { projectPictureFactory } from '../viewport/generatedPictures'; import { preloadProjectFiles, setUpProjectFiles } from '../viewport/projectFiles'; @@ -93,7 +92,7 @@ export async function startEditor(app: Application, root: VStack): Promise new HttpFileStore(API_ENDPOINT, 'textures'), new HttpFileStore(API_ENDPOINT, 'fonts'), new HttpFileStore(API_ENDPOINT, 'sounds'), - new HttpFileStore(API_ENDPOINT, 'videos'), + new HttpVideoStore(API_ENDPOINT), new HttpSnapshotStore(API_ENDPOINT), new HttpReleaseStore(API_ENDPOINT), { diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 6d1b0eb4..8fcf6bf4 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -79,6 +79,8 @@ import { Toolbar } from './panels/Toolbar'; import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession'; import { type RecordingUpload, RecordingUploads } from './recordingUploads'; import { EditorRecorder } from './EditorRecorder'; +import { EditorRecordingDialog } from './EditorRecordingDialog'; +import { editorRecordingSettings, keepEditorRecordingSettings } from './editorRecordingSettings'; import type { MadeRecording } from '../viewport/RecordingTake'; import { ProjectSession } from './ProjectSession'; import { ParticlesEditor } from './particles/ParticlesEditor'; @@ -351,6 +353,7 @@ export class Editor implements PlayHost { private readonly toolbar: Toolbar; private readonly passwordDialog = dialogSlot(); private readonly generalDialog = dialogSlot(); + private readonly recordingDialog = dialogSlot(); /** Drawn-frame count at the last measurement, not a counter of its own. */ private fpsFrames = 0; private fpsElapsed = 0; @@ -391,7 +394,7 @@ export class Editor implements PlayHost { this.particlesEditor = new ParticlesEditor(state); this.streamKeeper = new StreamKeeper(state, sounds); this.soundEditor = new SoundEditor(state, this.streamKeeper); - this.videoEditor = new VideoEditor(state); + this.videoEditor = new VideoEditor(state, videos); this.soundFxEditor = new SoundFxEditor(state); this.instrumentEditor = new InstrumentEditor(state); this.patternEditor = new PatternEditor(state, sounds); @@ -421,7 +424,7 @@ export class Editor implements PlayHost { state.setNotice(line); console.warn('[recording]', line); }, - saved: (recording) => this.recordings.add(recording), + saved: (recording, options) => this.recordings.add(recording, options), showBadge: (on) => this.toolbar.showRecording(on), badgeRoom: () => this.toolbar.recordingRoom(), }); @@ -815,7 +818,7 @@ export class Editor implements PlayHost { { separator: true }, recorder.isRecording ? { label: 'Stop recording', onSelect: () => recorder.stop() } - : { label: 'Start recording', onSelect: () => recorder.start() }, + : { label: 'Start recording…', onSelect: () => this.openEditorRecording() }, { separator: true }, { label: 'General settings…', onSelect: () => this.openGeneralSettings() }, ...(this.usersPane ? [{ label: 'User management', onSelect: open }] : []), @@ -833,6 +836,21 @@ export class Editor implements PlayHost { return this.generalDialog.current(); } + /** "Start recording…": how the editor is recorded, starting on the last choice made in this browser. */ + openEditorRecording(): EditorRecordingDialog { + return this.recordingDialog.open( + new EditorRecordingDialog(editorRecordingSettings(), (settings) => { + keepEditorRecordingSettings(settings); + this.editorRecorder.start(settings); + }), + ); + } + + /** The open "Start recording" dialog, for the check scripts. */ + editorRecordingDialog(): EditorRecordingDialog | null { + return this.recordingDialog.current(); + } + /** * The account menu under its button (#182). The name stands in it as an entry that cannot be * picked: it says who is signed in without costing the crowded toolbar another label. @@ -1350,6 +1368,7 @@ export class Editor implements PlayHost { // Closed rather than just thrown away with the overlay below, so the slot lets go of it too. this.passwordDialog.current()?.close(); this.generalDialog.current()?.close(); + this.recordingDialog.current()?.close(); // The tooltip is not the editor's to destroy: it leaves the overlay before that is emptied. tooltips.dismiss(); // A menu or a picker left open belongs to widgets that are about to be destroyed. diff --git a/src/editor/EditorRecorder.ts b/src/editor/EditorRecorder.ts index cdf42a15..901234d1 100644 --- a/src/editor/EditorRecorder.ts +++ b/src/editor/EditorRecorder.ts @@ -1,17 +1,21 @@ import { UPDATE_PRIORITY } from 'pixi.js'; import { cursorShape, type CursorShape } from '../core/recording/cursors'; -import { - EDITOR_RECORDING_FPS, - editorRecordingSize, - elapsedText, - fitPicture, - recordingName, -} from '../core/recording/recording'; +import { editorRecordingPlan, type EditorRecordingSettings } from '../core/recording/editorRecording'; +import { elapsedText, fitPicture, recordingName } from '../core/recording/recording'; import { renderCount, requestRender, Theme, toHex, ui } from '../ui'; -import { type FrameSource, Take, type TakeHost, type TakeInfo } from '../viewport/RecordingTake'; +import { type FrameSource, type MadeRecording, Take, type TakeInfo } from '../viewport/RecordingTake'; + +/** What becomes of a recording on the server, as "Start recording…" asked. */ +export interface RecordingUploadOptions { + convert: boolean; + adopt: boolean; +} /** What the editor recorder needs of the editor. */ -export interface EditorRecorderHost extends TakeHost { +export interface EditorRecorderHost { + /** A line for the editor's notice. */ + log(line: string): void; + saved(recording: MadeRecording, options: RecordingUploadOptions): void; /** Makes room in the top bar for the badge while recording, or takes it away. */ showBadge(on: boolean): void; /** Where the badge goes, in CSS pixels. */ @@ -19,12 +23,12 @@ export interface EditorRecorderHost extends TakeHost { } /** - * Records the whole editor (main menu → Start recording): the canvas as it is on screen, at its size - * in device pixels and 60 fps, with the mouse pointer drawn in, since the system draws that over the - * canvas. What the editor draws is copied from the canvas right after each render, so recording costs - * no second drawing. The badge beside the menu is an HTML element over the canvas, the one exception - * to the single canvas: it is never in the recording, and the canvas is drawn once per frame, which a - * `desynchronized` canvas needs to show no half-drawn picture. + * Records the whole editor (main menu → Start recording…): the canvas as it is on screen, or drawn + * sharper to fill a larger frame (`editorRecordingPlan`), with the mouse pointer drawn in, since + * the system draws that over the canvas. What the editor draws is copied from the canvas right after + * each render, so recording costs no second drawing. The badge beside the menu is an HTML element + * over the canvas, the one exception to the single canvas: it is never in the recording, and the + * canvas is drawn once per frame, which a `desynchronized` canvas needs to show no half-drawn picture. */ export class EditorRecorder { private take: Take | null = null; @@ -35,6 +39,8 @@ export class EditorRecorder { private startedAt = 0; private shownText = ''; private readonly badge = new RecordingBadge(); + /** The resolution the editor was drawn at before a recording raised it; null while it is not raised. */ + private screenResolution: number | null = null; constructor(private readonly host: EditorRecorderHost) {} @@ -42,13 +48,32 @@ export class EditorRecorder { return this.take !== null; } - start(): void { + start(settings: EditorRecordingSettings): void { if (this.take) return; const app = ui().app; - const source = new CanvasSource(app.canvas); - const take = new Take(recordingName('', new Date()), EDITOR_RECORDING_FPS, source, this.host, () => { - if (this.take === take) this.end(); - }); + const screen = { w: app.screen.width, h: app.screen.height }; + const plan = editorRecordingPlan(screen, app.renderer.resolution, settings.resolution); + if (plan.resolution !== app.renderer.resolution) { + this.screenResolution = app.renderer.resolution; + app.renderer.resize(screen.w, screen.h, plan.resolution); + } + const source = new CanvasSource(app.canvas, plan.size, settings.fps, settings.pointer); + const upload = { convert: settings.convert, adopt: settings.convert && settings.adopt }; + const host = { + log: (line: string) => this.host.log(line), + saved: (recording: MadeRecording) => this.host.saved(recording, upload), + }; + const name = recordingName(settings.name, new Date()); + const take = new Take( + name, + settings.fps, + source, + host, + () => { + if (this.take === take) this.end(); + }, + { sound: settings.sound }, + ); this.take = take; this.source = source; this.takes.push(take); @@ -72,7 +97,12 @@ export class EditorRecorder { this.take = null; this.source?.detach(); this.source = null; - ui().app.ticker.remove(this.tick, this); + const app = ui().app; + app.ticker.remove(this.tick, this); + if (this.screenResolution !== null) { + app.renderer.resize(Math.round(app.screen.width), Math.round(app.screen.height), this.screenResolution); + this.screenResolution = null; + } this.badge.hide(); this.host.showBadge(false); requestRender(); @@ -102,11 +132,14 @@ export class EditorRecorder { recording: boolean; takes: TakeInfo[]; badge: { x: number; y: number; w: number; h: number } | null; + /** What the editor is drawn at now, raised while a larger frame is recorded. */ + resolution: number; } { return { recording: this.isRecording, takes: this.takes.map((take) => take.info()), badge: this.isRecording ? this.host.badgeRoom() : null, + resolution: ui().app.renderer.resolution, }; } } @@ -178,7 +211,6 @@ class RecordingBadge { * picture with the mouse pointer drawn over it. */ class CanvasSource implements FrameSource { - private readonly size: { w: number; h: number }; private readonly picture: OffscreenCanvas; private readonly picturePen: OffscreenCanvasRenderingContext2D; private readonly frame: OffscreenCanvas; @@ -188,13 +220,17 @@ class CanvasSource implements FrameSource { private copied = false; private ready: VideoFrame[] = []; - constructor(private readonly canvas: HTMLCanvasElement) { - this.size = editorRecordingSize({ w: canvas.width, h: canvas.height }); + constructor( + private readonly canvas: HTMLCanvasElement, + private readonly size: { w: number; h: number }, + private readonly fps: number, + private readonly showsPointer: boolean, + ) { this.picture = new OffscreenCanvas(this.size.w, this.size.h); this.picturePen = this.picture.getContext('2d', { alpha: false })!; this.frame = new OffscreenCanvas(this.size.w, this.size.h); this.framePen = this.frame.getContext('2d', { alpha: false })!; - this.pointer.attach(canvas); + if (showsPointer) this.pointer.attach(canvas); } open(): { w: number; h: number } { @@ -230,7 +266,7 @@ class CanvasSource implements FrameSource { this.ready.push( new VideoFrame(this.frame, { timestamp: Math.round(ms * 1000), - duration: Math.round(1e6 / EDITOR_RECORDING_FPS), + duration: Math.round(1e6 / this.fps), }), ); return true; @@ -239,7 +275,7 @@ class CanvasSource implements FrameSource { private drawPointer(): void { const { canvas, pointer } = this; const shape = cursorShape(canvas.style.cursor); - if (!pointer.inside || !shape) return; + if (!this.showsPointer || !pointer.inside || !shape) return; const rect = canvas.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; const at = fitPicture({ w: canvas.width, h: canvas.height }, this.size); diff --git a/src/editor/EditorRecordingDialog.ts b/src/editor/EditorRecordingDialog.ts new file mode 100644 index 00000000..074dd965 --- /dev/null +++ b/src/editor/EditorRecordingDialog.ts @@ -0,0 +1,145 @@ +import { + EDITOR_RECORDING_RESOLUTIONS, + editorRecordingPlan, + type EditorRecordingResolution, + type EditorRecordingSettings, +} from '../core/recording/editorRecording'; +import { type Button, Checkbox, Dropdown, Label, Menu, TextField, ui } from '../ui'; +import { FormDialog } from './FormDialog'; + +const FIELD_WIDTH = 200; +const FPS_CHOICES = [60, 30] as const; + +type Switch = 'pointer' | 'sound' | 'convert' | 'adopt'; + +/** + * "Start recording…" in the main menu: how the whole editor is recorded, and what becomes of the + * file. It always goes up to the server; Convert makes the MP4 a project can use of it, and Import + * adds that to the project once it is ready. + */ +export class EditorRecordingDialog extends FormDialog { + private settings: EditorRecordingSettings; + private readonly nameField: TextField; + private readonly resolutionPicker: Dropdown; + private readonly fpsPicker: Dropdown; + private readonly size: Label; + private readonly boxes: Record; + /** The confirming button, so a check can click it. */ + readonly startButton: Button; + + constructor(settings: EditorRecordingSettings, onStart: (settings: EditorRecordingSettings) => void) { + super(360, 'Start recording'); + this.settings = { ...settings }; + + this.nameField = new TextField(settings.name, { + width: FIELD_WIDTH, + placeholder: 'Recording