diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 117e6724..0a59ea7a 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -147,6 +147,7 @@ import { } from "./projectPersistence"; import { SettingsPanel } from "./SettingsPanel"; import { getDevOpenRecordingConfig, getSmokeExportConfig } from "./smokeExportConfig"; +import { createSmokeExportProgressSampler } from "./smokeExportProgress"; import { APP_HEADER_ICON_BUTTON_CLASS, DiscordLinkButton, @@ -3984,41 +3985,12 @@ export default function VideoEditor() { smokeExportConfig.enabled && smokeExportConfig.shadowIntensity !== undefined ? smokeExportConfig.shadowIntensity : shadowIntensity; - const smokeProgressSamples: Array> = []; - let lastSmokeProgressSampleAt = 0; - let lastSmokeProgressPhase: ExportProgress["phase"] | undefined; - const recordSmokeProgress = (progress: ExportProgress) => { - if (!smokeExportConfig.enabled || smokeExportStartedAt === null) { - return; - } - - const now = performance.now(); - const phase = progress.phase ?? "extracting"; - const shouldSample = - smokeProgressSamples.length === 0 || - phase !== lastSmokeProgressPhase || - now - lastSmokeProgressSampleAt >= 1000 || - progress.currentFrame >= progress.totalFrames; - - if (!shouldSample) { - return; - } - - smokeProgressSamples.push({ - elapsedMs: Math.round(now - smokeExportStartedAt), - phase, - currentFrame: progress.currentFrame, - totalFrames: progress.totalFrames, - percentage: progress.percentage, - estimatedTimeRemaining: progress.estimatedTimeRemaining, - renderFps: progress.renderFps, - renderBackend: progress.renderBackend, - encodeBackend: progress.encodeBackend, - encoderName: progress.encoderName, - }); - lastSmokeProgressSampleAt = now; - lastSmokeProgressPhase = phase; - }; + const smokeProgressSampler = createSmokeExportProgressSampler({ + enabled: smokeExportConfig.enabled, + startedAtMs: smokeExportStartedAt, + }); + const smokeProgressSamples = smokeProgressSampler.samples; + const recordSmokeProgress = smokeProgressSampler.record; if (settings.format === "gif" && settings.gifConfig) { // GIF Export diff --git a/src/components/video-editor/smokeExportProgress.test.ts b/src/components/video-editor/smokeExportProgress.test.ts new file mode 100644 index 00000000..3251a218 --- /dev/null +++ b/src/components/video-editor/smokeExportProgress.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import type { ExportProgress } from "@/lib/exporter/types"; +import { createSmokeExportProgressSampler } from "./smokeExportProgress"; + +function progress(overrides: Partial = {}): ExportProgress { + return { + currentFrame: 1, + totalFrames: 100, + percentage: 1, + estimatedTimeRemaining: 10, + ...overrides, + }; +} + +describe("createSmokeExportProgressSampler", () => { + it("does not sample when smoke export is disabled", () => { + const sampler = createSmokeExportProgressSampler({ + enabled: false, + startedAtMs: 100, + now: () => 200, + }); + + sampler.record(progress()); + + expect(sampler.samples).toEqual([]); + }); + + it("does not sample before the smoke export start time is known", () => { + const sampler = createSmokeExportProgressSampler({ + enabled: true, + startedAtMs: null, + now: () => 200, + }); + + sampler.record(progress()); + + expect(sampler.samples).toEqual([]); + }); + + it("records the first progress update with the default phase", () => { + const sampler = createSmokeExportProgressSampler({ + enabled: true, + startedAtMs: 100, + now: () => 245.4, + }); + + sampler.record( + progress({ + currentFrame: 4, + totalFrames: 200, + percentage: 2, + estimatedTimeRemaining: 12, + renderFps: 58, + renderBackend: "webgpu", + encodeBackend: "webcodecs", + encoderName: "VideoEncoder", + }), + ); + + expect(sampler.samples).toEqual([ + { + elapsedMs: 145, + phase: "extracting", + currentFrame: 4, + totalFrames: 200, + percentage: 2, + estimatedTimeRemaining: 12, + renderFps: 58, + renderBackend: "webgpu", + encodeBackend: "webcodecs", + encoderName: "VideoEncoder", + }, + ]); + }); + + it("skips same-phase updates inside the sampling interval", () => { + const timestamps = [100, 500]; + const sampler = createSmokeExportProgressSampler({ + enabled: true, + startedAtMs: 0, + now: () => timestamps.shift() ?? 500, + }); + + sampler.record(progress({ currentFrame: 1, percentage: 1 })); + sampler.record(progress({ currentFrame: 2, percentage: 2 })); + + expect(sampler.samples).toHaveLength(1); + expect(sampler.samples[0]?.currentFrame).toBe(1); + }); + + it("samples phase changes and interval updates", () => { + const timestamps = [100, 200, 1200]; + const sampler = createSmokeExportProgressSampler({ + enabled: true, + startedAtMs: 0, + now: () => timestamps.shift() ?? 1200, + }); + + sampler.record(progress({ currentFrame: 1, phase: "extracting" })); + sampler.record(progress({ currentFrame: 2, phase: "finalizing" })); + sampler.record(progress({ currentFrame: 3, phase: "finalizing" })); + + expect(sampler.samples.map((sample) => sample.currentFrame)).toEqual([1, 2, 3]); + }); + + it("samples final progress even inside the sampling interval", () => { + const timestamps = [100, 250]; + const sampler = createSmokeExportProgressSampler({ + enabled: true, + startedAtMs: 0, + now: () => timestamps.shift() ?? 250, + }); + + sampler.record(progress({ currentFrame: 1, totalFrames: 10 })); + sampler.record(progress({ currentFrame: 10, totalFrames: 10 })); + + expect(sampler.samples.map((sample) => sample.currentFrame)).toEqual([1, 10]); + }); +}); diff --git a/src/components/video-editor/smokeExportProgress.ts b/src/components/video-editor/smokeExportProgress.ts new file mode 100644 index 00000000..8fabc13c --- /dev/null +++ b/src/components/video-editor/smokeExportProgress.ts @@ -0,0 +1,72 @@ +import type { ExportProgress } from "@/lib/exporter/types"; + +const DEFAULT_SAMPLE_INTERVAL_MS = 1000; + +export type SmokeExportProgressSample = { + elapsedMs: number; + phase: NonNullable | "extracting"; + currentFrame: number; + totalFrames: number; + percentage: number; + estimatedTimeRemaining: number; + renderFps?: number; + renderBackend?: ExportProgress["renderBackend"]; + encodeBackend?: ExportProgress["encodeBackend"]; + encoderName?: string; +}; + +type SmokeExportProgressSamplerOptions = { + enabled: boolean; + startedAtMs: number | null; + now?: () => number; + sampleIntervalMs?: number; +}; + +export function createSmokeExportProgressSampler({ + enabled, + startedAtMs, + now = () => performance.now(), + sampleIntervalMs = DEFAULT_SAMPLE_INTERVAL_MS, +}: SmokeExportProgressSamplerOptions) { + const samples: SmokeExportProgressSample[] = []; + let lastSampleAt = 0; + let lastSamplePhase: ExportProgress["phase"] | "extracting" | undefined; + + const record = (progress: ExportProgress) => { + if (!enabled || startedAtMs === null) { + return; + } + + const timestamp = now(); + const phase = progress.phase ?? "extracting"; + const shouldSample = + samples.length === 0 || + phase !== lastSamplePhase || + timestamp - lastSampleAt >= sampleIntervalMs || + progress.currentFrame >= progress.totalFrames; + + if (!shouldSample) { + return; + } + + samples.push({ + elapsedMs: Math.round(timestamp - startedAtMs), + phase, + currentFrame: progress.currentFrame, + totalFrames: progress.totalFrames, + percentage: progress.percentage, + estimatedTimeRemaining: progress.estimatedTimeRemaining, + renderFps: progress.renderFps, + renderBackend: progress.renderBackend, + encodeBackend: progress.encodeBackend, + encoderName: progress.encoderName, + }); + lastSampleAt = timestamp; + lastSamplePhase = phase; + }; + + return { + record, + samples, + }; +}