import type { UploadedVideo } from '../core/store/VideoStore'; import type { MadeRecording } from '../viewport/RecordingTake'; import type { RecordingUploadOptions } from './EditorRecorder'; import { adoptVideo, type VideoImportDeps } from './videoImport'; /** How often the server is asked whether a recording is converted, as the video picker does. */ const POLL_MS = 1000; /** `kept`: uploaded without converting it; `converted`: converted, but not to be imported. */ type UploadState = 'uploading' | 'converting' | 'added' | 'kept' | 'converted' | 'elsewhere' | 'failed'; /** What became of a recording on its way into the project (test hook). */ export interface RecordingUpload { name: string; state: UploadState; /** The name the server gave it, once it is up. */ file: string; assetId: string; bytes: number; error: string; } /** * Brings a recording of the game view (#405) or of the editor into the project: up to the server, * which converts it like any video, then an asset named after the recording once it is ready. Only * while the editor is not playing: a run plays a copy of the project that is thrown away, and so * would the asset be. An editor recording may stop short of either step ("Start recording…"): it * is always uploaded, and the video picker has it. */ export class RecordingUploads { private readonly uploads: RecordingUpload[] = []; constructor(private readonly deps: VideoImportDeps) {} add(recording: MadeRecording, options: RecordingUploadOptions = { convert: true, adopt: true }): void { const upload: RecordingUpload = { name: recording.name, state: 'uploading', file: '', assetId: '', bytes: recording.blob.size, error: '', }; this.uploads.push(upload); void this.bring(recording, upload, options).catch((error: unknown) => { upload.state = 'failed'; upload.error = error instanceof Error ? error.message : String(error); this.deps.state.setNotice(`Could not save the recording "${recording.name}": ${upload.error}`, 'error'); }); } info(): RecordingUpload[] { return this.uploads.map((upload) => ({ ...upload })); } private async bring( recording: MadeRecording, upload: RecordingUpload, options: RecordingUploadOptions, ): Promise { const { state, videos } = this.deps; const projectId = state.project.id; state.setNotice(`Uploading the recording "${recording.name}"…`); let video: UploadedVideo = await videos.upload( recording.blob, 'application/octet-stream', `${recording.name}.mkv`, { convert: options.convert }, ); upload.file = video.file; if (!options.convert) { upload.state = 'kept'; state.setNotice( `Uploaded the recording "${recording.name}" without converting it – the video picker has it.`, ); return; } upload.state = 'converting'; while (video.status !== 'ready') { if (video.status === 'failed') throw new Error(video.error ?? 'the server could not convert it'); await wait(POLL_MS); video = (await videos.list()).find((v) => v.file === upload.file) ?? video; } if (!options.adopt) { upload.state = 'converted'; state.setNotice(`The recording "${recording.name}" is converted – the video picker has it.`); return; } while (state.mode !== 'edit') await wait(POLL_MS); if (state.project.id !== projectId) { upload.state = 'elsewhere'; state.setNotice(`The recording "${recording.name}" is ready – the video picker has it.`); return; } const outcome = adoptVideo(video, recording.name, this.deps); if (!outcome.assetId) throw new Error(outcome.error ?? 'the project did not take it'); upload.state = 'added'; upload.assetId = outcome.assetId; state.setNotice(`Added the recording "${recording.name}" to the project.`); } } function wait(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); }