const MP4_MIME_TYPES = [ 'video/mp4;codecs=avc1.42E01E', 'video/mp4;codecs=avc1.4D401F', 'video/mp4', ]; export class VideoExportError extends Error { constructor(message, code = 'video-export-error') { super(message); this.name = 'VideoExportError'; this.code = code; } } export class VideoExportCancelledError extends VideoExportError { constructor(message = '영상 생성을 취소했습니다') { super(message, 'cancelled'); this.name = 'VideoExportCancelledError'; } } export class VideoExportStaleError extends VideoExportError { constructor(message = '전술이 변경되어 영상 생성을 취소했습니다') { super(message, 'stale'); this.name = 'VideoExportStaleError'; } } export class VideoShareError extends Error { constructor(message, code = 'video-share-error') { super(message); this.name = 'VideoShareError'; this.code = code; } } export function selectMp4MimeType(mediaRecorderConstructor = globalThis.MediaRecorder) { if (!mediaRecorderConstructor || typeof mediaRecorderConstructor.isTypeSupported !== 'function') return null; for (const mimeType of MP4_MIME_TYPES) { try { if (mediaRecorderConstructor.isTypeSupported(mimeType)) return mimeType; } catch { // A browser may throw for an unknown codec string. Try the next one. } } return null; } function isMp4MimeType(type) { return typeof type === 'string' && type.toLowerCase().split(';', 1)[0] === 'video/mp4'; } function abortError(signal) { return signal?.reason instanceof Error ? signal.reason : new VideoExportCancelledError(); } function stopStream(stream) { for (const track of stream?.getTracks?.() || []) track.stop?.(); } function makeMp4File(blob, fileName, FileConstructor = globalThis.File) { if (!(blob instanceof Blob) || blob.size <= 0 || !isMp4MimeType(blob.type)) { throw new VideoExportError('브라우저가 유효한 MP4 영상을 만들지 못했습니다', 'invalid-mp4'); } if (typeof FileConstructor !== 'function') throw new VideoExportError('이 브라우저는 MP4 파일을 준비할 수 없습니다', 'file-unsupported'); return new FileConstructor([blob], fileName, { type: 'video/mp4', lastModified: Date.now() }); } async function runVideoExport(options, signal) { const { canvas, duration, fps = 30, renderFrame, onProgress = () => {}, isCurrent = () => true, MediaRecorderConstructor = globalThis.MediaRecorder, clockImpl = () => globalThis.performance?.now?.() ?? Date.now(), sleepImpl = (delay) => new Promise((resolve) => setTimeout(resolve, Math.max(0, delay))), isVisible = () => globalThis.document?.hidden !== true, FileConstructor = globalThis.File, fileName = 'basket-utils-play.mp4', } = options; if (!canvas?.captureStream) throw new VideoExportError('이 브라우저는 캔버스 영상 생성을 지원하지 않습니다', 'capture-stream-unsupported'); if (typeof MediaRecorderConstructor !== 'function') throw new VideoExportError('이 브라우저는 MP4 영상 생성을 지원하지 않습니다', 'media-recorder-unsupported'); if (!Number.isFinite(duration) || duration <= 0) throw new VideoExportError('먼저 재생할 이동 행동을 추가하세요', 'empty-play'); if (!Number.isFinite(fps) || fps <= 0) throw new VideoExportError('영상 프레임 설정이 올바르지 않습니다', 'invalid-settings'); if (typeof renderFrame !== 'function') throw new VideoExportError('영상 렌더러를 준비하지 못했습니다', 'renderer-unsupported'); if (typeof clockImpl !== 'function' || typeof sleepImpl !== 'function') throw new VideoExportError('이 브라우저는 영상 시간 기준을 준비하지 못했습니다', 'clock-unsupported'); if (typeof isVisible !== 'function') throw new VideoExportError('이 브라우저의 영상 표시 상태를 확인하지 못했습니다', 'visibility-unsupported'); if (!isVisible()) throw new VideoExportError('영상 생성 중에는 이 탭을 보고 있어야 합니다', 'document-hidden'); if (!isCurrent()) throw new VideoExportStaleError(); if (signal?.aborted) throw abortError(signal); const mimeType = selectMp4MimeType(MediaRecorderConstructor); if (!mimeType) throw new VideoExportError('이 브라우저는 MP4 녹화를 지원하지 않습니다. Safari 또는 MP4 녹화를 지원하는 브라우저에서 다시 시도하세요.', 'mp4-unsupported'); let stream; try { stream = canvas.captureStream(fps); } catch (error) { throw new VideoExportError(`영상 캔버스를 준비하지 못했습니다: ${error.message || error}`, 'capture-stream-error'); } let recorder; try { recorder = new MediaRecorderConstructor(stream, { mimeType }); } catch (error) { stopStream(stream); throw new VideoExportError(`MP4 녹화기를 준비하지 못했습니다: ${error.message || error}`, 'recorder-error'); } if (recorder.mimeType && !isMp4MimeType(recorder.mimeType)) { stopStream(stream); throw new VideoExportError('브라우저가 MP4 대신 다른 영상 형식을 선택했습니다', 'mp4-unsupported'); } const chunks = []; // Capture at the requested wall-clock rate. A fixed sample loop tied to RAF // would finish too early on 60/120 Hz displays and produce fast-forwarded MP4. const frameCount = Math.max(1, Math.ceil(duration * fps)); let settled = false; let recording = false; let removeAbortListener = () => {}; const finish = (resolve, reject, error, result) => { if (settled) return; settled = true; removeAbortListener(); stopStream(stream); if (error) reject(error); else resolve(result); }; const promise = new Promise((resolve, reject) => { const fail = (error) => finish(resolve, reject, error); const abort = () => { const error = abortError(signal); if (recording) { try { recorder.stop(); } catch { /* recorder may already be stopping */ } } fail(error); }; if (signal) { signal.addEventListener('abort', abort, { once: true }); removeAbortListener = () => signal.removeEventListener('abort', abort); } recorder.ondataavailable = (event) => { if (event.data?.size) chunks.push(event.data); }; recorder.onerror = (event) => fail(new VideoExportError(event.error?.message || 'MP4 녹화 중 오류가 발생했습니다', 'recorder-error')); recorder.onstop = () => { if (signal?.aborted) return; try { const blob = new Blob(chunks, { type: mimeType }); const file = makeMp4File(blob, fileName, FileConstructor); finish(resolve, reject, null, { file, blob, mimeType, duration, fps, frameCount }); } catch (error) { fail(error); } }; try { recorder.start(); recording = true; } catch (error) { fail(new VideoExportError(`MP4 녹화를 시작하지 못했습니다: ${error.message || error}`, 'recorder-error')); return; } (async () => { try { const recordingStartedAt = clockImpl(); for (let index = 0; index < frameCount; index += 1) { if (signal?.aborted) throw abortError(signal); if (!isCurrent()) throw new VideoExportStaleError(); if (!isVisible()) throw new VideoExportError('영상 생성 중에는 이 탭을 보고 있어야 합니다', 'document-hidden'); const elapsed = Math.min(duration, index / fps); const frameTarget = elapsed * 1000; const wait = frameTarget - (clockImpl() - recordingStartedAt); if (wait > 0) await sleepImpl(wait); if (signal?.aborted) throw abortError(signal); if (!isCurrent()) throw new VideoExportStaleError(); if (!isVisible()) throw new VideoExportError('영상 생성 중에는 이 탭을 보고 있어야 합니다', 'document-hidden'); await renderFrame(elapsed, index / Math.max(1, frameCount - 1)); onProgress(Math.min(1, (index + 1) / frameCount)); } const finalWait = duration * 1000 - (clockImpl() - recordingStartedAt); if (finalWait > 0) await sleepImpl(finalWait); if (signal?.aborted) throw abortError(signal); if (!isCurrent()) throw new VideoExportStaleError(); if (!isVisible()) throw new VideoExportError('영상 생성 중에는 이 탭을 보고 있어야 합니다', 'document-hidden'); recorder.stop(); recording = false; } catch (error) { if (settled) return; try { if (recording) recorder.stop(); } catch { /* cleanup below is sufficient */ } finish(resolve, reject, error); } })(); }); return promise; } export function createVideoExportJob(options) { const controller = new AbortController(); if (options?.signal) { if (options.signal.aborted) controller.abort(options.signal.reason); else options.signal.addEventListener('abort', () => controller.abort(options.signal.reason), { once: true }); } const promise = runVideoExport(options || {}, controller.signal); return { promise, signal: controller.signal, cancel: () => controller.abort(new VideoExportCancelledError()) }; } export async function validateVideoFile(file, options = {}) { if (!file || file.size <= 0 || !isMp4MimeType(file.type)) throw new VideoExportError('준비된 파일이 유효한 MP4가 아닙니다', 'invalid-mp4'); if (options.signal?.aborted) throw abortError(options.signal); const documentObject = options.documentObject ?? globalThis.document; const urlObject = options.urlObject ?? globalThis.URL; if (!documentObject?.createElement || !urlObject?.createObjectURL) return { duration: null, type: file.type, size: file.size }; const video = documentObject.createElement('video'); if (typeof video.canPlayType === 'function' && !video.canPlayType('video/mp4')) throw new VideoExportError('이 브라우저에서 생성된 MP4를 재생할 수 없습니다', 'video-playback-unsupported'); const url = urlObject.createObjectURL(file); const timeoutMs = Math.max(500, Number(options.timeoutMs) || 5000); return new Promise((resolve, reject) => { let settled = false; let timer; const cleanup = () => { clearTimeout(timer); options.signal?.removeEventListener('abort', abort); video.onloadedmetadata = null; video.onerror = null; urlObject.revokeObjectURL?.(url); video.removeAttribute?.('src'); video.load?.(); }; const finish = (error, value) => { if (settled) return; settled = true; cleanup(); if (error) reject(error); else resolve(value); }; const abort = () => finish(abortError(options.signal)); options.signal?.addEventListener('abort', abort, { once: true }); video.onloadedmetadata = () => { const duration = Number(video.duration); if (!Number.isFinite(duration) || duration <= 0) { finish(new VideoExportError('생성된 MP4에 재생 가능한 시간 정보가 없습니다', 'video-metadata-invalid')); return; } finish(null, { duration, type: file.type, size: file.size }); }; video.onerror = () => finish(new VideoExportError('생성된 MP4를 미리보기로 열 수 없습니다', 'video-playback-invalid')); timer = setTimeout(() => finish(new VideoExportError('MP4 재생 정보를 확인하는 데 시간이 걸리고 있습니다', 'video-metadata-timeout')), timeoutMs); video.src = url; video.load?.(); }); } export async function shareVideoFile(file, options = {}) { const navigatorObject = options.navigatorObject ?? globalThis.navigator; if (!navigatorObject || typeof navigatorObject.share !== 'function' || typeof navigatorObject.canShare !== 'function') throw new VideoShareError('이 브라우저는 영상 파일 공유를 지원하지 않습니다', 'share-unsupported'); let supported = false; try { supported = Boolean(navigatorObject.canShare({ files: [file] })); } catch { supported = false; } if (!supported) throw new VideoShareError('이 브라우저에서 MP4 파일 공유를 지원하지 않습니다. MP4를 다운로드해 카카오톡에 직접 첨부하세요.', 'file-share-unsupported'); return navigatorObject.share({ files: [file], title: options.title || 'basket-utils 전술 영상', text: options.text || '농구 전술 MP4 영상' }); } export function downloadVideoFile(file, options = {}) { const documentObject = options.documentObject ?? globalThis.document; const urlObject = options.urlObject ?? globalThis.URL; if (!documentObject?.createElement || !urlObject?.createObjectURL) throw new VideoShareError('영상 다운로드를 준비하지 못했습니다', 'download-unsupported'); const url = urlObject.createObjectURL(file); const anchor = documentObject.createElement('a'); anchor.href = url; anchor.download = options.fileName || file.name || 'basket-utils-play.mp4'; anchor.click(); setTimeout(() => urlObject.revokeObjectURL?.(url), 1000); } export { MP4_MIME_TYPES };