'use client'; /** * Global store for the one-click MP4 render (issue #866). * * The render is async and long (a 10-minute classroom takes ~10 minutes), so * its progress must outlive the export menu: the user can close the menu, switch * scenes, and reopen the menu, and still see live progress. Keeping the state in * a component (as the first cut did) meant it died with the menu, and the * re-mounted menu reset its "already rendering" ref — letting the user submit a * duplicate render. Hoisting the whole lifecycle here fixes both. * * The store owns the submit → poll → download loop. Progress is a percentage * plus an estimated time remaining (extrapolated from elapsed time and smoothed * with an EMA); no producer stage strings are surfaced. i18n stays out of the * store: callers pass `t`, and toasts use sonner's global singleton so they * still fire if the calling component has unmounted. */ import { create } from 'zustand'; import { saveAs } from 'file-saver'; import { toast } from 'sonner'; import { createLogger } from '@/lib/logger'; import { runPolledTask } from '@/lib/media/polled-task'; import { NoScenesError, sanitizeFilename, type VideoFps, type VideoQuality, type VideoResolution, } from '@/lib/video-export-app/export-options'; import type { Locale } from '@/lib/i18n'; const log = createLogger('VideoRenderStore'); const POLL_INTERVAL_MS = 3000; const MAX_POLL_ATTEMPTS = Math.ceil((60 * 60 * 1000) / POLL_INTERVAL_MS); /** Below this percent the extrapolated ETA is too noisy to show. */ const ETA_MIN_PERCENT = 3; /** * EMA weight for the newest *speed* sample (percent-per-ms). Lower = smoother * and laggier. We smooth speed rather than the whole-run average because this * render is not uniform (prep → frame capture, which drops 4→1 worker mid-way, * → encode): a recent-speed estimate reacts to those regime changes, and the * EMA keeps it from jittering on the producer's uneven percent updates. */ const SPEED_SMOOTHING = 0.3; export type VideoRenderStatus = 'idle' | 'compiling' | 'rendering' | 'succeeded' | 'failed'; /** Minimal i18n surface the store needs, injected by callers. */ type Translate = (key: string, options?: Record) => string; export interface RenderOptions { resolution?: VideoResolution; fps?: VideoFps; quality?: VideoQuality; /** Burn subtitles into the MP4. Default false (sidecar SRT/VTT only). */ burnInSubtitles?: boolean; } /** Fully-resolved render options (the store always holds concrete values). */ type ResolvedOptions = Required; const DEFAULT_OPTIONS: ResolvedOptions = { resolution: '1080p', fps: 30, quality: 'standard', burnInSubtitles: false, }; interface JobStatusResponse { jobId: string; status: 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled'; progress?: number; currentStage?: string; error?: string; done?: boolean; } interface VideoRenderState { status: VideoRenderStatus; /** 0..100. */ percent: number; /** Estimated milliseconds remaining, or null while unknown. */ etaMs: number | null; filename: string | null; error: string | null; /** * The user's selected render options. Held in the store (not the menu * component) so they survive the menu unmounting on scene switches and, while * a render runs, reflect the options that render is actually using. */ options: ResolvedOptions; setOptions: (patch: Partial) => void; /** True while a render is in flight (compiling or rendering). */ isActive: () => boolean; /** `locale` is the export's, not the store's: it is baked into the emitted card chrome. */ startRender: (t: Translate, locale: Locale) => Promise; reset: () => void; } /** Whether a status means a render is still in flight. */ function inFlight(status: VideoRenderStatus): boolean { return status === 'compiling' || status === 'rendering'; } export const useVideoRenderStore = create()((set, get) => ({ status: 'idle', percent: 0, etaMs: null, filename: null, error: null, options: DEFAULT_OPTIONS, setOptions: (patch) => set((s) => ({ options: { ...s.options, ...patch } })), isActive: () => inFlight(get().status), reset: () => set({ status: 'idle', percent: 0, etaMs: null, filename: null, error: null }), startRender: async (t, locale) => { // Guard against a duplicate submit — the whole reason state lives here. if (inFlight(get().status)) return; const { resolution, fps, quality, burnInSubtitles } = get().options; set({ status: 'compiling', percent: 0, etaMs: null, filename: null, error: null }); const toastId = toast.loading(t('export.videoCompiling')); let zipBlob: Blob; let stageName: string; let missingCount = 0; let errorCount = 0; try { const { buildExportZip } = await import('@/lib/video-export-app/build-export-zip'); const built = await buildExportZip({ resolution, burnInSubtitles, locale }); ({ zipBlob, stageName, missingCount, errorCount } = built); } catch (error) { if (error instanceof NoScenesError) { toast.error(t('export.videoNoScenes'), { id: toastId }); } else { log.error('Video render (compile) failed:', error); toast.error(t('export.videoFailed'), { id: toastId }); } set({ status: 'failed', error: 'compile' }); return; } const filename = `${sanitizeFilename(stageName)}.mp4`; set({ status: 'rendering', filename }); // ETA via recent speed: track the previous (percent, timestamp) sample, // derive an instantaneous percent-per-ms rate, EMA-smooth it, and project // the remaining percent onto it. Reacts to the render's speed regime // changes (worker drop, encode) far better than a whole-run average. let lastPercent: number | null = null; let lastTs = 0; let smoothedSpeed: number | null = null; // percent per ms // Set once the service accepts the job. Lets the catch below distinguish a // *submit* failure (nothing started) from a failure after the render began // (hard error, and cancel the server job to free its slot). let submittedJobId: string | null = null; // HTTP status of a failed submit, so the catch can tell "service genuinely // unavailable" (degrade to ZIP) from a real rejection like 429/413/5xx // (surface the error instead of an unsolicited download). null = fetch threw. let submitStatus: number | null = null; try { const form = new FormData(); form.append('project', zipBlob, 'project.zip'); form.append('fps', String(fps)); form.append('quality', quality); form.append('format', 'mp4'); toast.loading(t('export.videoRendering'), { id: toastId }); const mp4 = await runPolledTask({ label: 'render-video', intervalMs: POLL_INTERVAL_MS, maxAttempts: MAX_POLL_ATTEMPTS, submit: async () => { const res = await fetch('/api/export-video/render', { method: 'POST', body: form }); const data = (await res.json().catch(() => ({}))) as { jobId?: string; error?: string; details?: string; }; if (!res.ok || !data.jobId) { submitStatus = res.status; const detail = [data.error, data.details].filter(Boolean).join(': '); return { status: 'failed', message: detail || `HTTP ${res.status}` }; } submittedJobId = data.jobId; return { status: 'submitted', taskId: data.jobId }; }, poll: async (jobId) => { const res = await fetch(`/api/export-video/render/${jobId}`); const data = (await res.json().catch(() => ({}))) as JobStatusResponse; if (!res.ok) return { status: 'failed', message: data.error || `HTTP ${res.status}` }; const percent = Math.round((data.progress ?? 0) * 100); // Derive a recent speed (percent/ms) from the delta since the last // sample, EMA-smooth it, then project the remaining percent. Only // forward progress updates the speed; stalls/regressions are ignored // so a paused stage doesn't blow the ETA up to infinity. const now = Date.now(); let etaMs = get().etaMs; if (lastPercent != null && percent > lastPercent && now > lastTs) { const sample = (percent - lastPercent) / (now - lastTs); smoothedSpeed = smoothedSpeed == null ? sample : SPEED_SMOOTHING * sample + (1 - SPEED_SMOOTHING) * smoothedSpeed; } if (percent >= ETA_MIN_PERCENT && percent < 100 && smoothedSpeed && smoothedSpeed > 0) { etaMs = (100 - percent) / smoothedSpeed; } if (lastPercent == null || percent > lastPercent) { lastPercent = percent; lastTs = now; } set({ percent, etaMs }); if (data.status === 'succeeded') { const dl = await fetch(`/api/export-video/render/${jobId}/download`); if (!dl.ok) return { status: 'failed', message: `download HTTP ${dl.status}` }; return { status: 'done', result: await dl.blob() }; } if (data.status === 'failed' || data.status === 'cancelled') { return { status: 'failed', message: data.error || data.status }; } return { status: 'pending', detail: data.currentStage }; }, }); saveAs(mp4, filename); set({ status: 'succeeded', percent: 100, etaMs: 0 }); toast.success(t('export.videoMp4Success'), { id: toastId }); if (missingCount > 0 || errorCount > 0) { toast.warning(t('export.videoWarnings', { assets: missingCount, diagnostics: errorCount })); } } catch (error) { const message = error instanceof Error ? error.message : String(error); if (submittedJobId == null) { // The submit never succeeded. Only degrade to the ZIP when the service // is genuinely unavailable — not configured (501) or unreachable (fetch // threw, submitStatus null). For real rejections (429 busy, 413 too // large, 5xx) surface the actual reason instead of silently downloading // a ZIP the user didn't ask for, so the failure is honest and retryable. const unavailable = submitStatus == null || submitStatus === 501; if (unavailable) { saveAs(zipBlob, `${sanitizeFilename(stageName)}-video.zip`); set({ status: 'idle', percent: 0, etaMs: null }); toast.info(t('export.videoServiceUnavailable'), { id: toastId }); } else { set({ status: 'failed', error: message }); toast.error(t('export.videoFailed'), { id: toastId }); } } else { // The render started but failed / timed out. Cancel the server job so it // doesn't hold a concurrency slot and scratch space, then surface the error. void fetch(`/api/export-video/render/${submittedJobId}`, { method: 'DELETE' }).catch( () => {}, ); log.error('Video render failed:', error); set({ status: 'failed', error: message }); toast.error(t('export.videoFailed'), { id: toastId }); } } }, }));