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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ OpenScreen is a free, open-source screen recorder and video editor (Electron + R
- Install deps: `npm install` (Node 22.22.1, npm 10.9.4 — see `package.json#engines`)
- Start dev: `npm run dev` (Vite dev server; Electron window opens via `vite-plugin-electron`)
- Build: `npm run build` (TypeScript check + Vite build + electron-builder)
- Typecheck: `npx tsc --noEmit` (CI runs this; no standalone script)
- Typecheck: `npx tsc --noEmit` — app code only. CI also runs `npx tsc -p tsconfig.test.json --noEmit` in a separate job ("Typecheck (tests)"), so **run both**: test files are invisible to the root config, and a type error in a `*.test.ts` fails CI while the root check stays green.
- Test (unit): `npm run test` (Vitest, jsdom env)
- Test (browser): `npm run test:browser` (Vitest + Playwright, requires `npm run test:browser:install` first)
- Test (e2e): `npm run test:e2e` (Playwright)
Expand Down
20 changes: 20 additions & 0 deletions electron/stt/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,26 @@ describe("SttManager", () => {
expect(fakeWhisperServer.stop).toHaveBeenCalledOnce();
});

it("retries setup after a failed one instead of caching the rejection", async () => {
// First run downloads a 253 MB model. Caching a rejected `prepare()` meant
// one dropped connection failed every later transcription in the session —
// including the retry the editor offers — until the app was restarted.
const { ensureModels } = await import("./modelManager");
const mocked = vi.mocked(ensureModels);
mocked.mockClear();
mocked.mockRejectedValueOnce(new Error("Failed to download: network unreachable"));
const mgr = new SttManager();

await expect(mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" })).rejects.toThrow(
"network unreachable",
);
// The network came back: the next attempt must actually attempt.
await mgr.init({ modelsBaseDir: "/tmp/fake-stt-models" });

expect(mocked).toHaveBeenCalledTimes(2);
expect(fakeWhisperServer.start).toHaveBeenCalledOnce();
});

it("setStatusSink replaces the previous sink (last call wins)", () => {
const mgr = new SttManager();
const a = vi.fn();
Expand Down
13 changes: 12 additions & 1 deletion electron/stt/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,18 @@ export class SttManager {
if (options.statusSink) this.statusSink = options.statusSink;
if (options.modelsBaseDir) this.modelsBaseDir = options.modelsBaseDir;
if (!this.initPromise) {
this.initPromise = this.prepare();
// A REJECTED init must not be cached. `prepare()` downloads a 253 MB
// model on first run, and caching its rejection meant one dropped
// connection poisoned the whole app session: every later transcription
// — including the retry the UI offers, and every remaining asset in the
// auto-transcription queue — awaited the same stale rejection and failed
// in milliseconds, with no way back short of quitting the app.
// Reconnecting the network changed nothing. Clearing the slot on failure
// makes the next attempt a real attempt.
this.initPromise = this.prepare().catch((error) => {
this.initPromise = null;
throw error;
});
}
return this.initPromise;
}
Expand Down
129 changes: 129 additions & 0 deletions src/components/ai-edition/CaptionsPane.gating.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Captions are a view of the transcript, so the pane's "Transcribe video"
// button is a retry, not a first step — the background pass has already tried.
// On a media with no audio track that retry can only fail again, so the button
// has to be dead and the pane has to say what is wrong instead of inviting a
// pointless click.

import "@testing-library/jest-dom";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { I18nProvider } from "@/contexts/I18nContext";
import type { AxcutAsset, AxcutDocument } from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { useTranscriptionStore } from "@/lib/ai-edition/store/transcriptionStore";
import { CaptionsPane } from "./CaptionsPane";

vi.mock("@/native", () => ({ nativeBridgeClient: { aiEdition: {} } }));
vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: {} } }));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

function documentWith(asset: AxcutAsset): AxcutDocument {
return {
schemaVersion: 7,
project: {
id: "proj_1",
title: "Test",
createdAt: "2026-06-25T10:00:00.000Z",
updatedAt: "2026-06-25T10:00:00.000Z",
primaryAssetId: asset.id,
},
assets: [asset],
transcript: null,
transcripts: [],
timeline: {
clips: [
{
id: "clip_1",
assetId: asset.id,
sourceStartSec: 0,
sourceEndSec: 12,
timelineStartSec: 0,
timelineEndSec: 12,
wordRefs: [],
origin: "user",
reason: "",
},
],
gaps: [],
trimRanges: [],
muteRanges: [],
speedRanges: [],
captionRanges: [],
},
annotations: [],
zoomRanges: [],
legacyEditor: null,
} as unknown as AxcutDocument;
}

const ASSET: AxcutAsset = {
id: "asset_1",
kind: "video",
label: "recording.mp4",
originalPath: "/rec.mp4",
durationSec: 12,
cameraTrack: null,
};

function load(document: AxcutDocument) {
useProjectStore.setState({
projectId: document.project.id,
document,
status: "ready",
error: null,
dirty: false,
});
}

