Skip to content
Open
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
30 changes: 29 additions & 1 deletion src/components/TrimControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props)
recipe.trimStart.toString()
);

const { waveform, isLoading: waveformLoading } = useAudioWaveform(file);
const {
waveform,
status: waveformStatus,
isLoading: waveformLoading,
} = useAudioWaveform(file);
const hasAudio = waveform.length > 0;

useEffect(() => {
Expand Down Expand Up @@ -235,6 +239,30 @@ export default function TrimControl({ recipe, onChange, duration, file }: Props)
/>
</div>
)}

{file && (
<div className="space-y-1">
{waveformStatus === "disabled" ? (
<div
role="img"
aria-label="Audio waveform preview disabled for large file"
className="flex h-16 w-full items-center justify-center rounded-md border border-[var(--border)] bg-[var(--surface)] px-3"
>
<p className="font-heading text-center text-[10px] text-[var(--muted)]">
Waveform preview is disabled for very large files to keep the
editor responsive.
</p>
</div>
) : (
<WaveformCanvas
samples={waveform}
loading={waveformLoading}
hasAudio={hasAudio}
/>
)}
</div>
)}

<div className="flex gap-3">
<div className="flex-1">
<label
Expand Down
78 changes: 78 additions & 0 deletions src/components/__tests__/TrimControl.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import "@testing-library/jest-dom/vitest";
import { render, screen } from "@testing-library/react";
import TrimControl from "../TrimControl";
import { DEFAULT_RECIPE } from "@/lib/constants";
import type { UseAudioWaveformResult } from "@/hooks/useAudioWaveform";

vi.mock("@/hooks/useAudioWaveform", () => ({
useAudioWaveform: vi.fn(),
}));

import { useAudioWaveform } from "@/hooks/useAudioWaveform";

const mockedHook = vi.mocked(useAudioWaveform);

function setWaveform(result: UseAudioWaveformResult) {
mockedHook.mockReturnValue(result);
}

function renderTrim(file: File | null) {
return render(
<TrimControl
recipe={DEFAULT_RECIPE}
onChange={() => {}}
duration={10}
file={file}
/>
);
}

const sampleFile = new File(["video-bytes"], "clip.mp4", { type: "video/mp4" });

beforeEach(() => {
mockedHook.mockReset();
});

describe("TrimControl waveform fallback", () => {
it("renders the disabled placeholder for very large files", () => {
setWaveform({ waveform: [], status: "disabled", isLoading: false });

renderTrim(sampleFile);

expect(
screen.getByLabelText("Audio waveform preview disabled for large file")
).toBeInTheDocument();
expect(
screen.getByText(/disabled for very large files/i)
).toBeInTheDocument();
// The canvas waveform must not render in the disabled state.
expect(screen.queryByLabelText("Audio waveform")).not.toBeInTheDocument();
});

it("renders the waveform canvas for normal files", () => {
setWaveform({
waveform: [0.1, 0.5, 0.9],
status: "ready",
isLoading: false,
});

renderTrim(sampleFile);

expect(screen.getByLabelText("Audio waveform")).toBeInTheDocument();
expect(
screen.queryByText(/disabled for very large files/i)
).not.toBeInTheDocument();
});

it("does not render any waveform UI when no file is selected", () => {
setWaveform({ waveform: [], status: "idle", isLoading: false });

renderTrim(null);

expect(
screen.queryByText(/disabled for very large files/i)
).not.toBeInTheDocument();
expect(screen.queryByLabelText("Audio waveform")).not.toBeInTheDocument();
});
});
133 changes: 133 additions & 0 deletions src/hooks/__tests__/useAudioWaveform.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, waitFor, act } from "@testing-library/react";
import { useAudioWaveform } from "../useAudioWaveform";
import { MAX_WAVEFORM_FILE_SIZE_BYTES } from "@/lib/constants";

// --- Test doubles -----------------------------------------------------------

type FakeAudioBuffer = { getChannelData: () => Float32Array };

