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
3 changes: 2 additions & 1 deletion docs/DATADOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ its own, so we can see which browser and device combinations fail.
| Event | When | Properties |
|---|---|---|
| `export_started` | Export clicked and settings validated | `format`, `resolution`, `input_size`, `clip_length`, `has_text`, `has_music`, `has_image_overlay`, `is_retry`, `retry_after` (retries only: the previous failure's category) |
| `export_succeeded` | Terminal | the context above, plus `engine`, `durationMs` (click to done), `encode_ms`, `output_size` |
| `export_succeeded` | Terminal | the context above, plus `engine`, `fell_back` (true after an engine fallback), `durationMs` (click to done), `encode_ms`, `output_size` |
| `export_failed` | Terminal | the context, plus `engine`, `durationMs`, `stage` (`engine` / `encode`), `category` |
| `export_cancelled` | Terminal: the user cancelled | the context, plus `engine`, `durationMs`, `stage` |
| `engine_fallback` | The multi-threaded engine stalled; the same export restarts automatically on the single-threaded engine. Not terminal: the attempt's outcome follows | the context, plus `from: multi`, `to: single`, `stall_reason` |
| `ffmpeg_load_success` | A worker loaded the engine (once per worker) | `engine` (`multi` / `single`, what actually loaded), `engine_requested`, `single_reason` (`not_isolated` / `after_stall` / `mt_load_failed`, single-threaded only), `load_number` (`first` / `repeat`), `durationMs` |
| `ffmpeg_load_failure` | A worker failed to load (not sent when the user cancelled) | `engine_requested`, `load_number`, `category` (`network` / `integrity` / `wasm` / `worker_script` / `unknown`), `durationMs` |
| `export` (duration vital) | With `export_succeeded`; encoding time only | the context, plus `engine` |
Expand Down
9 changes: 8 additions & 1 deletion src/components/ExportOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ interface Props {
status: ExportStatus;
progress: number;
exportStartedAt?: number | null;
/** One line about a change mid-export, e.g. the engine fallback. */
notice?: string | null;
onCancel?: () => void;
}

Expand All @@ -21,7 +23,7 @@ function formatElapsed(ms: number): string {
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}

export default function ExportOverlay({ status, progress, exportStartedAt, onCancel }: Props) {
export default function ExportOverlay({ status, progress, exportStartedAt, notice, onCancel }: Props) {
const visible = status === "loading-engine" || status === "exporting";
const previousFocusRef = useRef<HTMLElement | null>(null);
const focusAnchorRef = useRef<HTMLDivElement | null>(null);
Expand Down Expand Up @@ -124,6 +126,11 @@ export default function ExportOverlay({ status, progress, exportStartedAt, onCan
? "Downloading the video engine. This only happens once."
: "Processing your video locally."}
</p>
{notice && (
<p role="status" className="text-sm text-[var(--text)] mt-2">
{notice}
</p>
)}
<p className="text-xs font-heading font-semibold text-film-600 mt-2 uppercase tracking-wide">
Do not close or refresh this tab
</p>
Expand Down
2 changes: 2 additions & 0 deletions src/components/VideoEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export default function VideoEditor() {
result,
error,
exportStartedAt,
exportNotice,
updateRecipe,
handleFileSelect,
fileError,
Expand Down Expand Up @@ -425,6 +426,7 @@ export default function VideoEditor() {
status={status}
progress={progress}
exportStartedAt={exportStartedAt}
notice={exportNotice}
onCancel={cancelExport}
/>
<OnboardingTour />
Expand Down
17 changes: 17 additions & 0 deletions src/hooks/tests/exportTelemetry.hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,23 @@ describe("export reliability telemetry in the editor", () => {
expect(JSON.stringify(rum.addAction.mock.calls)).not.toContain("holiday-clip");
});

it("an automatic fallback to the single-threaded engine is one attempt, with a notice", async () => {
const { result } = await editorWithFile();
vi.mocked(exportVideo).mockImplementationOnce(async (...args) => {
const onEngineFallback = args[7] as (reason: "no_activity") => void;
onEngineFallback("no_activity");
return okResult;
});
await act(() => result.current.handleExport());
expect(lifecycle().map((e) => e.name)).toEqual(["export_started", "export_succeeded"]);
const fallback = rum.addAction.mock.calls.find(([name]) => name === "engine_fallback");
expect(fallback?.[1]).toMatchObject({ stall_reason: "no_activity", from: "multi", to: "single" });
expect(lifecycle()[1]!.ctx).toMatchObject({ engine: "single", fell_back: true });
expect(result.current.exportNotice).toMatch(/single-threaded engine/);
// The editor passes its known duration, so the export doesn't re-read it.
expect(vi.mocked(exportVideo).mock.calls[0]![6]).toBe(12);
});

it("a cancel followed by a late rejection is one cancellation, not a failure", async () => {
const { result } = await editorWithFile();
let reject!: (e: unknown) => void;
Expand Down
10 changes: 10 additions & 0 deletions src/hooks/useVideoEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,8 @@ export function useVideoEditor() {
const [error, setError] = useState<string | null>(null);
const [fileError, setFileError] = useState("");
const [exportStartedAt, setExportStartedAt] = useState<number | null>(null);
/** Shown during an export when something changed mid-way (engine fallback). */
const [exportNotice, setExportNotice] = useState<string | null>(null);
const exportAbortControllerRef = useRef<AbortController | null>(null);
const exportCancelledRef = useRef(false);
/** The export attempt in progress, which reports its single outcome. */
Expand Down Expand Up @@ -725,6 +727,7 @@ export function useVideoEditor() {
try {
setStatus("loading-engine");
setProgress(0);
setExportNotice(null);
setError(null);
setExportStartedAt(null);
if (result?.blobUrl) URL.revokeObjectURL(result.blobUrl);
Expand Down Expand Up @@ -754,6 +757,12 @@ export function useVideoEditor() {
position: overlayPosition,
size: overlaySize,
opacity: overlayOpacity,
},
duration,
(reason) => {
// Same export, same edits, on the slower but sturdier engine.
attempt.engineFallback(reason);
setExportNotice("Switched to the single-threaded engine and started again. This takes longer.");
}
);
if (exportCancelledRef.current) return;
Expand Down Expand Up @@ -1120,6 +1129,7 @@ export function useVideoEditor() {
status,
progress,
exportStartedAt,
exportNotice,
result,
error,
videoRef,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/exportEstimate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ function resolutionMultiplier(width: number, height: number): number {

// ---------------------------------------------------------------------------
// Format overhead factor
// MP4 and MKV are close; WebM (VP9) tends to produce slightly smaller files
// MP4 and MKV are close; WebM (VP8) tends to produce slightly smaller files
// at the same CRF, so we apply a small discount.
// ---------------------------------------------------------------------------
function formatFactor(format: string | undefined): number {
Expand Down
21 changes: 19 additions & 2 deletions src/lib/exportTelemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
* Event names and properties are documented in docs/DATADOG.md.
*/
import { failureCategory, trackAction, trackVital } from "./telemetry";
import type { EngineMode } from "./ffmpegArgs";

/** Which FFmpeg core the worker loaded. */
export type EngineMode = "multi" | "single";
export type { EngineMode };

export type ExportStage = "engine" | "encode";

Expand All @@ -22,6 +22,9 @@ export type ExportFailureCategory =
| "network"
| "encode_error";

/** Which watchdog rule stopped a stalled export (see ExportStalledError). */
export type StallReason = "no_activity" | "no_progress" | "overrun";

export type EngineLoadFailureCategory = "network" | "integrity" | "wasm" | "worker_script" | "unknown";

/** Why a load ended up single-threaded. */
Expand Down Expand Up @@ -86,6 +89,7 @@ export class ExportAttempt {
private readonly startedAt = now();
private encodeStartedAt: number | null = null;
private engine: EngineMode | "none" = "none";
private fellBack = false;

constructor(
private readonly context: ExportContext,
Expand All @@ -109,10 +113,23 @@ export class ExportAttempt {
this.encodeStartedAt = now();
}

/**
* The multi-threaded engine stalled and this same attempt is being retried
* on the single-threaded one. The attempt keeps going: its one outcome is
* reported when the retry ends, with engine "single" and fell_back true.
*/
engineFallback(reason: StallReason): void {
if (this.settled) return;
trackAction("engine_fallback", { ...this.context, from: "multi", to: "single", stall_reason: reason });
this.engine = "single";
this.fellBack = true;
}

private common() {
return {
...this.context,
engine: this.engine,
fell_back: this.fellBack,
is_retry: this.retryOf !== null,
durationMs: Math.round(now() - this.startedAt),
};
Expand Down
Loading
Loading