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
10 changes: 6 additions & 4 deletions electron/ipc/nativeBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 0 additions & 20 deletions electron/native-bridge/cursor/adapter.ts

This file was deleted.

19 changes: 15 additions & 4 deletions electron/native-bridge/cursor/telemetryCursorAdapter.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
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<CursorRecordingData>;
resolveVideoPath: (videoPath?: string | null) => string | null;
loadTelemetry: (videoPath: string) => Promise<CursorTelemetryLoadResult>;
}

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) {}

Expand Down
8 changes: 4 additions & 4 deletions electron/native-bridge/services/cursorService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions electron/native-bridge/services/projectService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
Expand Down
4 changes: 2 additions & 2 deletions electron/native-bridge/services/systemService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CursorCapabilities>;
Expand Down
98 changes: 40 additions & 58 deletions electron/native-bridge/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
SystemCapabilities,
} from "../../src/native/contracts";

export interface NativeBridgeState {
interface NativeBridgeStateData {
system: {
platform: NativePlatform;
capabilities: SystemCapabilities | null;
Expand All @@ -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(),
},
},
};
}
};
},
};
}
13 changes: 8 additions & 5 deletions src/components/video-editor/customPlaybackSpeed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
});

Expand All @@ -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,
});
});
Expand Down
25 changes: 9 additions & 16 deletions src/components/video-editor/customPlaybackSpeed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
}
2 changes: 1 addition & 1 deletion src/components/video-editor/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
10 changes: 3 additions & 7 deletions src/lib/zoomMath/focusUtils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {
clampFocusToDepth,
clampFocus,
ZOOM_DEPTH_SCALES,
type ZoomDepth,
type ZoomFocus,
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions src/utils/math.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 });
});
});
4 changes: 2 additions & 2 deletions technical-documentation/architecture/native-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down
Loading