From 7b2137a11a3377b84fcbd4261049e5fe5985cb99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E8=B6=85=E7=BA=A7?= =?UTF-8?q?=E5=96=9C=E6=AC=A2=E4=BD=A0=E7=9A=84=E8=BE=BE=E5=A6=AE=E5=A8=85?= <176143450+My-Denia@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:36:17 +0800 Subject: [PATCH 01/16] feat: restore auto-zoom suggestions after a fresh recording 1.5 applied cursor-dwell zooms when a take landed in the editor. Import still seeded a clip, but never ran that pass. --- electron/ipc/handlers.ts | 3 + src/components/ai-edition/NewEditorShell.tsx | 85 ++++---- .../ai-edition/recordingImport.test.ts | 193 +++++++++++++++++- src/components/ai-edition/recordingImport.ts | 160 +++++++++++++++ src/components/ai-edition/v4/RecStage.tsx | 26 ++- src/components/ai-edition/v4/V4Timeline.tsx | 35 +--- src/components/launch/HudControls.tsx | 29 +++ src/components/launch/HudIcons.tsx | 10 + src/components/launch/LaunchWindow.test.tsx | 17 ++ src/components/launch/LaunchWindow.tsx | 17 ++ src/hooks/useScreenRecorder.ts | 6 + src/i18n/locales/ar/editor.json | 1 + src/i18n/locales/ar/launch.json | 4 + src/i18n/locales/en/editor.json | 1 + src/i18n/locales/en/launch.json | 4 + src/i18n/locales/es/editor.json | 1 + src/i18n/locales/es/launch.json | 4 + src/i18n/locales/fr/editor.json | 1 + src/i18n/locales/fr/launch.json | 4 + src/i18n/locales/it/editor.json | 1 + src/i18n/locales/it/launch.json | 4 + src/i18n/locales/ja-JP/editor.json | 1 + src/i18n/locales/ja-JP/launch.json | 4 + src/i18n/locales/ko-KR/editor.json | 1 + src/i18n/locales/ko-KR/launch.json | 4 + src/i18n/locales/pt-BR/editor.json | 1 + src/i18n/locales/pt-BR/launch.json | 4 + src/i18n/locales/ru/editor.json | 1 + src/i18n/locales/ru/launch.json | 4 + src/i18n/locales/tr/editor.json | 1 + src/i18n/locales/tr/launch.json | 4 + src/i18n/locales/vi/editor.json | 1 + src/i18n/locales/vi/launch.json | 4 + src/i18n/locales/zh-CN/editor.json | 1 + src/i18n/locales/zh-CN/launch.json | 4 + src/i18n/locales/zh-TW/editor.json | 1 + src/i18n/locales/zh-TW/launch.json | 4 + .../store/documentWriteAudit.test.ts | 14 +- src/lib/ai-edition/store/useTimeline.ts | 22 +- .../timeline/apply-auto-zooms.test.ts | 112 ++++++++++ .../ai-edition/timeline/apply-auto-zooms.ts | 68 ++++++ src/native/browserShim.ts | 2 + 42 files changed, 771 insertions(+), 93 deletions(-) create mode 100644 src/lib/ai-edition/timeline/apply-auto-zooms.test.ts create mode 100644 src/lib/ai-edition/timeline/apply-auto-zooms.ts diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index fcda39e0c..302f820a1 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -605,6 +605,8 @@ export interface RecordingPrefs { camDeviceId: string | null; systemAudioEnabled: boolean; cursorCaptureMode: CursorCaptureMode; + /** After a take, suggest cursor-dwell zooms. Default on, matching 1.5. */ + autoZoomEnabled: boolean; } let recordingPrefs: RecordingPrefs = { micEnabled: false, @@ -614,6 +616,7 @@ let recordingPrefs: RecordingPrefs = { camDeviceId: null, systemAudioEnabled: false, cursorCaptureMode: "editable-overlay", + autoZoomEnabled: true, }; // Cached source from the user's pick. Used by setDisplayMediaRequestHandler in main.ts for cursor-free capture. diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index b82c2a1cf..2d2cf6d4a 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -61,7 +61,7 @@ import { type UnsavedChoice, } from "./Modals"; import { Preview } from "./Preview"; -import { importPendingRecording } from "./recordingImport"; +import { importPendingRecording, maybeSaveFreshRecordingAutoZooms } from "./recordingImport"; import { AddAudioLayerDialog } from "./v4/AddAudioLayerDialog"; import v4 from "./v4/EditorShellV4.module.css"; import { type EditorMode, EditorTopBar } from "./v4/EditorTopBar"; @@ -421,6 +421,7 @@ export function NewEditorShell() { })); }, [document]); + const metadataChainRef = useRef(Promise.resolve()); const handleLoadedMetadata = useCallback( (durationSec: number, assetId: string) => { // ponytail: WebM recordings from MediaRecorder report NaN/Infinity @@ -429,45 +430,51 @@ export function NewEditorShell() { // placeholder. All store reads go through getState() to avoid // stale-closure bugs. const known = Number.isFinite(durationSec) && durationSec > 0 ? durationSec : 60; - const state = useProjectStore.getState(); setSourceDuration(known); - const doc = state.document; - if (!doc || doc.assets.length === 0) return; - if (doc.timeline.clips.length === 0) { - // ponytail: replaceTimeline derives clip length from - // asset.durationSec, which import never populates — without this - // patch the first auto-created clip silently comes out empty - // (normalizeIntervals clamps against a 0 duration and drops it). - const primaryAssetId = doc.project.primaryAssetId ?? doc.assets[0]?.id; - const docWithDuration = primaryAssetId - ? { - ...doc, - assets: doc.assets.map((a) => - a.id === primaryAssetId ? { ...a, durationSec: known } : a, - ), - } - : doc; - const next = replaceTimelineOp( - docWithDuration, - [{ startSec: 0, endSec: known }], - "Auto-created full-duration clip", - ); - // `history: false` for both writes in this callback: they are the probed - // duration being folded into the document on load, not something the user - // did — an undo landing on one of them would empty their timeline. - void state.saveDocument(next, { history: false }); - return; - } - // Hand the probed duration to the pure document layer: it patches only the - // clips of THIS asset that are still waiting for a real length (the - // pre-probe placeholder, or the extent-less clip a legacy v2 import mints), - // shifts what follows, and brings the modifiers along — anchoring the ones - // migration had to leave unanchored. Returns the document untouched when - // nothing is waiting, so there is nothing to guard here. - const next = applyProbedDuration(doc, assetId, known); - if (next !== doc) { - void state.saveDocument(next, { history: false }); - } + metadataChainRef.current = metadataChainRef.current + .catch(() => undefined) + .then(async () => { + const state = useProjectStore.getState(); + const doc = state.document; + if (!doc || doc.assets.length === 0) return; + let next = doc; + if (doc.timeline.clips.length === 0) { + // ponytail: replaceTimeline derives clip length from + // asset.durationSec, which import never populates — without this + // patch the first auto-created clip silently comes out empty + // (normalizeIntervals clamps against a 0 duration and drops it). + const primaryAssetId = doc.project.primaryAssetId ?? doc.assets[0]?.id; + const docWithDuration = primaryAssetId + ? { + ...doc, + assets: doc.assets.map((a) => + a.id === primaryAssetId ? { ...a, durationSec: known } : a, + ), + } + : doc; + next = replaceTimelineOp( + docWithDuration, + [{ startSec: 0, endSec: known }], + "Auto-created full-duration clip", + ); + } else { + // Hand the probed duration to the pure document layer: it patches only the + // clips of THIS asset that are still waiting for a real length (the + // pre-probe placeholder, or the extent-less clip a legacy v2 import mints), + // shifts what follows, and brings the modifiers along — anchoring the ones + // migration had to leave unanchored. Returns the document untouched when + // nothing is waiting, so there is nothing to guard here. + next = applyProbedDuration(doc, assetId, known); + } + // `history: false` for the duration write: it is the probed length being + // folded in on load, not something the user did — an undo landing on it + // would empty their timeline. Auto-zoom is a later, undoable suggestion. + if (next !== doc) { + await state.saveDocument(next, { history: false }); + next = useProjectStore.getState().document ?? next; + } + await maybeSaveFreshRecordingAutoZooms(useProjectStore.getState().document ?? next); + }); }, [setSourceDuration], ); diff --git a/src/components/ai-edition/recordingImport.test.ts b/src/components/ai-edition/recordingImport.test.ts index 4faea3d5c..5826d45f0 100644 --- a/src/components/ai-edition/recordingImport.test.ts +++ b/src/components/ai-edition/recordingImport.test.ts @@ -5,7 +5,12 @@ import { type AxcutDocument, createEmptyDocument } from "@/lib/ai-edition/schema import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { undo } from "@/lib/ai-edition/store/undo"; import { clearHistory, past } from "@/lib/ai-edition/store/undoStack"; -import { importPendingRecording } from "./recordingImport"; +import { + applyPendingFreshRecordingAutoZooms, + consumeFreshRecordingAutoZoomPending, + importPendingRecording, + markFreshRecordingAutoZoomPending, +} from "./recordingImport"; // The first describe stubs the store actions, so the bridge is never reached // there. The second one runs the REAL store against these, which is the only way @@ -14,8 +19,14 @@ const bridge = vi.hoisted(() => ({ create: vi.fn(), addAsset: vi.fn(), save: vi.fn(), + getTelemetry: vi.fn(async () => []), +})); +vi.mock("@/native/client", () => ({ + nativeBridgeClient: { + aiEdition: bridge, + cursor: { getTelemetry: bridge.getTelemetry }, + }, })); -vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: bridge } })); const createProject = vi.fn(async () => undefined); const addAsset = vi.fn(async () => null); @@ -49,6 +60,7 @@ function stubElectronApi(screenVideoPath: string | null) { describe("importPendingRecording", () => { beforeEach(() => { vi.clearAllMocks(); + consumeFreshRecordingAutoZoomPending(); useProjectStore.setState({ document: null, // biome-ignore lint/suspicious/noExplicitAny: partial action stubs, the rest of the store is untouched @@ -144,6 +156,7 @@ describe("what the recording import leaves on the undo stack", () => { beforeEach(() => { vi.clearAllMocks(); + consumeFreshRecordingAutoZoomPending(); useProjectStore.getState().clear(); useProjectStore.setState(realActions); clearHistory(); @@ -187,3 +200,179 @@ describe("what the recording import leaves on the undo stack", () => { expect(useProjectStore.getState().document?.timeline.clips).toHaveLength(1); }); }); + +function dwell( + centerMs: number, + cx: number, + cy: number, + count = 6, + spanMs = 900, +): Array<{ timeMs: number; cx: number; cy: number }> { + const step = spanMs / (count - 1); + return Array.from({ length: count }, (_, i) => ({ + timeMs: centerMs - spanMs / 2 + i * step, + cx, + cy, + })); +} + +function documentWithClip(durationSec = 10): AxcutDocument { + const doc = createEmptyDocument({ projectId: "p_autozoom", title: "Recording" }); + return { + ...doc, + assets: [ + { + id: "asset_1", + kind: "video", + label: "rec.mp4", + originalPath: "C:\\recordings\\rec.mp4", + cameraTrack: null, + durationSec, + }, + ], + project: { ...doc.project, primaryAssetId: "asset_1" }, + timeline: { + ...doc.timeline, + clips: [ + { + id: "clip_1", + assetId: "asset_1", + sourceStartSec: 0, + sourceEndSec: durationSec, + timelineStartSec: 0, + timelineEndSec: durationSec, + wordRefs: [], + origin: "system", + reason: "", + }, + ], + }, + }; +} + +describe("fresh-recording auto-zoom", () => { + beforeEach(() => { + vi.clearAllMocks(); + consumeFreshRecordingAutoZoomPending(); + useProjectStore.setState({ + document: null, + // biome-ignore lint/suspicious/noExplicitAny: partial action stubs + createProject: createProject as any, + // biome-ignore lint/suspicious/noExplicitAny: partial action stubs + addAsset: addAsset as any, + replaceTimeline, + }); + }); + + it("marks a successful import as waiting when the document is not ready yet", async () => { + stubElectronApi("C:\\recordings\\recording-1.mp4"); + await importPendingRecording(); + expect(consumeFreshRecordingAutoZoomPending()).toBe(true); + }); + + it("does not mark when nothing is waiting", async () => { + stubElectronApi(null); + await importPendingRecording(); + expect(consumeFreshRecordingAutoZoomPending()).toBe(false); + }); + + it("applies cursor-dwell zooms once, after duration is known", async () => { + markFreshRecordingAutoZoomPending(); + const next = await applyPendingFreshRecordingAutoZooms(documentWithClip(), { + enabled: true, + getTelemetry: async () => dwell(4000, 0.4, 0.6), + createId: (prefix) => `${prefix}_test`, + }); + expect(next.zoomRanges).toHaveLength(1); + expect(next.zoomRanges[0]).toMatchObject({ + startMs: 3000, + endMs: 5000, + focusMode: "auto", + }); + expect(await applyPendingFreshRecordingAutoZooms(next, { enabled: true })).toBe(next); + }); + + it("skips when the HUD toggle is off", async () => { + markFreshRecordingAutoZoomPending(); + const document = documentWithClip(); + const next = await applyPendingFreshRecordingAutoZooms(document, { + enabled: false, + getTelemetry: async () => dwell(4000, 0.5, 0.5), + }); + expect(next).toBe(document); + expect(next.zoomRanges).toEqual([]); + }); + + it("keeps the pending flag when the sidecar is still empty, then applies on retry", async () => { + markFreshRecordingAutoZoomPending(); + const document = documentWithClip(); + const first = await applyPendingFreshRecordingAutoZooms(document, { + enabled: true, + getTelemetry: async () => [], + }); + expect(first).toBe(document); + const next = await applyPendingFreshRecordingAutoZooms(document, { + enabled: true, + getTelemetry: async () => dwell(4000, 0.4, 0.6), + createId: (prefix) => `${prefix}_retry`, + }); + expect(next.zoomRanges).toHaveLength(1); + expect(await applyPendingFreshRecordingAutoZooms(next, { enabled: true })).toBe(next); + }); + + it("keeps the pending flag when clips are not on the document yet", async () => { + markFreshRecordingAutoZoomPending(); + const document = createEmptyDocument({ projectId: "p_empty", title: "Recording" }); + const next = await applyPendingFreshRecordingAutoZooms(document, { + enabled: true, + getTelemetry: async () => dwell(4000, 0.5, 0.5), + }); + expect(next).toBe(document); + expect(consumeFreshRecordingAutoZoomPending()).toBe(true); + }); + + it("keeps pending when telemetry is present but no dwell has landed yet", async () => { + markFreshRecordingAutoZoomPending(); + const document = documentWithClip(); + const moving = Array.from({ length: 8 }, (_, i) => ({ + timeMs: 1000 + i * 80, + cx: 0.2 + i * 0.08, + cy: 0.3, + })); + const first = await applyPendingFreshRecordingAutoZooms(document, { + enabled: true, + getTelemetry: async () => moving, + }); + expect(first).toBe(document); + const next = await applyPendingFreshRecordingAutoZooms(document, { + enabled: true, + getTelemetry: async () => dwell(4000, 0.4, 0.6), + createId: (prefix) => `${prefix}_late`, + }); + expect(next.zoomRanges).toHaveLength(1); + }); + + it("keeps pending when telemetry read throws", async () => { + markFreshRecordingAutoZoomPending(); + const document = documentWithClip(); + const next = await applyPendingFreshRecordingAutoZooms(document, { + enabled: true, + getTelemetry: async () => { + throw new Error("sidecar missing"); + }, + }); + expect(next).toBe(document); + expect(consumeFreshRecordingAutoZoomPending()).toBe(true); + }); + + it("does not decorate a different project's asset after a leftover pending flag", async () => { + markFreshRecordingAutoZoomPending("C:\\recordings\\fresh.mp4"); + const other = documentWithClip(); + const next = await applyPendingFreshRecordingAutoZooms(other, { + enabled: true, + getTelemetry: async () => dwell(4000, 0.4, 0.6), + }); + expect(next).toBe(other); + expect(consumeFreshRecordingAutoZoomPending()).toBe(true); + }); +}); diff --git a/src/components/ai-edition/recordingImport.ts b/src/components/ai-edition/recordingImport.ts index 9a3474397..bdaef8bfe 100644 --- a/src/components/ai-edition/recordingImport.ts +++ b/src/components/ai-edition/recordingImport.ts @@ -14,7 +14,159 @@ // derived `currentVideoPath`); the only renderer that still needs the session // after this point is the CLI runner, which lives in its own process. +import type { CursorTelemetryPoint } from "@/components/video-editor/types"; +import { createId } from "@/lib/ai-edition/document/ids"; +import type { AxcutDocument } from "@/lib/ai-edition/schema"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; +import { + appendAutoZoomSuggestions, + collectAutoZoomSuggestionsForDocument, +} from "@/lib/ai-edition/timeline/apply-auto-zooms"; +import { nativeBridgeClient } from "@/native/client"; + +// Fresh recordings used to get cursor-dwell zooms on load (legacy editor +// `autoZoomEnabled`, default on). The ai-edition import only seeded a clip, so +// the wand still worked but a new take landed un-zoomed. This flag is the +// one-shot hand-off: set as soon as the asset is on the document, then taken +// only after a real apply attempt (clips exist and telemetry was readable, or +// the toggle is off). Keeping it across an empty-clip / empty-sidecar pass is +// what lets metadata win the race against the helper flush. The path stops a +// leftover flag from decorating a later, unrelated project in the same window. +let pendingFreshRecordingAutoZoom = false; +let pendingFreshRecordingAutoZoomPath: string | null = null; +let pendingFreshRecordingAutoZoomTimers: ReturnType[] = []; + +const FRESH_RECORDING_AUTO_ZOOM_RETRY_MS = [500, 1500, 3000]; + +function clearFreshRecordingAutoZoomTimers(): void { + for (const timer of pendingFreshRecordingAutoZoomTimers) { + clearTimeout(timer); + } + pendingFreshRecordingAutoZoomTimers = []; +} + +function scheduleFreshRecordingAutoZoomRetries(): void { + clearFreshRecordingAutoZoomTimers(); + for (const delayMs of FRESH_RECORDING_AUTO_ZOOM_RETRY_MS) { + pendingFreshRecordingAutoZoomTimers.push( + setTimeout(() => { + if (!pendingFreshRecordingAutoZoom) return; + const document = useProjectStore.getState().document; + if (document) { + void maybeSaveFreshRecordingAutoZooms(document); + } + }, delayMs), + ); + } +} + +export function markFreshRecordingAutoZoomPending(assetPath?: string): void { + pendingFreshRecordingAutoZoom = true; + pendingFreshRecordingAutoZoomPath = assetPath ?? null; + scheduleFreshRecordingAutoZoomRetries(); +} + +function clearFreshRecordingAutoZoomPending(): void { + pendingFreshRecordingAutoZoom = false; + pendingFreshRecordingAutoZoomPath = null; + clearFreshRecordingAutoZoomTimers(); +} + +export function consumeFreshRecordingAutoZoomPending(): boolean { + const was = pendingFreshRecordingAutoZoom; + clearFreshRecordingAutoZoomPending(); + return was; +} + +export type ApplyFreshRecordingAutoZoomsDeps = { + enabled?: boolean; + getTelemetry?: (videoPath: string) => Promise; + createId?: (prefix: string) => string; +}; + +async function readAutoZoomPref(): Promise { + try { + const prefs = await window.electronAPI?.getRecordingPrefs?.(); + return prefs?.autoZoomEnabled !== false; + } catch { + return true; + } +} + +export async function applyFreshRecordingAutoZooms( + document: AxcutDocument, + deps: ApplyFreshRecordingAutoZoomsDeps = {}, +): Promise { + const enabled = deps.enabled ?? (await readAutoZoomPref()); + if (!enabled) return document; + if (document.zoomRanges.length > 0 || document.timeline.clips.length === 0) { + return document; + } + const getTelemetry = + deps.getTelemetry ?? ((videoPath: string) => nativeBridgeClient.cursor.getTelemetry(videoPath)); + const suggestions = await collectAutoZoomSuggestionsForDocument(document, getTelemetry); + if (suggestions.length === 0) return document; + return appendAutoZoomSuggestions(document, suggestions, deps.createId ?? createId); +} + +export async function applyPendingFreshRecordingAutoZooms( + document: AxcutDocument, + deps: ApplyFreshRecordingAutoZoomsDeps = {}, +): Promise { + if (!pendingFreshRecordingAutoZoom) return document; + const enabled = deps.enabled ?? (await readAutoZoomPref()); + if (!enabled) { + clearFreshRecordingAutoZoomPending(); + return document; + } + if ((document.zoomRanges?.length ?? 0) > 0) { + clearFreshRecordingAutoZoomPending(); + return document; + } + if ((document.timeline?.clips?.length ?? 0) === 0) { + return document; + } + if ( + pendingFreshRecordingAutoZoomPath && + !document.assets.some((asset) => asset.originalPath === pendingFreshRecordingAutoZoomPath) + ) { + return document; + } + const inner = + deps.getTelemetry ?? ((videoPath: string) => nativeBridgeClient.cursor.getTelemetry(videoPath)); + const getTelemetry = async (videoPath: string) => { + try { + return await inner(videoPath); + } catch { + // Sidecar may not be readable yet. Keep pending for the delayed retries. + return []; + } + }; + const suggestions = await collectAutoZoomSuggestionsForDocument(document, getTelemetry); + if (suggestions.length === 0) { + // Do not consume: a mid-flush sidecar can have samples but no dwell yet + // (the qualifying sit is often the last second of the take). + return document; + } + // Leave pending set until a later pass sees zoomRanges on the stored + // document. A overlapping `loadedmetadata` can still replace the timeline + // from a stale empty-clip snapshot and wipe this write. + return appendAutoZoomSuggestions(document, suggestions, deps.createId ?? createId); +} + +export async function maybeSaveFreshRecordingAutoZooms( + document: AxcutDocument, + deps: ApplyFreshRecordingAutoZoomsDeps = {}, +): Promise { + try { + const latest = useProjectStore.getState().document ?? document; + const next = await applyPendingFreshRecordingAutoZooms(latest, deps); + if (next === latest) return false; + return useProjectStore.getState().saveDocument(next, { history: true }); + } catch { + return false; + } +} /** * Imports the recording the HUD handed over into a new project, and consumes the @@ -35,6 +187,10 @@ export async function importPendingRecording(): Promise { const label = screenPath.split(/[\\/]/).pop() || "Recording"; await useProjectStore.getState().createProject(`Recording ${new Date().toLocaleString()}`); await useProjectStore.getState().addAsset(screenPath, label); + // Mark before the video element can fire `loadedmetadata`. The asset path is + // already on the document; waiting until the 60s seed finished let the first + // metadata pass consume nothing and the second never arrive. + markFreshRecordingAutoZoomPending(screenPath); // Consumed: the recording now lives in a project. Cleared here rather than // after the timeline seed below so a failure down there can't hand the same // recording to the next editor window. @@ -58,5 +214,9 @@ export async function importPendingRecording(): Promise { history: false, }); } + const latest = useProjectStore.getState().document; + if (latest) { + await maybeSaveFreshRecordingAutoZooms(latest); + } return true; } diff --git a/src/components/ai-edition/v4/RecStage.tsx b/src/components/ai-edition/v4/RecStage.tsx index 8564d4895..8e5303dd7 100644 --- a/src/components/ai-edition/v4/RecStage.tsx +++ b/src/components/ai-edition/v4/RecStage.tsx @@ -9,6 +9,7 @@ import { MousePointer2, Volume2, VolumeX, + ZoomIn, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { AudioLevelMeter } from "@/components/ui/audio-level-meter"; @@ -28,6 +29,7 @@ interface RecordingPrefsState { camDeviceId: string | null; systemAudioEnabled: boolean; cursorCaptureMode: "editable-overlay" | "system"; + autoZoomEnabled: boolean; } const DEFAULT_PREFS: RecordingPrefsState = { @@ -38,6 +40,7 @@ const DEFAULT_PREFS: RecordingPrefsState = { camDeviceId: null, systemAudioEnabled: false, cursorCaptureMode: "editable-overlay", + autoZoomEnabled: true, }; /** @@ -67,7 +70,13 @@ export function RecStage({ void window.electronAPI ?.getRecordingPrefs?.() .then((p) => { - if (!cancelled && p) setPrefsState(p as RecordingPrefsState); + if (!cancelled && p) { + setPrefsState({ + ...DEFAULT_PREFS, + ...p, + autoZoomEnabled: p.autoZoomEnabled !== false, + } as RecordingPrefsState); + } }) .catch((err) => { // Bare ipcRenderer.invoke — rejects if the main handler throws. Keeping @@ -367,6 +376,21 @@ export function RecStage({ {cursorHighlight ? t("rec.on") : t("rec.off")} + +
+
+ + {t("rec.autoZoom")} +
+ +
diff --git a/src/components/ai-edition/v4/V4Timeline.tsx b/src/components/ai-edition/v4/V4Timeline.tsx index a4b41ff32..8dd426376 100644 --- a/src/components/ai-edition/v4/V4Timeline.tsx +++ b/src/components/ai-edition/v4/V4Timeline.tsx @@ -28,7 +28,7 @@ import { import { toast } from "sonner"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tooltip, TooltipProvider } from "@/components/ui/tooltip"; -import { fromFileUrl, toFileUrl } from "@/components/video-editor/projectPersistence"; +import { toFileUrl } from "@/components/video-editor/projectPersistence"; import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types"; import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; @@ -52,6 +52,7 @@ import { useTimelineTranscriptGate } from "@/lib/ai-edition/store/transcriptionS import { useChatPromptBus } from "@/lib/ai-edition/store/useChatPromptBus"; import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings"; import type { useTimeline } from "@/lib/ai-edition/store/useTimeline"; +import { collectAutoZoomSuggestionsForDocument } from "@/lib/ai-edition/timeline/apply-auto-zooms"; import { hasAnyClipWithCamera } from "@/lib/ai-edition/timeline/camera"; import { formatSec } from "@/lib/ai-edition/timeline/format"; import { @@ -65,10 +66,6 @@ import { resolveTimelineSpanToTrim, ventilateTimelineSpanToTrims, } from "@/lib/ai-edition/timeline/trim-mapping"; -import { - type AutoZoomSuggestion, - buildAutoZoomSuggestionsForClips, -} from "@/lib/ai-edition/timeline/zoom-suggestions"; import { formatBinding } from "@/lib/shortcuts"; import { nativeBridgeClient } from "@/native/client"; import { TransportBar } from "../TransportBar"; @@ -1484,34 +1481,16 @@ export function V4Timeline({ // timeline was previously never consulted at all. const runAutoZooms = useCallback(async () => { setAutoEnhanceOpen(false); - const sources = videoSources.filter((source) => clips.some((c) => c.assetId === source.id)); - if (sources.length === 0) { + const document = useProjectStore.getState().document; + if (!document || document.timeline.clips.length === 0) { toast.error(t("toolbar.importRecordingFirst")); return; } setAutoBusy(true); try { - // Read once, up front: every clip reserves against the zooms the document - // ALREADY holds, and two clips can never contest the same stretch of ruler, so - // nothing here depends on the order the assets are visited — which is what lets - // their telemetry be fetched concurrently rather than one IPC round trip after - // another. `Promise.all` preserves input order, so the suggestions come out in - // the same sequence a loop would have produced. - const existingRegions = tl.zoomRegions.map((z) => ({ startMs: z.startMs, endMs: z.endMs })); - const perSource = await Promise.all( - sources.map(async (source) => { - const telemetry = - (await nativeBridgeClient.cursor.getTelemetry(fromFileUrl(source.src))) ?? []; - return buildAutoZoomSuggestionsForClips({ - cursorTelemetry: telemetry, - assetId: source.id, - clips, - existingRegions, - defaultDurationMs: 2000, - }); - }), + const suggestions = await collectAutoZoomSuggestionsForDocument(document, (videoPath) => + nativeBridgeClient.cursor.getTelemetry(videoPath), ); - const suggestions: AutoZoomSuggestion[] = perSource.flat(); if (suggestions.length === 0) { toast.info(t("toolbar.noAutoZoomMoments"), { description: t("toolbar.noAutoZoomMomentsDescription"), @@ -1533,7 +1512,7 @@ export function V4Timeline({ } finally { setAutoBusy(false); } - }, [videoSources, clips, tl, t]); + }, [tl, t]); // Auto-enhance option 2 — hand a generic prompt to the AI agent (smart // zooms + cuts) via the chat prompt-bus. The chat panel owns the outcome diff --git a/src/components/launch/HudControls.tsx b/src/components/launch/HudControls.tsx index 360499c79..c4f000491 100644 --- a/src/components/launch/HudControls.tsx +++ b/src/components/launch/HudControls.tsx @@ -4,6 +4,7 @@ import { formatTimePadded } from "../../utils/timeUtils"; import { Button } from "../ui/button"; import { Tooltip } from "../ui/tooltip"; import { + AutoZoomIcon, CameraIcon, CursorIcon, getIcon, @@ -253,6 +254,34 @@ export const HudSettingsButton = memo(function HudSettingsButton({ ); }); +export const HudAutoZoomButton = memo(function HudAutoZoomButton({ + enabled, + disabled, + label, + onClick, +}: { + enabled: boolean; + disabled: boolean; + label: string; + onClick: () => void; +}) { + return ( + + ); +}); + export const HudCursorButton = memo(function HudCursorButton({ editableOverlay, disabled, diff --git a/src/components/launch/HudIcons.tsx b/src/components/launch/HudIcons.tsx index 0ec73bdff..f8e527abb 100644 --- a/src/components/launch/HudIcons.tsx +++ b/src/components/launch/HudIcons.tsx @@ -117,6 +117,16 @@ export function CameraIcon({ off, className }: { off: boolean; className?: strin ); } +export function AutoZoomIcon({ className }: { className?: string }) { + return ( + + ); +} + export function CursorIcon({ className }: { className?: string }) { return ( ({ setSystemAudioEnabled: vi.fn(), cursorCaptureMode: "editable-overlay", setCursorCaptureMode: vi.fn(), + autoZoomEnabled: true, + setAutoZoomEnabled: vi.fn(), softwareEncoderFallbackNoticeVisible: false, dismissSoftwareEncoderFallbackNotice: vi.fn(), }, @@ -171,6 +173,8 @@ vi.mock("@/contexts/I18nContext", () => ({ "webcam.cameraDevice": "Camera device", "cursor.useEditableCursor": "Use editable cursor", "cursor.useSystemCursor": "Use system cursor", + "autoZoom.enable": "Enable auto-zoom after recording", + "autoZoom.disable": "Disable auto-zoom after recording", "tooltips.openStudio": "Open Studio", "tooltips.hideHUD": "Hide HUD", "tooltips.closeApp": "Close App", @@ -237,6 +241,7 @@ function stubElectronAPI(getSelectedSource: Window["electronAPI"]["getSelectedSo endHudOverlayDrag: vi.fn(), hudOverlayHide: vi.fn(), hudOverlayClose: vi.fn(), + setRecordingPrefs: vi.fn(async (prefs) => prefs), openNotes: vi.fn(), switchToEditor: vi.fn(async () => undefined), onSelectedSourceChanged: vi.fn((callback) => { @@ -338,6 +343,18 @@ describe("LaunchWindow record button", () => { expect(recorderState.value.toggleRecording).not.toHaveBeenCalled(); }); + it("toggles post-record auto-zoom without touching cursor capture", () => { + renderLaunchWindow(); + + const button = screen.getByTestId("launch-auto-zoom-button"); + expect(button).toHaveAttribute("title", "Disable auto-zoom after recording"); + fireEvent.click(button); + + expect(recorderState.value.setAutoZoomEnabled).toHaveBeenCalledWith(false); + expect(window.electronAPI.setRecordingPrefs).toHaveBeenCalledWith({ autoZoomEnabled: false }); + expect(recorderState.value.setCursorCaptureMode).not.toHaveBeenCalled(); + }); + it("records immediately after source selection when the record button opened the picker", async () => { renderLaunchWindow(); await waitForSourceSelectionSubscription(); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 8a2427fc1..117504006 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -9,6 +9,7 @@ import { usePortalOwnsSource } from "../../hooks/usePortalOwnsSource"; import { useScreenRecorder } from "../../hooks/useScreenRecorder"; import { requestCameraAccess } from "../../lib/requestCameraAccess"; import { + HudAutoZoomButton, HudCameraButton, HudCursorButton, HudDivider, @@ -105,6 +106,8 @@ export function LaunchWindow() { setWebcamDeviceName, cursorCaptureMode, setCursorCaptureMode, + autoZoomEnabled, + setAutoZoomEnabled, softwareEncoderFallbackNoticeVisible, dismissSoftwareEncoderFallbackNotice, } = useScreenRecorder(); @@ -720,6 +723,7 @@ export function LaunchWindow() { camDeviceId?: string; micDeviceId?: string; micDeviceName?: string; + autoZoomEnabled?: boolean; }) => { void window.electronAPI?.setRecordingPrefs?.(patch).catch((error) => { console.warn("Failed to persist the device preference:", error); @@ -728,6 +732,13 @@ export function LaunchWindow() { [], ); + const toggleAutoZoom = useCallback(() => { + if (controlsLocked) return; + const next = !autoZoomEnabled; + setAutoZoomEnabled(next); + persistRecordingPrefs({ autoZoomEnabled: next }); + }, [autoZoomEnabled, controlsLocked, persistRecordingPrefs, setAutoZoomEnabled]); + const toggleWebcam = useCallback(() => { if (controlsLocked) return; const next = !webcamEnabled; @@ -1009,6 +1020,12 @@ export function LaunchWindow() { onClick={toggleDeviceSettings} /> + {supportsCursorModeToggle && ( Promise; cursorCaptureMode: CursorCaptureMode; setCursorCaptureMode: (mode: CursorCaptureMode) => void; + autoZoomEnabled: boolean; + setAutoZoomEnabled: (enabled: boolean) => void; softwareEncoderFallbackNoticeVisible: boolean; dismissSoftwareEncoderFallbackNotice: (dontShowAgain?: boolean) => void; }; @@ -242,6 +244,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { const [systemAudioEnabled, setSystemAudioEnabled] = useState(false); const [webcamEnabled, setWebcamEnabledState] = useState(false); const [cursorCaptureMode, setCursorCaptureMode] = useState("editable-overlay"); + const [autoZoomEnabled, setAutoZoomEnabled] = useState(true); const [softwareEncoderFallbackNoticeVisible, setSoftwareEncoderFallbackNoticeVisible] = useState(false); @@ -267,6 +270,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { if (prefs.camDeviceId) setWebcamDeviceId(prefs.camDeviceId); setSystemAudioEnabled(prefs.systemAudioEnabled); setCursorCaptureMode(prefs.cursorCaptureMode); + setAutoZoomEnabled(prefs.autoZoomEnabled !== false); }) .catch((err) => { // Bare ipcRenderer.invoke — rejects if the main handler throws. Falling @@ -2347,6 +2351,8 @@ export function useScreenRecorder(): UseScreenRecorderReturn { setWebcamEnabled, cursorCaptureMode, setCursorCaptureMode, + autoZoomEnabled, + setAutoZoomEnabled, softwareEncoderFallbackNoticeVisible, dismissSoftwareEncoderFallbackNotice, }; diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 45190b2eb..9ab7f624b 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -110,6 +110,7 @@ "microphone": "الميكروفون", "camera": "الكاميرا", "cursorHighlight": "إبراز المؤشر", + "autoZoom": "تكبير تلقائي بعد التسجيل", "on": "تشغيل", "off": "إيقاف", "loading": "جارٍ التحميل...", diff --git a/src/i18n/locales/ar/launch.json b/src/i18n/locales/ar/launch.json index 0198af382..8372ed737 100644 --- a/src/i18n/locales/ar/launch.json +++ b/src/i18n/locales/ar/launch.json @@ -78,6 +78,10 @@ "useEditableCursor": "استخدام مؤشر قابل للتحرير", "useSystemCursor": "استخدام مؤشر النظام" }, + "autoZoom": { + "enable": "تفعيل التكبير التلقائي بعد التسجيل", + "disable": "تعطيل التكبير التلقائي بعد التسجيل" + }, "softwareEncoderFallback": { "title": "تم التبديل إلى الترميز البرمجي", "description": "تعذّر تشغيل مرمّز GPU الافتراضي، لذا تحوّل OpenScreen إلى ترميز H.264 البرمجي. سيستمر التسجيل كالمعتاد، لكن استخدام المعالج قد يكون أعلى.", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 6b738c399..269cb4299 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -110,6 +110,7 @@ "microphone": "Microphone", "camera": "Camera", "cursorHighlight": "Cursor highlight", + "autoZoom": "Auto-zoom after recording", "on": "On", "off": "Off", "loading": "Loading…", diff --git a/src/i18n/locales/en/launch.json b/src/i18n/locales/en/launch.json index ad2385c02..86daba038 100644 --- a/src/i18n/locales/en/launch.json +++ b/src/i18n/locales/en/launch.json @@ -56,6 +56,10 @@ "useEditableCursor": "Use editable cursor", "useSystemCursor": "Use system cursor" }, + "autoZoom": { + "enable": "Enable auto-zoom after recording", + "disable": "Disable auto-zoom after recording" + }, "sourceSelector": { "loading": "Loading sources...", "screens": "Screens ({{count}})", diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 397a86298..038b5190a 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -110,6 +110,7 @@ "microphone": "Micrófono", "camera": "Cámara", "cursorHighlight": "Resaltar cursor", + "autoZoom": "Zoom automático tras grabar", "on": "Activado", "off": "Desactivado", "loading": "Cargando…", diff --git a/src/i18n/locales/es/launch.json b/src/i18n/locales/es/launch.json index 7a2c92a55..58e414f72 100644 --- a/src/i18n/locales/es/launch.json +++ b/src/i18n/locales/es/launch.json @@ -56,6 +56,10 @@ "useEditableCursor": "Usar cursor editable", "useSystemCursor": "Usar cursor del sistema" }, + "autoZoom": { + "enable": "Activar zoom automático tras grabar", + "disable": "Desactivar zoom automático tras grabar" + }, "sourceSelector": { "loading": "Cargando fuentes...", "screens": "Pantallas ({{count}})", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index dcc83218f..9eefef772 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -110,6 +110,7 @@ "microphone": "Microphone", "camera": "Caméra", "cursorHighlight": "Curseur en surbrillance", + "autoZoom": "Zoom automatique après l'enregistrement", "on": "Activé", "off": "Désactivé", "loading": "Chargement…", diff --git a/src/i18n/locales/fr/launch.json b/src/i18n/locales/fr/launch.json index 858fd58f1..d25ccd9d6 100644 --- a/src/i18n/locales/fr/launch.json +++ b/src/i18n/locales/fr/launch.json @@ -56,6 +56,10 @@ "useEditableCursor": "Utiliser le curseur éditable", "useSystemCursor": "Utiliser le curseur système" }, + "autoZoom": { + "enable": "Activer le zoom automatique après l'enregistrement", + "disable": "Désactiver le zoom automatique après l'enregistrement" + }, "sourceSelector": { "loading": "Chargement des sources...", "screens": "Écrans ({{count}})", diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index e343d482e..8c97bfea1 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -110,6 +110,7 @@ "microphone": "Microfono", "camera": "Fotocamera", "cursorHighlight": "Evidenzia cursore", + "autoZoom": "Zoom automatico dopo la registrazione", "on": "Attivo", "off": "Disattivo", "loading": "Caricamento…", diff --git a/src/i18n/locales/it/launch.json b/src/i18n/locales/it/launch.json index ed5088d67..04e18fac2 100644 --- a/src/i18n/locales/it/launch.json +++ b/src/i18n/locales/it/launch.json @@ -56,6 +56,10 @@ "useEditableCursor": "Usa cursore modificabile", "useSystemCursor": "Usa cursore di sistema" }, + "autoZoom": { + "enable": "Attiva lo zoom automatico dopo la registrazione", + "disable": "Disattiva lo zoom automatico dopo la registrazione" + }, "sourceSelector": { "loading": "Caricamento sorgenti...", "screens": "Schermi ({{count}})", diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 239bc1287..2a984ceba 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -110,6 +110,7 @@ "microphone": "マイク", "camera": "カメラ", "cursorHighlight": "カーソルの強調表示", + "autoZoom": "録画後に自動ズーム", "on": "オン", "off": "オフ", "loading": "読み込み中…", diff --git a/src/i18n/locales/ja-JP/launch.json b/src/i18n/locales/ja-JP/launch.json index ab9cebbd5..043c15fd3 100644 --- a/src/i18n/locales/ja-JP/launch.json +++ b/src/i18n/locales/ja-JP/launch.json @@ -56,6 +56,10 @@ "useEditableCursor": "編集可能なカーソルを使う", "useSystemCursor": "システムカーソルを使う" }, + "autoZoom": { + "enable": "録画後の自動ズームをオンにする", + "disable": "録画後の自動ズームをオフにする" + }, "sourceSelector": { "loading": "ソースを読み込み中...", "screens": "画面 ({{count}})", diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index 8dd4be036..01eb0ceba 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -110,6 +110,7 @@ "microphone": "마이크", "camera": "카메라", "cursorHighlight": "커서 강조", + "autoZoom": "녹화 후 자동 확대", "on": "켜짐", "off": "꺼짐", "loading": "로딩 중…", diff --git a/src/i18n/locales/ko-KR/launch.json b/src/i18n/locales/ko-KR/launch.json index 192089224..01c04b323 100644 --- a/src/i18n/locales/ko-KR/launch.json +++ b/src/i18n/locales/ko-KR/launch.json @@ -56,6 +56,10 @@ "useEditableCursor": "편집 가능한 커서 사용", "useSystemCursor": "시스템 커서 사용" }, + "autoZoom": { + "enable": "녹화 후 자동 확대 켜기", + "disable": "녹화 후 자동 확대 끄기" + }, "sourceSelector": { "loading": "소스 불러오는 중...", "screens": "화면 ({{count}}개)", diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 5a6219e6c..01cbe2fe7 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -110,6 +110,7 @@ "microphone": "Microfone", "camera": "Câmera", "cursorHighlight": "Destaque do cursor", + "autoZoom": "Zoom automático após a gravação", "on": "Ativado", "off": "Desativado", "loading": "Carregando…", diff --git a/src/i18n/locales/pt-BR/launch.json b/src/i18n/locales/pt-BR/launch.json index 4d1fc9270..7bc49e535 100644 --- a/src/i18n/locales/pt-BR/launch.json +++ b/src/i18n/locales/pt-BR/launch.json @@ -56,6 +56,10 @@ "useEditableCursor": "Usar cursor editável", "useSystemCursor": "Usar cursor do sistema" }, + "autoZoom": { + "enable": "Ativar zoom automático após a gravação", + "disable": "Desativar zoom automático após a gravação" + }, "sourceSelector": { "loading": "Carregando fontes...", "screens": "Telas ({{count}})", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 86830571b..2b6460ef7 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -110,6 +110,7 @@ "microphone": "Микрофон", "camera": "Камера", "cursorHighlight": "Подсветка курсора", + "autoZoom": "Автомасштабирование после записи", "on": "Вкл", "off": "Выкл", "loading": "Загрузка…", diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 0af9564c4..5edb08af8 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -78,6 +78,10 @@ "useEditableCursor": "Использовать редактируемый курсор", "useSystemCursor": "Использовать системный курсор" }, + "autoZoom": { + "enable": "Включить автомасштабирование после записи", + "disable": "Отключить автомасштабирование после записи" + }, "softwareEncoderFallback": { "title": "Выполнен переход на программное кодирование", "description": "Стандартный GPU-кодировщик не запустился, поэтому OpenScreen перешёл на программное кодирование H.264. Запись продолжится как обычно, но нагрузка на процессор может быть выше.", diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index cb1c1906e..b614c962c 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -110,6 +110,7 @@ "microphone": "Mikrofon", "camera": "Kamera", "cursorHighlight": "İmleç vurgusu", + "autoZoom": "Kayıttan sonra otomatik yakınlaştırma", "on": "Açık", "off": "Kapalı", "loading": "Yükleniyor…", diff --git a/src/i18n/locales/tr/launch.json b/src/i18n/locales/tr/launch.json index 632709c82..846a7eb84 100644 --- a/src/i18n/locales/tr/launch.json +++ b/src/i18n/locales/tr/launch.json @@ -56,6 +56,10 @@ "useEditableCursor": "Düzenlenebilir imleci kullan", "useSystemCursor": "Sistem imlecini kullan" }, + "autoZoom": { + "enable": "Kayıttan sonra otomatik yakınlaştırmayı aç", + "disable": "Kayıttan sonra otomatik yakınlaştırmayı kapat" + }, "sourceSelector": { "loading": "Kaynaklar yükleniyor...", "screens": "Ekranlar ({{count}})", diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index 9f484688c..eb5b61f38 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -110,6 +110,7 @@ "microphone": "Micro", "camera": "Máy ảnh", "cursorHighlight": "Làm nổi bật con trỏ", + "autoZoom": "Tự động phóng to sau khi ghi", "on": "Bật", "off": "Tắt", "loading": "Đang tải…", diff --git a/src/i18n/locales/vi/launch.json b/src/i18n/locales/vi/launch.json index 98405bf99..2d5036ab4 100644 --- a/src/i18n/locales/vi/launch.json +++ b/src/i18n/locales/vi/launch.json @@ -78,6 +78,10 @@ "useEditableCursor": "Dùng con trỏ có thể chỉnh sửa", "useSystemCursor": "Dùng con trỏ hệ thống" }, + "autoZoom": { + "enable": "Bật tự động phóng to sau khi ghi", + "disable": "Tắt tự động phóng to sau khi ghi" + }, "softwareEncoderFallback": { "title": "Đã chuyển sang mã hóa bằng phần mềm", "description": "Bộ mã hóa GPU mặc định không khởi động được, nên OpenScreen đã chuyển sang mã hóa H.264 bằng phần mềm. Quá trình ghi vẫn tiếp tục bình thường, nhưng mức sử dụng CPU có thể cao hơn.", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 0405b5c13..3ef69427e 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -110,6 +110,7 @@ "microphone": "麦克风", "camera": "摄像头", "cursorHighlight": "光标高亮", + "autoZoom": "录制后自动缩放", "on": "开", "off": "关", "loading": "加载中…", diff --git a/src/i18n/locales/zh-CN/launch.json b/src/i18n/locales/zh-CN/launch.json index e7efe8eec..48d241853 100644 --- a/src/i18n/locales/zh-CN/launch.json +++ b/src/i18n/locales/zh-CN/launch.json @@ -56,6 +56,10 @@ "useEditableCursor": "使用可编辑光标", "useSystemCursor": "使用系统光标" }, + "autoZoom": { + "enable": "启用录制后自动缩放", + "disable": "关闭录制后自动缩放" + }, "sourceSelector": { "loading": "正在加载源...", "screens": "屏幕 ({{count}})", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index f4d10694f..071c4f0aa 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -110,6 +110,7 @@ "microphone": "麥克風", "camera": "攝影機", "cursorHighlight": "游標醒目提示", + "autoZoom": "錄製後自動縮放", "on": "開", "off": "關", "loading": "載入中…", diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index 025e422e7..b03303314 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -56,6 +56,10 @@ "useEditableCursor": "使用可編輯游標", "useSystemCursor": "使用系統游標" }, + "autoZoom": { + "enable": "啟用錄製後自動縮放", + "disable": "關閉錄製後自動縮放" + }, "sourceSelector": { "loading": "正在載入來源...", "screens": "螢幕 ({{count}})", diff --git a/src/lib/ai-edition/store/documentWriteAudit.test.ts b/src/lib/ai-edition/store/documentWriteAudit.test.ts index 9a832b356..cf4bc693a 100644 --- a/src/lib/ai-edition/store/documentWriteAudit.test.ts +++ b/src/lib/ai-edition/store/documentWriteAudit.test.ts @@ -123,9 +123,8 @@ const DECLARED: WritePath[] = [ w("src/components/ai-edition/NewEditorShell.tsx", "NewEditorShell", "save", "automatic"), // "Save" on the unsaved-changes prompt. w("src/components/ai-edition/NewEditorShell.tsx", "handleConfirmUnsaved", "save", "gesture"), - // The probed duration folded into the document when the