Initial commit: courtlab tactical board with team management and MP4 export
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
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 = 'court-lab-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 || 'court.lab 전술 영상', 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 || 'court-lab-play.mp4';
|
||||
anchor.click();
|
||||
setTimeout(() => urlObject.revokeObjectURL?.(url), 1000);
|
||||
}
|
||||
|
||||
export { MP4_MIME_TYPES };
|
||||
Reference in New Issue
Block a user