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
4 changes: 4 additions & 0 deletions electron/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,10 @@ export function createEditorWindow(query: Record<string, string> = {}): BrowserW
contextIsolation: true,
webSecurity: false,
backgroundThrottling: false,
// Media track lists: the preview's hidden <video> and <audio> deselect their video
// track, so Chromium stops decoding pictures the native compositor already draws
// (`dropVideoTrack` in VirtualPreview.tsx).
enableBlinkFeatures: "AudioVideoTracks",
},
});

Expand Down
12 changes: 6 additions & 6 deletions src/components/ai-edition/NewEditorShell.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -726,7 +726,8 @@
position: relative;
z-index: 1;
/* Pixels come from the native canvas now; this <video> stays mounted (and
playing) purely as the decode/playback-clock/metadata source. A plain
playing) purely as the playback clock and metadata source, its picture
undecoded (`dropVideoTrack` in VirtualPreview.tsx). A plain
descendant selector (not a fixed child-depth chain) is required here:
VirtualPreview nests the actual <video> THREE levels down
(.screenStage > .container > .videoFrame > video), not one or two —
Expand All @@ -744,9 +745,8 @@
border-radius: 18px !important;
}

/* Webcam overlay: real <video> driven by the cameraTrack. The slot is
transparent so clip-path / border-radius carve the camera without leaving
a rectangular placeholder peeking through. */
/* Webcam slot: the drag-to-reposition hitbox over the camera the native canvas
draws. Transparent, so nothing of it shows. */
.webcamSlot {
background: transparent;
overflow: hidden;
Expand All @@ -757,8 +757,8 @@
width: 100%;
height: 100%;
object-fit: cover;
/* Native canvas already draws the composited webcam PiP; this <video> stays
mounted (playing) only to drive the camera's own decode/sync clock. The
/* Native canvas already draws the composited webcam PiP; this <video> is
never played, only read for the camera's size (WebcamOverlay.tsx). The
slot div itself stays visible/clickable for the drag-to-reposition hitbox. */
visibility: hidden;
display: block;
Expand Down
25 changes: 3 additions & 22 deletions src/components/ai-edition/PreviewCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ import type {
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
import { resolveActiveCameraTrack } from "@/lib/ai-edition/timeline/camera";
import { createPlaybackClockRef } from "@/lib/ai-edition/timeline/playback-clock";
import type { SpeedRegion } from "@/lib/ai-edition/timeline/speed";
import { locateVirtualPosition } from "@/lib/ai-edition/timeline/virtual-preview";
import {
Expand Down Expand Up @@ -135,11 +134,6 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
const assets = document?.assets ?? [];
const frameRef = useRef<HTMLDivElement | null>(null);
const webcamSlotRef = useRef<HTMLDivElement | null>(null);
// One clock per mounted canvas, shared between the screen preview (writer)
// and the webcam overlay (reader) — see playback-clock.ts.
const clockRefHolder = useRef<ReturnType<typeof createPlaybackClockRef>>();
if (!clockRefHolder.current) clockRefHolder.current = createPlaybackClockRef();
const clockRef = clockRefHolder.current;
// Real dimensions of the active source, from the <video>'s own
// onLoadedMetadata (videoWidth/videoHeight) — null until the first source
// loads, then falls back to SCREEN_SOURCE_SIZE.
Expand Down Expand Up @@ -384,7 +378,6 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
onVideoElement: relayIsPlaying,
onLoadedMetadata: relayLoadedMetadata,
cropRegion,
clockRef,
};

const handleWebcamPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
Expand Down Expand Up @@ -445,8 +438,8 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
>
{/* Sole pixel source: the D3D-composited frame (wallpaper + screen + webcam +
cursor), streamed into a canvas. The <video> elements below are CSS-hidden
(visibility only — they stay mounted for decode/playback-clock/metadata
duties, since the native compositor doesn't drive playback itself), and the
(visibility only — they stay mounted as the playback clock and for metadata,
since the native compositor doesn't drive playback itself), and the
interactive-only layers (ZoomFocusOverlay, AnnotationLayer, webcam drag
hitbox) still render on top as normal DOM so they stay clickable. No more
dual preview path. */}
Expand All @@ -467,7 +460,6 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
// behind a flag with its own tests.
// See technical-documentation/architecture/preview.md
// (todo) for the failure write-up.
void relayProps.clockRef;
return (
<VirtualPreview
{...relayProps}
Expand Down Expand Up @@ -497,18 +489,7 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
onPointerDown={isPipGrab ? handleWebcamPointerDown : undefined}
aria-label={te("preview.webcamPreview")}
>
<WebcamOverlay
clips={props.clips}
currentTimeSec={props.currentTimeSec}
onTimeChange={props.onTimeChange}
isPlaying={isPlaying}
clockRef={clockRef}
borderRadius={
effectiveLayout?.webcamRect?.borderRadius ?? layout.webcamRect.borderRadius
}
webcamMaskShape={effectiveLayout?.webcamRect?.maskShape ?? settings.webcamMaskShape}
layoutPreset={settings.webcamLayoutPreset}
/>
<WebcamOverlay clips={props.clips} currentTimeSec={props.currentTimeSec} />
</div>
) : null}
{/* Last, so a selected annotation over the camera takes the pointer before the
Expand Down
46 changes: 46 additions & 0 deletions src/components/ai-edition/VirtualPreview.tracks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { dropVideoTrack } from "./VirtualPreview";

/** A media element as Chromium shows it under `AudioVideoTracks`: track lists, nothing else. */
function element(tracks: { audio?: number; video?: number }) {
const videoTracks = Array.from({ length: tracks.video ?? 0 }, (_, index) => ({
selected: index === 0,
}));
return {
element: {
audioTracks: tracks.audio === undefined ? undefined : { length: tracks.audio },
videoTracks: tracks.video === undefined ? undefined : videoTracks,
} as unknown as HTMLMediaElement,
videoTracks,
};
}

describe("dropVideoTrack", () => {
it("deselects the picture of an element that has sound to keep time with", () => {
const { element: media, videoTracks } = element({ audio: 1, video: 1 });

expect(dropVideoTrack(media)).toBe(true);
expect(videoTracks[0].selected).toBe(false);
});

// With neither track selected an element has no stream left to keep time with: measured,
// its clock races to the end instead of playing.
it("leaves a recording without sound decoding, so its clock still runs", () => {
const { element: media, videoTracks } = element({ audio: 0, video: 1 });

expect(dropVideoTrack(media)).toBe(false);
expect(videoTracks[0].selected).toBe(true);
});

it("does nothing where the track lists are absent (the Blink feature is off)", () => {
const { element: media } = element({});

expect(dropVideoTrack(media)).toBe(false);
});

it("does nothing to an element with no picture, the extracted second audio track", () => {
const { element: media } = element({ audio: 1, video: 0 });

expect(dropVideoTrack(media)).toBe(false);
});
});
59 changes: 39 additions & 20 deletions src/components/ai-edition/VirtualPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import type {
} from "@/lib/ai-edition/schema";
import { audioGainScalar } from "@/lib/ai-edition/store/editorSettings";
import { useEditorSettings } from "@/lib/ai-edition/store/useEditorSettings";
import type { PlaybackClockRef } from "@/lib/ai-edition/timeline/playback-clock";
import { removedRawSpans } from "@/lib/ai-edition/timeline/programme-time";
import { findActiveSpeedRegion, type SpeedRegion } from "@/lib/ai-edition/timeline/speed";
import {
Expand Down Expand Up @@ -178,6 +177,37 @@ export function shouldResyncAudio(
return Math.abs(driftSec) > (freeRunning ? playingLeashSec : AUDIO_PARKED_LEASH_SEC);
}

/** The track lists Chromium exposes on a media element under the `AudioVideoTracks` Blink
* feature, which the editor window turns on (`createEditorWindow`). Absent elsewhere. */
interface MediaTrackLists {
audioTracks?: { length: number };
videoTracks?: { length: number; [index: number]: { selected: boolean } };
}

/**
* Stops Chromium decoding the picture of a media element that is only here for its clock or
* its sound: the native compositor draws every pixel of the preview. Measured on a 1080p60
* recording: a hidden `<video>` that plays keeps ~6.7 % of an RTX 4070 Ti's decode engine
* busy, and an `<audio>` on the same mp4 decodes its picture just as much — a second and a
* third decode of the recording next to the compositor's own. With the video track
* deselected the element decodes no frame, and its clock, `playbackRate` and seeks behave
* the same.
*
* Only when the element has a sound track: with neither track selected it has no stream
* left to keep time with, and races to its end. A recording without sound keeps decoding,
* as before. Returns whether the track was dropped.
*/
export function dropVideoTrack(element: HTMLMediaElement): boolean {
const { audioTracks, videoTracks } = element as HTMLMediaElement & MediaTrackLists;
if (!videoTracks?.length || !audioTracks?.length) {
return false;
}
for (let index = 0; index < videoTracks.length; index++) {
videoTracks[index].selected = false;
}
return true;
}

/**
* Where an audio element should sit to track the video, and whether it should be playing.
*
Expand Down Expand Up @@ -442,12 +472,6 @@ interface VirtualPreviewProps {
* identity ({x:0,y:0,width:1,height:1}) renders the full frame, unchanged
* from before crop support existed. */
cropRegion?: CropRegion | null;
/**
* Written every rAF tick with this video's live position/rate so other
* media elements (the webcam overlay) can read it directly instead of
* waiting for a React state round trip. See playback-clock.ts.
*/
clockRef?: PlaybackClockRef;
}

export function VirtualPreview({
Expand All @@ -467,7 +491,6 @@ export function VirtualPreview({
onVideoRecovered,
retryToken,
cropRegion,
clockRef,
}: VirtualPreviewProps) {
const { settings } = useEditorSettings();
// ponytail: an oversized, offset video inside .videoFrame's overflow:hidden
Expand Down Expand Up @@ -1117,15 +1140,6 @@ export function VirtualPreview({
el.pause();
}
}
// Publish this frame's live position/rate for other media elements
// (webcam) to read directly — see playback-clock.ts for why this
// bypasses React state entirely.
if (clockRef) {
clockRef.current.sourceTimeSec = v.currentTime;
clockRef.current.isPlaying = !v.paused;
clockRef.current.playbackRate = v.playbackRate;
clockRef.current.virtualTimeSec = virtualTimeSecRef.current;
}
// A reload is in flight: the decoder is dead and `currentTime` is
// frozen (or already reset to 0), so every decision below — trim
// skipping, the clip-boundary advance, the unmapped-position
Expand Down Expand Up @@ -1210,9 +1224,9 @@ export function VirtualPreview({
// `seekToVirtualTimeRef(nextClip.timelineStartSec)` plus bas renvoyait la tête au
// DÉBUT du clip voisin — le tressaillement observé au passage d'un clip à l'autre.
//
// `clockRef` et `setSourceTimeSec` ci-dessus continuent d'être publiés : la webcam
// et le calque curseur ont besoin du temps source même à l'arrêt. Seule la
// position de la TIMELINE cesse d'être dictée par le média.
// `setSourceTimeSec` ci-dessus continue d'être publié : le calque curseur a besoin du
// temps source même à l'arrêt. Seule la position de la TIMELINE cesse d'être
// dictée par le média.
if (v.paused) {
return;
}
Expand Down Expand Up @@ -1657,6 +1671,8 @@ export function VirtualPreview({
e.currentTarget.videoWidth,
e.currentTarget.videoHeight,
);
// Its size is read: from here on only its clock is needed.
dropVideoTrack(e.currentTarget);
if (pendingSeekRef.current) {
const { sourceTimeSec, play } = pendingSeekRef.current;
pendingSeekRef.current = null;
Expand Down Expand Up @@ -1795,6 +1811,8 @@ export function VirtualPreview({
preload="metadata"
aria-hidden="true"
data-testid="preview-audio-primary"
// The recording's own mp4: without this its picture is decoded too.
onLoadedMetadata={(e) => dropVideoTrack(e.currentTarget)}
/>
{supplementalAudioSrc ? (
<audio
Expand All @@ -1803,6 +1821,7 @@ export function VirtualPreview({
src={supplementalAudioSrc}
preload="metadata"
aria-hidden="true"
onLoadedMetadata={(e) => dropVideoTrack(e.currentTarget)}
data-testid="preview-audio-supplemental"
/>
) : null}
Expand Down
35 changes: 29 additions & 6 deletions src/components/ai-edition/WebcamOverlay.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom";
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AxcutClip, AxcutDocument } from "@/lib/ai-edition/schema";
import { axcutSchemaVersion } from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
Expand Down Expand Up @@ -83,11 +83,6 @@ function baseProps(currentTimeSec: number) {
return {
clips: [CLIP_WITH_CAMERA, CLIP_WITHOUT_CAMERA],
currentTimeSec,
onTimeChange: () => undefined,
isPlaying: false,
borderRadius: 0,
webcamMaskShape: "rectangle" as const,
layoutPreset: "picture-in-picture" as const,
};
}

Expand Down Expand Up @@ -205,4 +200,32 @@ describe("WebcamOverlay (per-clip camera resolution)", () => {
unmount();
}
});

// The native compositor draws the camera. Playing this element decoded the whole camera
// recording a second time, alongside the compositor, for pixels CSS hides.
it("reads the camera for its size and never plays it", () => {
useProjectStore.setState({
projectId: "proj_test",
document: makeDocument(),
revision: 1,
status: "ready",
error: null,
sourceDurationSec: 0,
currentTimeSec: 2,
dirty: false,
lastSavedAt: new Date(),
});
const play = vi.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue(undefined);
try {
const { container, rerender } = render(<WebcamOverlay {...baseProps(2)} />);
rerender(<WebcamOverlay {...baseProps(3)} />);

const video = container.querySelector("video");
expect(video?.getAttribute("preload")).toBe("metadata");
expect(video?.autoplay).toBe(false);
expect(play).not.toHaveBeenCalled();
} finally {
play.mockRestore();
}
});
});
Loading
Loading