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
1 change: 1 addition & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ interface Window {
startNativeMacRecording: (
request: import("../src/lib/nativeMacRecording").NativeMacRecordingRequest,
) => Promise<import("../src/lib/nativeMacRecording").NativeMacRecordingStartResult>;
onNativeMacSystemAudioUnavailable: (callback: () => void) => () => void;
pauseNativeMacRecording: () => Promise<{
success: boolean;
error?: string;
Expand Down
32 changes: 21 additions & 11 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1605,13 +1605,15 @@ function inspectNativeMacCaptureOutput() {
function attachNativeMacCaptureOutputDrain(
proc: ChildProcessWithoutNullStreams,
onTakeEnded: () => void,
onSystemAudioUnavailable: () => void,
) {
let lineBuffer = "";
// Hooked here rather than on `nativeMacCaptureEvents`, which the start wait
// replays from the buffer: the drain sees each line once, live.
const watchLiveTake = createNativeMacMidCaptureErrorWatch(
() => nativeMacCaptureProcess === proc && !nativeMacStopInFlight,
onTakeEnded,
onSystemAudioUnavailable,
);
const drain = (chunk: Buffer) => {
const text = chunk.toString();
Expand Down Expand Up @@ -2287,12 +2289,11 @@ export function registerIpcHandlers(
return access;
}

// The helper that answered has just raised macOS' own Accessibility prompt on
// its way up, so the window must offer System Settings, not a second prompt. It
// explains what the grant is for and tracks it live, where a message box could
// only say "go to System Settings" in English.
// Accessibility improves cursor shape hints but is not required to record.
// Remember that the helper raised the system prompt so a later visit to the
// permissions window can direct the user to Settings, but don't reopen that
// window from every Record press.
getMacPermissions().noteRequested("accessibility");
showPermissionsWindow();
}

return access;
Expand Down Expand Up @@ -3108,12 +3109,21 @@ export function registerIpcHandlers(
// When the take ends without the user — the helper reported an error or
// exited — this drives the renderer's own stop, the same one the tray's Stop
// Recording sends: it clears the HUD and surfaces the result.
attachNativeMacCaptureOutputDrain(proc, () => {
const hudWindow = getMainWindow();
if (hudWindow && !hudWindow.isDestroyed()) {
hudWindow.webContents.send("stop-recording-from-tray");
}
});
attachNativeMacCaptureOutputDrain(
proc,
() => {
const hudWindow = getMainWindow();
if (hudWindow && !hudWindow.isDestroyed()) {
hudWindow.webContents.send("stop-recording-from-tray");
}
},
() => {
const hudWindow = getMainWindow();
if (hudWindow && !hudWindow.isDestroyed()) {
hudWindow.webContents.send("native-mac-system-audio-unavailable");
}
},
);

await waitForNativeMacCaptureStart(proc);
const captureStartedAtMs = Date.now();
Expand Down
44 changes: 37 additions & 7 deletions electron/ipc/nativeMacMidCaptureErrorWatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ describe("createNativeMacMidCaptureErrorWatch", () => {

it("fires on an error raised after recording started", () => {
const onError = vi.fn();
const watch = createNativeMacMidCaptureErrorWatch(() => true, onError);
const watch = createNativeMacMidCaptureErrorWatch(() => true, onError, vi.fn());

watch({ event: "ready" });
watch({ event: "recording-started" });
Expand All @@ -17,7 +17,7 @@ describe("createNativeMacMidCaptureErrorWatch", () => {

it("leaves an error raised before recording started to the start wait", () => {
const onError = vi.fn();
const watch = createNativeMacMidCaptureErrorWatch(() => true, onError);
const watch = createNativeMacMidCaptureErrorWatch(() => true, onError, vi.fn());

watch(error);

Expand All @@ -26,7 +26,7 @@ describe("createNativeMacMidCaptureErrorWatch", () => {

it("ignores events that are not errors", () => {
const onError = vi.fn();
const watch = createNativeMacMidCaptureErrorWatch(() => true, onError);
const watch = createNativeMacMidCaptureErrorWatch(() => true, onError, vi.fn());

watch({ event: "recording-started" });
watch({ event: "warning", code: "stop-capture-failed" });
Expand All @@ -38,7 +38,7 @@ describe("createNativeMacMidCaptureErrorWatch", () => {
it("ignores a helper that is no longer the current process", () => {
const onError = vi.fn();
let current = true;
const watch = createNativeMacMidCaptureErrorWatch(() => current, onError);
const watch = createNativeMacMidCaptureErrorWatch(() => current, onError, vi.fn());

watch({ event: "recording-started" });
current = false;
Expand All @@ -50,7 +50,7 @@ describe("createNativeMacMidCaptureErrorWatch", () => {
/** Killed or crashed: no error line ever comes, only the process closing. */
it("fires when the helper exits in the middle of a take", () => {
const onTakeEnded = vi.fn();
const watch = createNativeMacMidCaptureErrorWatch(() => true, onTakeEnded);
const watch = createNativeMacMidCaptureErrorWatch(() => true, onTakeEnded, vi.fn());

watch({ event: "recording-started" });
watch.exited();
Expand All @@ -60,7 +60,7 @@ describe("createNativeMacMidCaptureErrorWatch", () => {

it("leaves an exit before recording started to the start wait", () => {
const onTakeEnded = vi.fn();
const watch = createNativeMacMidCaptureErrorWatch(() => true, onTakeEnded);
const watch = createNativeMacMidCaptureErrorWatch(() => true, onTakeEnded, vi.fn());

watch.exited();

Expand All @@ -71,12 +71,42 @@ describe("createNativeMacMidCaptureErrorWatch", () => {
it("ignores the exit of a take that is already being stopped", () => {
const onTakeEnded = vi.fn();
let live = true;
const watch = createNativeMacMidCaptureErrorWatch(() => live, onTakeEnded);
const watch = createNativeMacMidCaptureErrorWatch(() => live, onTakeEnded, vi.fn());

watch({ event: "recording-started" });
live = false;
watch.exited();

expect(onTakeEnded).not.toHaveBeenCalled();
});

it("surfaces a system-audio warning once without ending the take", () => {
const onTakeEnded = vi.fn();
const onSystemAudioUnavailable = vi.fn();
const watch = createNativeMacMidCaptureErrorWatch(
() => true,
onTakeEnded,
onSystemAudioUnavailable,
);

watch({ event: "recording-started" });
watch({ event: "warning", code: "system-audio-unavailable" });
watch({ event: "warning", code: "system-audio-unavailable" });

expect(onSystemAudioUnavailable).toHaveBeenCalledOnce();
expect(onTakeEnded).not.toHaveBeenCalled();
});

it("does not surface the system-audio warning for a take that is no longer live", () => {
const onSystemAudioUnavailable = vi.fn();
const watch = createNativeMacMidCaptureErrorWatch(
() => false,
vi.fn(),
onSystemAudioUnavailable,
);

watch({ event: "warning", code: "system-audio-unavailable" });

expect(onSystemAudioUnavailable).not.toHaveBeenCalled();
});
});
11 changes: 11 additions & 0 deletions electron/ipc/nativeMacMidCaptureErrorWatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,23 @@
export function createNativeMacMidCaptureErrorWatch(
isLiveTake: () => boolean,
onTakeEnded: () => void,
onSystemAudioUnavailable: () => void,
) {
let recordingStarted = false;
let reportedSystemAudioUnavailable = false;
const watch = (event: Record<string, unknown>) => {
if (event.event === "recording-started") {
recordingStarted = true;
}
if (
event.event === "warning" &&
event.code === "system-audio-unavailable" &&
!reportedSystemAudioUnavailable &&
isLiveTake()
) {
reportedSystemAudioUnavailable = true;
onSystemAudioUnavailable();
}
if (event.event === "error" && recordingStarted && isLiveTake()) {
onTakeEnded();
}
Expand Down
5 changes: 5 additions & 0 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,11 @@ contextBridge.exposeInMainWorld("electronAPI", {
startNativeMacRecording: (request: NativeMacRecordingRequest) => {
return ipcRenderer.invoke("start-native-mac-recording", request);
},
onNativeMacSystemAudioUnavailable: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on("native-mac-system-audio-unavailable", listener);
return () => ipcRenderer.removeListener("native-mac-system-audio-unavailable", listener);
},
pauseNativeMacRecording: () => {
return ipcRenderer.invoke("pause-native-mac-recording");
},
Expand Down
3 changes: 2 additions & 1 deletion src/components/ai-edition/v4/FloatingInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ export function ZoomLevelControl({
};

const presets = ZOOM_PRESETS.filter((preset) => preset.value <= maxScale);
const hasSelectedPreset = presets.some((preset) => preset.value === requested);

return (
<>
Expand All @@ -519,7 +520,7 @@ export function ZoomLevelControl({
type="text"
inputMode="decimal"
aria-label={ts("zoom.customScale")}
placeholder={`${requested}×`}
placeholder={hasSelectedPreset ? "" : `${requested}×`}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commitDraft}
Expand Down
28 changes: 27 additions & 1 deletion src/components/ai-edition/v4/MediaStage.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import { addSelectedAssetToTimeline } from "./MediaStage";
import type { AxcutTranscript } from "@/lib/ai-edition/schema";
import { addSelectedAssetToTimeline, detectedLanguageForTranscript } from "./MediaStage";

function transcript(language: string, text = ""): AxcutTranscript {
return {
assetId: "asset-1",
language,
segments: text
? [{ id: "segment-1", kind: "speech", startSec: 0, endSec: 1, text, wordIds: [] }]
: [],
words: [],
};
}

describe("detectedLanguageForTranscript", () => {
it("hides the detected language when the transcript has no speech", () => {
expect(detectedLanguageForTranscript(transcript("en"))).toBeNull();
});

it("shows the detected language when the transcript contains speech", () => {
expect(detectedLanguageForTranscript(transcript("en", "hello there"))).toBe("en");
});

it("does not show automatic language selection as a detected language", () => {
expect(detectedLanguageForTranscript(transcript("auto", "hello there"))).toBeNull();
});
});

describe("addSelectedAssetToTimeline", () => {
it("reports success only once the selected asset has been added", async () => {
Expand Down
15 changes: 12 additions & 3 deletions src/components/ai-edition/v4/MediaStage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ArrowDown, Film, Plus, RotateCw, Search, X } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import type { AxcutAsset, TranscriptLanguageCode } from "@/lib/ai-edition/schema";
import type { AxcutAsset, AxcutTranscript, TranscriptLanguageCode } from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import {
useAssetTranscriptions,
Expand All @@ -17,6 +17,7 @@ import {
type AssetTranscriptionStatus,
type AssetTranscriptionView,
isSilentFailure,
transcriptHasSpeech,
} from "@/lib/ai-edition/transcription/status";
import { formatBytes } from "@/utils/formatBytes";
import {
Expand All @@ -38,6 +39,13 @@ function basename(path: string): string {
return path.split(/[\\/]/).pop() ?? path;
}

export function detectedLanguageForTranscript(transcript: AxcutTranscript | null): string | null {
if (!transcript || transcript.language === "auto" || !transcriptHasSpeech(transcript)) {
return null;
}
return transcript.language;
}

export async function addSelectedAssetToTimeline(
selected: Pick<AxcutAsset, "id" | "label" | "originalPath"> | null,
onAddToTimeline: (assetId: string) => Promise<void>,
Expand Down Expand Up @@ -90,6 +98,7 @@ export function MediaStage({
const transcript = selected
? (document?.transcripts?.find((t) => t.assetId === selected.id) ?? null)
: null;
const detectedLanguage = detectedLanguageForTranscript(transcript);
const selectedTranscription: AssetTranscriptionView = selected
? (transcriptions[selected.id] ?? { assetId: selected.id, status: "idle" })
: { assetId: "", status: "idle" };
Expand Down Expand Up @@ -337,7 +346,7 @@ export function MediaStage({
reaching the document and being displayed nowhere. It belongs next to
"Regenerate as" below in any case: that selector is the control
you set BECAUSE of what was detected. */}
{transcript?.language && transcript.language !== "auto" ? (
{detectedLanguage ? (
<span
style={{
display: "inline-flex",
Expand All @@ -352,7 +361,7 @@ export function MediaStage({
}}
>
{t("mediaStage.detectedLanguage", {
language: languageLabel(transcript.language, locale),
language: languageLabel(detectedLanguage, locale),
})}
</span>
) : null}
Expand Down
5 changes: 3 additions & 2 deletions src/components/ai-edition/v4/ZoomLevelControl.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe("ZoomLevelControl", () => {
updateZoomCustomScale.mockClear();
});

it("renders four presets, labelled with the table value, current one pressed", () => {
it("renders the selected preset without repeating its value in the custom-scale field", () => {
const { buttons, field } = renderControl(3);
expect(buttons.map((b) => b.textContent)).toEqual(
([2, 3, 4, 5] as const).map((d) => `${ZOOM_DEPTH_SCALES[d]}×`),
Expand All @@ -84,7 +84,8 @@ describe("ZoomLevelControl", () => {
"false",
"false",
]);
expect(field).toHaveAttribute("placeholder", "1.8×");
expect(field).toBeInTheDocument();
expect(field).toHaveAttribute("placeholder", "");
});

// The ends of the table and every custom level live in the field, not the row.
Expand Down
Loading
Loading