Files
openmaic/OpenMAIC/lib/store/video-render.ts
2026-08-16 14:58:47 +08:00

273 lines
11 KiB
TypeScript

'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, unknown>) => 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<RenderOptions>;
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<ResolvedOptions>) => 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<void>;
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<VideoRenderState>()((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<Blob>({
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 });
}
}
},
}));