diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index dabf62955..56009ba1f 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -12,14 +12,16 @@ import { } from "../../src/native/contracts"; import type { ChatEventSink } from "../ai-edition/chat-service"; import type { DocumentService } from "../ai-edition/document-service"; -import type { CursorTelemetryLoadResult } from "../native-bridge/cursor/adapter"; -import { TelemetryCursorAdapter } from "../native-bridge/cursor/telemetryCursorAdapter"; +import { + type CursorTelemetryLoadResult, + TelemetryCursorAdapter, +} from "../native-bridge/cursor/telemetryCursorAdapter"; import { AiEditionService } from "../native-bridge/services/aiEditionService"; import { CompositorViewService } from "../native-bridge/services/compositorViewService"; import { CursorService } from "../native-bridge/services/cursorService"; import { ProjectService } from "../native-bridge/services/projectService"; import { SystemService } from "../native-bridge/services/systemService"; -import { NativeBridgeStateStore } from "../native-bridge/store"; +import { createNativeBridgeState } from "../native-bridge/store"; export interface NativeBridgeContext { getPlatform: () => NodeJS.Platform; @@ -191,7 +193,7 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { ipcMain.removeHandler(NATIVE_BRIDGE_CHANNEL); const platform = normalizePlatform(context.getPlatform()); - const store = new NativeBridgeStateStore(platform); + const store = createNativeBridgeState(platform); const projectService = new ProjectService({ store, getCurrentProjectPath: context.getCurrentProjectPath, diff --git a/electron/native-bridge/cursor/adapter.ts b/electron/native-bridge/cursor/adapter.ts deleted file mode 100644 index cdb88e24a..000000000 --- a/electron/native-bridge/cursor/adapter.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { - CursorCapabilities, - CursorProviderKind, - CursorRecordingData, - CursorTelemetryPoint, -} from "../../../src/native/contracts"; - -export interface CursorTelemetryLoadResult { - success: boolean; - samples: CursorTelemetryPoint[]; - message?: string; - error?: string; -} - -export interface CursorNativeAdapter { - readonly kind: CursorProviderKind; - getCapabilities(): Promise; - getRecordingData(videoPath?: string | null): Promise; - getTelemetry(videoPath?: string | null): Promise; -} diff --git a/electron/native-bridge/cursor/telemetryCursorAdapter.ts b/electron/native-bridge/cursor/telemetryCursorAdapter.ts index 073b18316..19fcc9a60 100644 --- a/electron/native-bridge/cursor/telemetryCursorAdapter.ts +++ b/electron/native-bridge/cursor/telemetryCursorAdapter.ts @@ -1,5 +1,9 @@ -import type { CursorCapabilities, CursorRecordingData } from "../../../src/native/contracts"; -import type { CursorNativeAdapter, CursorTelemetryLoadResult } from "./adapter"; +import type { + CursorCapabilities, + CursorProviderKind, + CursorRecordingData, + CursorTelemetryPoint, +} from "../../../src/native/contracts"; interface TelemetryCursorAdapterOptions { loadRecordingData: (videoPath: string) => Promise; @@ -7,8 +11,15 @@ interface TelemetryCursorAdapterOptions { loadTelemetry: (videoPath: string) => Promise; } -export class TelemetryCursorAdapter implements CursorNativeAdapter { - readonly kind = "none" as const; +export interface CursorTelemetryLoadResult { + success: boolean; + samples: CursorTelemetryPoint[]; + message?: string; + error?: string; +} + +export class TelemetryCursorAdapter { + readonly kind: CursorProviderKind = "none"; constructor(private readonly options: TelemetryCursorAdapterOptions) {} diff --git a/electron/native-bridge/services/cursorService.ts b/electron/native-bridge/services/cursorService.ts index e3e9a2552..05436bdd4 100644 --- a/electron/native-bridge/services/cursorService.ts +++ b/electron/native-bridge/services/cursorService.ts @@ -3,12 +3,12 @@ import type { CursorRecordingData, CursorTelemetryPoint, } from "../../../src/native/contracts"; -import type { CursorNativeAdapter } from "../cursor/adapter"; -import type { NativeBridgeStateStore } from "../store"; +import type { TelemetryCursorAdapter } from "../cursor/telemetryCursorAdapter"; +import type { NativeBridgeState } from "../store"; interface CursorServiceOptions { - store: NativeBridgeStateStore; - adapter: CursorNativeAdapter; + store: NativeBridgeState; + adapter: TelemetryCursorAdapter; } export class CursorService { diff --git a/electron/native-bridge/services/projectService.ts b/electron/native-bridge/services/projectService.ts index 0c363cc13..88d3359d7 100644 --- a/electron/native-bridge/services/projectService.ts +++ b/electron/native-bridge/services/projectService.ts @@ -3,10 +3,10 @@ import type { ProjectFileResult, ProjectPathResult, } from "../../../src/native/contracts"; -import type { NativeBridgeStateStore } from "../store"; +import type { NativeBridgeState } from "../store"; interface ProjectServiceOptions { - store: NativeBridgeStateStore; + store: NativeBridgeState; getCurrentProjectPath: () => string | null; getCurrentVideoPath: () => string | null; saveProjectFile: ( diff --git a/electron/native-bridge/services/systemService.ts b/electron/native-bridge/services/systemService.ts index 50eff2838..40acae66d 100644 --- a/electron/native-bridge/services/systemService.ts +++ b/electron/native-bridge/services/systemService.ts @@ -4,10 +4,10 @@ import type { SystemCapabilities, } from "../../../src/native/contracts"; import { NATIVE_BRIDGE_VERSION } from "../../../src/native/contracts"; -import type { NativeBridgeStateStore } from "../store"; +import type { NativeBridgeState } from "../store"; interface SystemServiceOptions { - store: NativeBridgeStateStore; + store: NativeBridgeState; getPlatform: () => NativePlatform; getAssetBasePath: () => string | null; getCursorCapabilities: () => Promise; diff --git a/electron/native-bridge/store.ts b/electron/native-bridge/store.ts index dcdbed154..9cf955085 100644 --- a/electron/native-bridge/store.ts +++ b/electron/native-bridge/store.ts @@ -5,7 +5,7 @@ import type { SystemCapabilities, } from "../../src/native/contracts"; -export interface NativeBridgeState { +interface NativeBridgeStateData { system: { platform: NativePlatform; capabilities: SystemCapabilities | null; @@ -21,68 +21,50 @@ export interface NativeBridgeState { }; } -export class NativeBridgeStateStore { - private state: NativeBridgeState; - - constructor(platform: NativePlatform) { - this.state = { - system: { - platform, - capabilities: null, - }, - project: { - currentProjectPath: null, - currentVideoPath: null, - }, - cursor: { - capabilities: null, - lastTelemetryLoad: null, - }, - }; - } - - getState() { - return this.state; - } - - setProjectContext(project: ProjectContext) { - this.state = { - ...this.state, - project, - }; - } - - setSystemCapabilities(capabilities: SystemCapabilities) { - this.state = { - ...this.state, - system: { - ...this.state.system, - capabilities, - }, - }; - } +export interface NativeBridgeState { + getState(): NativeBridgeStateData; + setProjectContext(project: ProjectContext): void; + setSystemCapabilities(capabilities: SystemCapabilities): void; + setCursorCapabilities(capabilities: CursorCapabilities): void; + markCursorTelemetryLoaded(videoPath: string, sampleCount: number): void; +} - setCursorCapabilities(capabilities: CursorCapabilities) { - this.state = { - ...this.state, - cursor: { - ...this.state.cursor, - capabilities, - }, - }; - } +export function createNativeBridgeState(platform: NativePlatform): NativeBridgeState { + const state: NativeBridgeStateData = { + system: { + platform, + capabilities: null, + }, + project: { + currentProjectPath: null, + currentVideoPath: null, + }, + cursor: { + capabilities: null, + lastTelemetryLoad: null, + }, + }; - markCursorTelemetryLoaded(videoPath: string, sampleCount: number) { - this.state = { - ...this.state, - cursor: { - ...this.state.cursor, + return { + getState: () => state, + setProjectContext: (project) => { + state.project = project; + }, + setSystemCapabilities: (capabilities) => { + state.system = { ...state.system, capabilities }; + }, + setCursorCapabilities: (capabilities) => { + state.cursor = { ...state.cursor, capabilities }; + }, + markCursorTelemetryLoaded: (videoPath, sampleCount) => { + state.cursor = { + ...state.cursor, lastTelemetryLoad: { videoPath, sampleCount, loadedAt: Date.now(), }, - }, - }; - } + }; + }, + }; } diff --git a/src/components/video-editor/customPlaybackSpeed.test.ts b/src/components/video-editor/customPlaybackSpeed.test.ts index aaffb2b5a..eb14cf16e 100644 --- a/src/components/video-editor/customPlaybackSpeed.test.ts +++ b/src/components/video-editor/customPlaybackSpeed.test.ts @@ -10,11 +10,12 @@ describe("parseCustomPlaybackSpeedInput", () => { }); }); - it("keeps a single decimal point while typing", () => { + it("rejects malformed multi-dot input", () => { + // The previous parser silently collapsed "1.2.3" into "1.23"; that + // round-trip is gone, so the new parser sees an unparseable string. expect(parseCustomPlaybackSpeedInput("1.2.3")).toEqual({ - status: "valid", - draft: "1.23", - speed: 1.23, + status: "empty", + draft: "1.2.3", }); }); @@ -34,9 +35,11 @@ describe("parseCustomPlaybackSpeedInput", () => { }); it("accepts comma decimal input by normalizing to a dot", () => { + // The draft round-trip is gone; the input is preserved verbatim. Only the + // parsed speed uses the normalized form. expect(parseCustomPlaybackSpeedInput("1,1")).toEqual({ status: "valid", - draft: "1.1", + draft: "1,1", speed: 1.1, }); }); diff --git a/src/components/video-editor/customPlaybackSpeed.ts b/src/components/video-editor/customPlaybackSpeed.ts index 64cc3e3e4..03a16cae3 100644 --- a/src/components/video-editor/customPlaybackSpeed.ts +++ b/src/components/video-editor/customPlaybackSpeed.ts @@ -11,27 +11,20 @@ export type CustomPlaybackSpeedInputResult = | { status: "too-slow"; draft: string } | { status: "valid"; draft: string; speed: PlaybackSpeed }; -export function parseCustomPlaybackSpeedInput(rawValue: string): CustomPlaybackSpeedInputResult { - const decimalDraft = rawValue.replace(/,/g, ".").replace(/[^\d.]/g, ""); - const [whole = "", ...fractionParts] = decimalDraft.split("."); - const draft = fractionParts.length > 0 ? `${whole}.${fractionParts.join("")}` : whole; +export function parseCustomPlaybackSpeedInput(draft: string): CustomPlaybackSpeedInputResult { + const normalized = Number(draft.replace(/,/g, ".")); - if (draft === "" || draft === ".") { + if (!Number.isFinite(normalized)) { return { status: "empty", draft }; } - - const speed = Number(draft); - if (!Number.isFinite(speed)) { - return { status: "empty", draft }; - } - - if (speed > MAX_PLAYBACK_SPEED) { + if (normalized > MAX_PLAYBACK_SPEED) { return { status: "too-fast", draft }; } - - if (speed < MIN_PLAYBACK_SPEED) { + if (normalized < MIN_PLAYBACK_SPEED) { return { status: "too-slow", draft }; } - - return { status: "valid", draft, speed: clampPlaybackSpeed(speed) }; + // Reuse the shared clamp rather than re-inlining its rounding rule: this + // value feeds the native scene, and a second copy would drift the first time + // the bounds or the 2-decimal step change. + return { status: "valid", draft, speed: clampPlaybackSpeed(normalized) }; } diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 725cd970b..aaeac5333 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -452,7 +452,7 @@ function focusOrCentre(value: number): number { return Number.isFinite(value) ? clamp01(value) : 0.5; } -export function clampFocusToDepth(focus: ZoomFocus, _depth: ZoomDepth): ZoomFocus { +export function clampFocus(focus: ZoomFocus): ZoomFocus { return { cx: focusOrCentre(focus.cx), cy: focusOrCentre(focus.cy), diff --git a/src/lib/zoomMath/focusUtils.ts b/src/lib/zoomMath/focusUtils.ts index d94dfe194..0bd1f03ae 100644 --- a/src/lib/zoomMath/focusUtils.ts +++ b/src/lib/zoomMath/focusUtils.ts @@ -1,5 +1,5 @@ import { - clampFocusToDepth, + clampFocus, ZOOM_DEPTH_SCALES, type ZoomDepth, type ZoomFocus, @@ -60,12 +60,8 @@ export function getFocusBoundsForScale(zoomScale: number, viewportRatio?: Viewpo }; } -export function clampFocusToStage( - focus: ZoomFocus, - depth: ZoomDepth, - _stageSize: StageSize, -): ZoomFocus { - const baseFocus = clampFocusToDepth(focus, depth); +export function clampFocusToStage(focus: ZoomFocus, depth: ZoomDepth): ZoomFocus { + const baseFocus = clampFocus(focus); const bounds = getFocusBounds(depth); return { diff --git a/src/utils/math.test.ts b/src/utils/math.test.ts index 6c1429ce2..f560430f3 100644 --- a/src/utils/math.test.ts +++ b/src/utils/math.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { clampFocusToDepth } from "@/components/video-editor/types"; +import { clampFocus } from "@/components/video-editor/types"; import { getNormalizedBlurIntensity, getNormalizedMosaicBlockSize } from "@/lib/blurEffects"; import { clamp, clamp01 } from "./math"; @@ -30,7 +30,7 @@ describe("callers that depend on the guard", () => { }); it("an unknown zoom focus recentres rather than jumping to the corner", () => { - expect(clampFocusToDepth({ cx: Number.NaN, cy: Number.NaN }, 2)).toEqual({ cx: 0.5, cy: 0.5 }); - expect(clampFocusToDepth({ cx: 2, cy: -1 }, 2)).toEqual({ cx: 1, cy: 0 }); + expect(clampFocus({ cx: Number.NaN, cy: Number.NaN })).toEqual({ cx: 0.5, cy: 0.5 }); + expect(clampFocus({ cx: 2, cy: -1 })).toEqual({ cx: 1, cy: 0 }); }); }); diff --git a/technical-documentation/architecture/native-bridge.md b/technical-documentation/architecture/native-bridge.md index 5cd25c73f..5296bd567 100644 --- a/technical-documentation/architecture/native-bridge.md +++ b/technical-documentation/architecture/native-bridge.md @@ -13,7 +13,7 @@ flowchart LR ``` 1. **Native adapters** implement platform-facing interfaces. Cursor telemetry is adapted by `electron/native-bridge/cursor/telemetryCursorAdapter.ts`; native compositor loading is isolated in the compositor service. -2. **Main-process services** own state and domain behavior. They receive a `NativeBridgeStateStore` and application callbacks from `NativeBridgeContext` rather than exposing Electron primitives to React. +2. **Main-process services** own state and domain behavior. They receive a `NativeBridgeState` and application callbacks from `NativeBridgeContext` rather than exposing Electron primitives to React. The state is created by `createNativeBridgeState(platform)` and exposes `getState` plus the per-domain setters as direct property assignments. 3. **Unified IPC transport** is registered by `registerNativeBridgeHandlers` in `electron/ipc/nativeBridge.ts`. It handles one channel, validates the request shape, dispatches by domain and action, and wraps every result. 4. **Renderer client** in `src/native/client.ts` generates request IDs, invokes the preload transport, unwraps successful data, and throws the contract error for failures. Renderer features use `nativeBridgeClient` rather than importing main-process services. @@ -81,7 +81,7 @@ A new bridge operation must have a browser-shim entry when renderer code can cal ## Invariants - Renderer code crosses the native boundary through `src/native/client.ts` and its domain clients. -- Main-process state stays behind services and `NativeBridgeStateStore`; it is not reconstructed independently in each renderer. +- Main-process state stays behind services and `NativeBridgeState`; it is not reconstructed independently in each renderer. - Every response carries `NativeBridgeMeta`, and every failure uses a `NativeBridgeErrorCode` rather than an arbitrary transport exception. - Capability probing is explicit. An unavailable addon or platform feature returns a capability or `UNAVAILABLE` result instead of making renderer code guess from the platform string. - The preload remains the only renderer-to-main transport surface for this bridge. `electron/preload.ts:22` exposes the `electronAPI` object, including its `invokeNativeBridge` method; the legacy object is still present for compatibility, while new native operations belong on the unified bridge.