256 lines
13 KiB
JavaScript
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 };
|