Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 7 additions & 35 deletions src/components/video-editor/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -3984,41 +3985,12 @@ export default function VideoEditor() {
smokeExportConfig.enabled && smokeExportConfig.shadowIntensity !== undefined
? smokeExportConfig.shadowIntensity
: shadowIntensity;
const smokeProgressSamples: Array<Record<string, unknown>> = [];
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
Expand Down
119 changes: 119 additions & 0 deletions src/components/video-editor/smokeExportProgress.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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]);
});
});
72 changes: 72 additions & 0 deletions src/components/video-editor/smokeExportProgress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { ExportProgress } from "@/lib/exporter/types";

const DEFAULT_SAMPLE_INTERVAL_MS = 1000;

export type SmokeExportProgressSample = {
elapsedMs: number;
phase: NonNullable<ExportProgress["phase"]> | "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,
};
}