class FakeAudioContext {
static instances: FakeAudioContext[] = [];
static decodeImpl: () => Promise<FakeAudioBuffer> = async () => ({
getChannelData: () => new Float32Array([0, 0.25, 0.5, 0.75, 1]),
});

decodeAudioData = vi.fn(() => FakeAudioContext.decodeImpl());
close = vi.fn(async () => {});

constructor() {
FakeAudioContext.instances.push(this);
}
}

/**
* Builds a minimal File stand-in. The hook only touches `file.size` and
* `file.slice(...).arrayBuffer()`, so we expose spies for both to assert that
* the full file is never read for oversized inputs.
*/
function makeFile(size: number, readBytes: () => Promise<ArrayBuffer>) {
const arrayBuffer = vi.fn(readBytes);
const slice = vi.fn(() => ({ arrayBuffer }) as unknown as Blob);
return { size, slice, arrayBuffer } as unknown as File;
}

function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}

beforeEach(() => {
FakeAudioContext.instances = [];
FakeAudioContext.decodeImpl = async () => ({
getChannelData: () => new Float32Array([0, 0.25, 0.5, 0.75, 1]),
});
vi.stubGlobal("AudioContext", FakeAudioContext);
});

afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});

// --- Tests ------------------------------------------------------------------

describe("useAudioWaveform", () => {
it("is idle when no file is provided", async () => {
const { result } = renderHook(() => useAudioWaveform(null));
await waitFor(() => expect(result.current.status).toBe("idle"));
expect(result.current.waveform).toEqual([]);
expect(result.current.isLoading).toBe(false);
});

it("extracts a waveform for files within the size threshold", async () => {
const file = makeFile(1024, () => Promise.resolve(new ArrayBuffer(8)));

const { result } = renderHook(() => useAudioWaveform(file, 5));

await waitFor(() => expect(result.current.status).toBe("ready"));
expect(result.current.waveform).toHaveLength(5);
expect(result.current.isLoading).toBe(false);
// Reads through a bounded slice, never the raw File in one shot.
expect(file.slice).toHaveBeenCalledWith(0, MAX_WAVEFORM_FILE_SIZE_BYTES);
expect(FakeAudioContext.instances).toHaveLength(1);
});

it("skips extraction and reports 'disabled' for very large files", async () => {
const file = makeFile(MAX_WAVEFORM_FILE_SIZE_BYTES + 1, () =>
Promise.resolve(new ArrayBuffer(8))
);

const { result } = renderHook(() => useAudioWaveform(file));

await waitFor(() => expect(result.current.status).toBe("disabled"));
expect(result.current.waveform).toEqual([]);
// The whole point of #1013: the oversized file is never read into memory.
expect(file.slice).not.toHaveBeenCalled();
expect(file.arrayBuffer).not.toHaveBeenCalled();
expect(FakeAudioContext.instances).toHaveLength(0);
});

it("respects a custom size threshold", async () => {
const file = makeFile(2048, () => Promise.resolve(new ArrayBuffer(8)));

const { result } = renderHook(() => useAudioWaveform(file, 96, 1024));

await waitFor(() => expect(result.current.status).toBe("disabled"));
expect(file.slice).not.toHaveBeenCalled();
});

it("reports 'error' when decoding fails", async () => {
FakeAudioContext.decodeImpl = () => Promise.reject(new Error("bad audio"));
const file = makeFile(1024, () => Promise.resolve(new ArrayBuffer(8)));

const { result } = renderHook(() => useAudioWaveform(file));

await waitFor(() => expect(result.current.status).toBe("error"));
expect(result.current.waveform).toEqual([]);
});

it("cancels in-flight work and does not update state after unmount", async () => {
const gate = deferred<ArrayBuffer>();
const file = makeFile(1024, () => gate.promise);

const { result, unmount } = renderHook(() => useAudioWaveform(file));

await waitFor(() => expect(result.current.status).toBe("loading"));

unmount();

// Resolve the pending read *after* unmount — the hook must bail out before
// creating an AudioContext or advancing to "ready".
await act(async () => {
gate.resolve(new ArrayBuffer(8));
await Promise.resolve();
});

expect(result.current.status).toBe("loading");
expect(FakeAudioContext.instances).toHaveLength(0);
});
});
73 changes: 59 additions & 14 deletions src/hooks/useAudioWaveform.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
"use client";

