Files
basket_utils/src/videoExport.js
T
2026-09-08 12:32:10 +09:00

256 lines
13 KiB
JavaScript

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 };