{"version":3,"file":"media-recorder.cjs","names":[],"sources":["../../src/capture/media-recorder.ts"],"sourcesContent":["/**\n * @tempest-limits function-lines — the engine behind both the audio and the video\n * recorder: MIME negotiation, the state machine MediaRecorder does not give you, and\n * the clock kept by hand because a fresh WebM reports no duration. The clock has to\n * pause and resume with the state machine, so they are one closure.\n */\n/** Lifecycle of a recording. */\nexport type MediaRecorderStatus = \"idle\" | \"recording\" | \"paused\" | \"stopped\";\n\n/** What a track carries — used only to word the errors this engine throws. */\nexport type MediaRecordingKind = \"audio\" | \"video\";\n\n/** A finished recording, whatever it was made of. */\nexport interface MediaRecording {\n    /** The bytes. Wrap with `useObjectUrl` to play it, or POST it as-is. */\n    blob: Blob;\n    /** What the browser actually produced — not necessarily what you asked for. */\n    mimeType: string;\n    /** Recorded length, excluding time spent paused. */\n    durationMs: number;\n}\n\n/** Options for {@link createMediaRecorder}. */\nexport interface MediaRecordingOptions {\n    /**\n     * Container candidates, best first. Required: this engine has no opinion about\n     * codecs — the audio and video wrappers own that list.\n     */\n    candidates: readonly string[];\n    /** Wording for the thrown messages (\"cannot record any supported *video* container\"). */\n    kind: MediaRecordingKind;\n    /** Force a container, bypassing the negotiation. Throws when unsupported. */\n    mimeType?: string;\n    /** Target audio bitrate. */\n    audioBitsPerSecond?: number;\n    /** Target video bitrate. Ignored by the browser on an audio-only stream. */\n    videoBitsPerSecond?: number;\n    /**\n     * Emit a chunk every N ms through `onChunk`, for streaming upload.\n     *\n     * Without it the whole recording is buffered in memory until `stop()`.\n     */\n    timesliceMs?: number;\n    /** Receives each chunk when `timesliceMs` is set. Chunks are **not** independently playable. */\n    onChunk?: (chunk: Blob) => void;\n    /** Recorder-level failure (device unplugged mid-recording, encoder error). */\n    onError?: (error: unknown) => void;\n}\n\n/** Imperative recorder over one `MediaStream`. */\nexport interface MediaRecorderHandle {\n    /** Begin recording. No-op when already recording or paused. */\n    start: () => void;\n    /** Pause. The clock stops; `durationMs` freezes. */\n    pause: () => void;\n    /** Resume after `pause()`. */\n    resume: () => void;\n    /** Stop and resolve with the assembled recording. */\n    stop: () => Promise<MediaRecording>;\n    /** Stop and throw the bytes away. */\n    cancel: () => void;\n    status: () => MediaRecorderStatus;\n    /** Recorded length so far, excluding paused time. */\n    durationMs: () => number;\n    /** The negotiated container. */\n    mimeType: string;\n}\n\n/**\n * First container in `preferred` the browser can actually produce, or `null`.\n *\n * @param preferred - Candidates, best first.\n * @returns A supported MIME type, or `null` when none is.\n */\nexport function pickRecordingMimeType(preferred: readonly string[]): string | null {\n    if (typeof MediaRecorder === \"undefined\") return null;\n    // Older WebViews ship `MediaRecorder` without the static probe. Assume the first\n    // candidate rather than refusing outright — the constructor will tell us.\n    if (typeof MediaRecorder.isTypeSupported !== \"function\") return preferred[0] ?? null;\n    return preferred.find((type) => MediaRecorder.isTypeSupported(type)) ?? null;\n}\n\n/**\n * Wrap a `MediaStream` in a recorder with a real state machine and an honest clock.\n *\n * This is the engine behind both `createAudioRecorder` and `createVideoRecorder`; the\n * only thing it does not decide is which containers to try, because that is the one\n * part that genuinely differs between the two.\n *\n * Two things `MediaRecorder` does not give you:\n *\n * - **A duration.** It reports none, and the `Blob` has no reliable one either —\n *   WebM written by `MediaRecorder` carries no duration in its header, which is why\n *   `<audio>`/`<video>` shows `Infinity` for a fresh recording. So the clock is kept\n *   here, and it subtracts paused time: a recorder that counts wall-clock through a\n *   pause reports a 30-second note as two minutes.\n * - **A promise from `stop()`.** The last chunk arrives *after* `stop()` returns, in\n *   a `dataavailable` event that fires before `onstop`. Assembling the blob in\n *   `onstop` is the only point where every chunk is in hand.\n *\n * The stream is **not** owned here: `stop()` leaves the device open so a retake does\n * not need a second permission round-trip. Release it with the owning hook's `stop()`.\n *\n * @param stream - A live stream, from `getUserMedia` or `getDisplayMedia`.\n * @param options - See {@link MediaRecordingOptions}.\n * @returns The imperative recorder.\n * @throws When `MediaRecorder` is unavailable, or an explicit `mimeType` is unsupported.\n */\nexport function createMediaRecorder(\n    stream: MediaStream,\n    options: MediaRecordingOptions,\n): MediaRecorderHandle {\n    const {\n        candidates,\n        kind,\n        mimeType: forced,\n        audioBitsPerSecond,\n        videoBitsPerSecond,\n        timesliceMs,\n        onChunk,\n        onError,\n    } = options;\n\n    if (typeof MediaRecorder === \"undefined\") {\n        throw new Error(\"MediaRecorder is not available in this environment.\");\n    }\n    const negotiated = forced ?? pickRecordingMimeType(candidates);\n    if (negotiated === null) {\n        throw new Error(`This browser cannot record any supported ${kind} container.`);\n    }\n    if (\n        forced !== undefined &&\n        typeof MediaRecorder.isTypeSupported === \"function\" &&\n        !MediaRecorder.isTypeSupported(forced)\n    ) {\n        throw new Error(`This browser cannot record \"${forced}\".`);\n    }\n\n    const recorder = new MediaRecorder(stream, {\n        mimeType: negotiated,\n        ...(audioBitsPerSecond !== undefined ? { audioBitsPerSecond } : {}),\n        ...(videoBitsPerSecond !== undefined ? { videoBitsPerSecond } : {}),\n    });\n\n    let chunks: Blob[] = [];\n    let status: MediaRecorderStatus = \"idle\";\n    let accumulatedMs = 0;\n    let segmentStart = 0;\n    let settle: ((recording: MediaRecording) => void) | null = null;\n    let discard = false;\n\n    const elapsed = (): number =>\n        accumulatedMs + (status === \"recording\" ? Date.now() - segmentStart : 0);\n\n    recorder.ondataavailable = (event: BlobEvent): void => {\n        if (event.data.size === 0) return;\n        if (discard) return;\n        chunks.push(event.data);\n        onChunk?.(event.data);\n    };\n\n    recorder.onerror = (event: Event): void => {\n        onError?.((event as unknown as { error?: unknown }).error ?? event);\n    };\n\n    recorder.onstop = (): void => {\n        const durationMs = elapsed();\n        status = \"stopped\";\n        const resolve = settle;\n        settle = null;\n        if (discard) {\n            chunks = [];\n            return;\n        }\n        // `recorder.mimeType` is the source of truth: a browser handed\n        // `video/webm;codecs=vp9,opus` may report plain `video/webm` back.\n        const type = recorder.mimeType || negotiated;\n        resolve?.({ blob: new Blob(chunks, { type }), mimeType: type, durationMs });\n        chunks = [];\n    };\n\n    return {\n        mimeType: negotiated,\n        status: () => status,\n        durationMs: elapsed,\n\n        start(): void {\n            if (status === \"recording\" || status === \"paused\") return;\n            chunks = [];\n            accumulatedMs = 0;\n            discard = false;\n            segmentStart = Date.now();\n            status = \"recording\";\n            if (timesliceMs !== undefined) recorder.start(timesliceMs);\n            else recorder.start();\n        },\n\n        pause(): void {\n            if (status !== \"recording\") return;\n            accumulatedMs += Date.now() - segmentStart;\n            status = \"paused\";\n            recorder.pause();\n        },\n\n        resume(): void {\n            if (status !== \"paused\") return;\n            segmentStart = Date.now();\n            status = \"recording\";\n            recorder.resume();\n        },\n\n        stop(): Promise<MediaRecording> {\n            if (status === \"idle\" || status === \"stopped\") {\n                return Promise.resolve({\n                    blob: new Blob([], { type: negotiated }),\n                    mimeType: negotiated,\n                    durationMs: 0,\n                });\n            }\n            // Stopping while paused needs no clock fix-up: `pause()` already folded\n            // the last segment into `accumulatedMs`, and `elapsed()` adds nothing\n            // while the status is not `\"recording\"`.\n            return new Promise<MediaRecording>((resolve) => {\n                settle = resolve;\n                recorder.stop();\n            });\n        },\n\n        cancel(): void {\n            if (status === \"idle\" || status === \"stopped\") return;\n            discard = true;\n            settle = null;\n            recorder.stop();\n        },\n    };\n}\n"],"mappings":"AA0EA,SAAgB,EAAsB,EAA6C,CAK/E,OAJI,OAAO,cAAkB,IAAoB,KAG7C,OAAO,cAAc,iBAAoB,WACtC,EAAU,KAAM,GAAS,cAAc,gBAAgB,CAAI,CAAC,GAAK,KADR,EAAU,IAAM,IAEpF,CA4BA,SAAgB,EACZ,EACA,EACmB,CACnB,GAAM,CACF,aACA,OACA,SAAU,EACV,qBACA,qBACA,cACA,UACA,WACA,EAEJ,GAAI,OAAO,cAAkB,IACzB,MAAU,MAAM,qDAAqD,EAEzE,IAAM,EAAa,GAAU,EAAsB,CAAU,EAC7D,GAAI,IAAe,KACf,MAAU,MAAM,4CAA4C,EAAK,YAAY,EAEjF,GACI,IAAW,IAAA,IACX,OAAO,cAAc,iBAAoB,YACzC,CAAC,cAAc,gBAAgB,CAAM,EAErC,MAAU,MAAM,+BAA+B,EAAO,GAAG,EAG7D,IAAM,EAAW,IAAI,cAAc,EAAQ,CACvC,SAAU,EACV,GAAI,IAAuB,IAAA,GAAqC,CAAC,EAA1B,CAAE,oBAAmB,EAC5D,GAAI,IAAuB,IAAA,GAAqC,CAAC,EAA1B,CAAE,oBAAmB,CAChE,CAAC,EAEG,EAAiB,CAAC,EAClB,EAA8B,OAC9B,EAAgB,EAChB,EAAe,EACf,EAAuD,KACvD,EAAU,GAER,MACF,GAAiB,IAAW,YAAc,KAAK,IAAI,EAAI,EAAe,GA6B1E,MA3BA,GAAS,gBAAmB,GAA2B,CAC/C,EAAM,KAAK,OAAS,IACpB,IACJ,EAAO,KAAK,EAAM,IAAI,EACtB,IAAU,EAAM,IAAI,GACxB,EAEA,EAAS,QAAW,GAAuB,CACvC,IAAW,EAAyC,OAAS,CAAK,CACtE,EAEA,EAAS,WAAqB,CAC1B,IAAM,EAAa,EAAQ,EAC3B,EAAS,UACT,IAAM,EAAU,EAEhB,GADA,EAAS,KACL,EAAS,CACT,EAAS,CAAC,EACV,MACJ,CAGA,IAAM,EAAO,EAAS,UAAY,EAClC,IAAU,CAAE,KAAM,IAAI,KAAK,EAAQ,CAAE,MAAK,CAAC,EAAG,SAAU,EAAM,YAAW,CAAC,EAC1E,EAAS,CAAC,CACd,EAEO,CACH,SAAU,EACV,WAAc,EACd,WAAY,EAEZ,OAAc,CACN,IAAW,aAAe,IAAW,WACzC,EAAS,CAAC,EACV,EAAgB,EAChB,EAAU,GACV,EAAe,KAAK,IAAI,EACxB,EAAS,YACL,IAAgB,IAAA,GACf,EAAS,MAAM,EADW,EAAS,MAAM,CAAW,EAE7D,EAEA,OAAc,CACN,IAAW,cACf,GAAiB,KAAK,IAAI,EAAI,EAC9B,EAAS,SACT,EAAS,MAAM,EACnB,EAEA,QAAe,CACP,IAAW,WACf,EAAe,KAAK,IAAI,EACxB,EAAS,YACT,EAAS,OAAO,EACpB,EAEA,MAAgC,CAW5B,OAVI,IAAW,QAAU,IAAW,UACzB,QAAQ,QAAQ,CACnB,KAAM,IAAI,KAAK,CAAC,EAAG,CAAE,KAAM,CAAW,CAAC,EACvC,SAAU,EACV,WAAY,CAChB,CAAC,EAKE,IAAI,QAAyB,GAAY,CAC5C,EAAS,EACT,EAAS,KAAK,CAClB,CAAC,CACL,EAEA,QAAe,CACP,IAAW,QAAU,IAAW,YACpC,EAAU,GACV,EAAS,KACT,EAAS,KAAK,EAClB,CACJ,CACJ"}