import { useEffect, useState } from "react";
import { MAX_WAVEFORM_FILE_SIZE_BYTES } from "@/lib/constants";

const DEFAULT_BAR_COUNT = 96;

/**
* Lifecycle of a waveform extraction request:
* - `idle` — no file selected.
* - `loading` — reading/decoding audio.
* - `ready` — `waveform` holds the downsampled peaks.
* - `disabled` — file exceeds the size threshold; extraction was skipped to
* keep memory bounded (#1013). UI should show a placeholder.
* - `error` — decoding failed or Web Audio is unavailable.
*/
export type WaveformStatus = "idle" | "loading" | "ready" | "disabled" | "error";

export interface UseAudioWaveformResult {
waveform: number[];
status: WaveformStatus;
/** Convenience flag, retained for backward compatibility. */
isLoading: boolean;
}

type BrowserWindow = Window &
typeof globalThis & {
webkitAudioContext?: typeof AudioContext;
Expand All @@ -27,12 +46,23 @@ function downsampleWaveform(channelData: Float32Array, barCount: number): number
return peaks.map((peak) => peak / maxPeak);
}

/**
* Extracts a low-resolution audio waveform from a media file.
*
* Memory safety (#1013): files larger than `maxFileSizeBytes` are not read into
* memory at all — the hook reports `status: "disabled"` and the caller renders a
* placeholder instead. Within the threshold the audio is read through a bounded
* `Blob.slice` so the amount of data handed to `decodeAudioData` never exceeds
* the threshold, even if the underlying file grows unexpectedly. Small files
* (the common case) are read in full exactly as before — no behavioural change.
*/
export function useAudioWaveform(
file: File | null,
barCount = DEFAULT_BAR_COUNT
) {
barCount = DEFAULT_BAR_COUNT,
maxFileSizeBytes = MAX_WAVEFORM_FILE_SIZE_BYTES
): UseAudioWaveformResult {
const [waveform, setWaveform] = useState<number[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [status, setStatus] = useState<WaveformStatus>("idle");

useEffect(() => {
let isCancelled = false;
Expand All @@ -41,7 +71,14 @@ export function useAudioWaveform(
async function extractWaveform() {
if (!file) {
setWaveform([]);
setIsLoading(false);
setStatus("idle");
return;
}

// Hard memory guard: never pull a multi-gigabyte file into the heap.
if (file.size > maxFileSizeBytes) {
setWaveform([]);
setStatus("disabled");
return;
}

Expand All @@ -50,41 +87,49 @@ export function useAudioWaveform(

if (!AudioContextCtor) {
setWaveform([]);
setIsLoading(false);
setStatus("error");
return;
}

setIsLoading(true);
setWaveform([]);
setStatus("loading");

try {
// Read at most `maxFileSizeBytes`. For files within the threshold this
// is the whole file (unchanged); the slice bounds the allocation so we
// never decode more than the threshold's worth of bytes.
const audioBytes = await file.slice(0, maxFileSizeBytes).arrayBuffer();
if (isCancelled) return;

audioContext = new AudioContextCtor();
const audioBuffer = await audioContext.decodeAudioData(
await file.arrayBuffer()
);
const audioBuffer = await audioContext.decodeAudioData(audioBytes);
if (isCancelled) return;

const channelData = audioBuffer.getChannelData(0);
const peaks = downsampleWaveform(channelData, barCount);

if (!isCancelled) {
setWaveform(peaks);
setStatus("ready");
}
} catch {
if (!isCancelled) {
setWaveform([]);
setStatus("error");
}
} finally {
await audioContext?.close();
if (!isCancelled) {
setIsLoading(false);
}
}
}

extractWaveform();

// Cancellation: a new file, a changed bar count, or an unmount stops the
// in-flight read/decode and prevents state updates on a dead component.
return () => {
isCancelled = true;
};
}, [barCount, file]);
}, [barCount, file, maxFileSizeBytes]);

return { waveform, isLoading };
return { waveform, status, isLoading: status === "loading" };
}
Loading