beforeEach(() => {
useTranscriptionStore.getState().reset();
useProjectStore.getState().clear();
});

afterEach(() => {
cleanup();
});

describe("captions pane gating", () => {
it("offers the retry while the media might still yield a transcript", () => {
load(documentWith(ASSET));
render(
<I18nProvider>
<CaptionsPane />
</I18nProvider>,
);
expect(screen.getByRole("button", { name: "Transcribe video" })).toBeEnabled();
});

it("shows the queued background run instead of an idle button", () => {
load(documentWith(ASSET));
useTranscriptionStore.setState({
projectId: "proj_1",
jobs: { asset_1: { status: "running", language: "auto", manual: false } },
});
render(
<I18nProvider>
<CaptionsPane />
</I18nProvider>,
);
expect(screen.getByRole("button", { name: "Transcribing…" })).toBeDisabled();
});

it("kills the retry on a media with no audio track and explains it", () => {
load(
documentWith({
...ASSET,
transcriptionFailure: { kind: "no-audio", message: "No audio track found in this video." },
}),
);
render(
<I18nProvider>
<CaptionsPane />
</I18nProvider>,
);
expect(screen.getByRole("button", { name: "Transcribe video" })).toBeDisabled();
expect(
screen.getByText("This media has no audio track — there is nothing to transcribe."),
).toBeInTheDocument();
});
});
47 changes: 36 additions & 11 deletions src/components/ai-edition/CaptionsPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import { useScopedT } from "@/contexts/I18nContext";
import type { CaptionTextAlign, CaptionVerticalPosition } from "@/lib/ai-edition/captions";
import { untranslatedUnits } from "@/lib/ai-edition/captions";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import {
useTimelineTranscriptGate,
useTranscriptionStore,
} from "@/lib/ai-edition/store/transcriptionStore";
import { useCaptions } from "@/lib/ai-edition/store/useCaptions";
import { nativeBridgeClient } from "@/native";
import { ColorField } from "./ColorField";
Expand Down Expand Up @@ -62,15 +66,9 @@ const TRANSLATION_LANGUAGES: ReadonlyArray<{ code: string; label: string }> = [
{ code: "zh", label: "中文" },
];

interface CaptionsPaneProps {
/** Runs the local Whisper pipeline for the primary asset — owned by the
* shell, which already has the toast + per-asset status plumbing. */
onTranscribe: () => void;
isTranscribing: boolean;
}

export function CaptionsPane({ onTranscribe, isTranscribing }: CaptionsPaneProps) {
export function CaptionsPane() {
const t = useScopedT("settings");
const te = useScopedT("editor");
const {
settings,
translations,
Expand All @@ -85,6 +83,20 @@ export function CaptionsPane({ onTranscribe, isTranscribing }: CaptionsPaneProps
} = useCaptions();
const document = useProjectStore((s) => s.document);
const saveDocument = useProjectStore((s) => s.saveDocument);
// Captions are a view of the transcript, and the transcript arrives on its
// own (transcriptionStore's background pass). The pane reads that state
// straight from the store rather than being handed a busy flag: it is the
// same answer everywhere, and "Transcribe" here is only ever a retry.
//
// Resolved over the timeline's assets, not the primary one: `hasTranscript`
// below is already timeline-scoped (useCaptions), and mixing the two scopes
// is what let a silent primary asset dead-end this button for a project whose
// actual footage had speech.
const gate = useTimelineTranscriptGate();
const requestTimelineTranscripts = useTranscriptionStore((s) => s.requestTimelineTranscripts);
const isTranscribing = gate.state === "pending";
const silentMedia = gate.state === "blocked" && gate.reason === "no-audio";
const engineError = gate.state === "blocked" && gate.reason === "failed" ? gate.message : null;

const [target, setTarget] = useState<string>(TRANSLATION_LANGUAGES[1].code);
const [translating, setTranslating] = useState(false);
Expand Down Expand Up @@ -191,13 +203,26 @@ export function CaptionsPane({ onTranscribe, isTranscribing }: CaptionsPaneProps
}}
>
<p style={{ margin: 0, font: "400 12px/1.5 var(--font-body)", color: "var(--muted)" }}>
{t("captions.noTranscript")}
{silentMedia ? te("mediaStage.noAudioTrackHint") : t("captions.noTranscript")}
</p>
{engineError ? (
<p
style={{
margin: 0,
font: "400 11.5px/1.5 var(--font-body)",
color: "var(--danger)",
}}
>
{engineError}
</p>
) : null}
<button
type="button"
className={`${styles.btn} ${styles.btnPrimary}`}
disabled={disabled || isTranscribing}
onClick={onTranscribe}
// A media with no audio track has nothing to transcribe — the
// button would fail the same way every time it is pressed.
disabled={disabled || isTranscribing || silentMedia}
onClick={() => void requestTimelineTranscripts()}
>
{isTranscribing ? <Loader2 size={14} className="animate-spin" /> : null}
{isTranscribing ? t("captions.transcribing") : t("captions.transcribe")}
Expand Down
Loading
Loading