(() => {
if (!layout?.webcamRect || cameraFullscreenProgress <= 0) return layout;
@@ -443,7 +448,7 @@ export function PreviewCanvas(props: PreviewCanvasProps) {
interactive-only layers (ZoomFocusOverlay, AnnotationLayer, webcam drag
hitbox) still render on top as normal DOM so they stay clickable. No more
dual preview path. */}
-
+
{layout?.screenRect ? (
{(() => {
diff --git a/src/components/ai-edition/VirtualPreview.playback.test.tsx b/src/components/ai-edition/VirtualPreview.playback.test.tsx
index c2be74392..becfb077a 100644
--- a/src/components/ai-edition/VirtualPreview.playback.test.tsx
+++ b/src/components/ai-edition/VirtualPreview.playback.test.tsx
@@ -60,6 +60,7 @@ function clip(
* recorded rather than performed (jsdom implements neither). */
function driveVideo(element: HTMLVideoElement) {
let currentTime = 0;
+ let playbackRate = 1;
let paused = true;
const pauseCalls: number[] = [];
Object.defineProperty(element, "currentTime", {
@@ -70,6 +71,13 @@ function driveVideo(element: HTMLVideoElement) {
},
});
Object.defineProperty(element, "paused", { configurable: true, get: () => paused });
+ Object.defineProperty(element, "playbackRate", {
+ configurable: true,
+ get: () => playbackRate,
+ set: (next: number) => {
+ playbackRate = next;
+ },
+ });
Object.defineProperty(element, "readyState", { configurable: true, get: () => 4 });
Object.defineProperty(element, "duration", { configurable: true, get: () => 10 });
element.play = vi.fn(() => {
@@ -94,7 +102,12 @@ function driveVideo(element: HTMLVideoElement) {
};
}
-function mount(clips: AxcutClip[], trimRanges: AxcutTrimRange[] = []) {
+function mount(
+ clips: AxcutClip[],
+ trimRanges: AxcutTrimRange[] = [],
+ speedRegions: SpeedRegion[] = [],
+ playUneditedTimeline = false,
+) {
const onTimeChange = vi.fn();
const sources: VideoSource[] = [{ id: "a1", src: "file:///tmp/a1.mp4", label: "a1" }];
const { container } = render(
@@ -102,6 +115,8 @@ function mount(clips: AxcutClip[], trimRanges: AxcutTrimRange[] = []) {
videoSources={sources}
clips={clips}
trimRanges={trimRanges}
+ speedRegions={speedRegions}
+ playUneditedTimeline={playUneditedTimeline}
onTimeChange={onTimeChange}
/>,
);
@@ -207,6 +222,31 @@ describe("VirtualPreview playback across a clip boundary", () => {
expect(video.currentTime).toBe(6); // jumped to where clip_1's content resumes
expect(video.pauseCalls).toHaveLength(0);
});
+
+ it("plays through cuts at 1x while recording a voiceover", () => {
+ const clips = [clip("clip_1", "a1", 0, 10, 0)];
+ const trims: AxcutTrimRange[] = [
+ {
+ id: "trim_1",
+ assetId: "a1",
+ clipId: "clip_1",
+ startSec: 4,
+ endSec: 6,
+ origin: "user",
+ reason: "",
+ },
+ ];
+ const speeds: SpeedRegion[] = [{ id: "speed_1", startMs: 0, endMs: 10_000, speed: 2 }];
+ const { onTimeChange, video, element } = mount(clips, trims, speeds, true);
+
+ video.play();
+ video.seekTo(5);
+ tick();
+
+ expect(video.currentTime).toBe(5);
+ expect(element.playbackRate).toBe(1);
+ expect(reportedTimes(onTimeChange).at(-1)).toBeCloseTo(5, 5);
+ });
});
// Issue #350 — imported audio tracks follow the RAW virtual playhead. The
diff --git a/src/components/ai-edition/VirtualPreview.tsx b/src/components/ai-edition/VirtualPreview.tsx
index 4e4ec9e55..759a9625a 100644
--- a/src/components/ai-edition/VirtualPreview.tsx
+++ b/src/components/ai-edition/VirtualPreview.tsx
@@ -452,6 +452,8 @@ interface VirtualPreviewProps {
zoomRegions?: AxcutZoomRegion[];
speedRegions?: SpeedRegion[];
trimRanges?: AxcutTrimRange[];
+ /** Play the raw timeline continuously while capturing a voiceover. */
+ playUneditedTimeline?: boolean;
seekTarget?: { timeSec: number; isSource?: boolean; requestId: number } | null;
onTimeChange?: (timeSec: number) => void;
onLoadedMetadata?: (
@@ -484,6 +486,9 @@ interface VirtualPreviewProps {
cropRegion?: CropRegion | null;
}
+const NO_TRIM_RANGES: AxcutTrimRange[] = [];
+const NO_SPEED_REGIONS: SpeedRegion[] = [];
+
export function VirtualPreview({
videoSources,
audioTracks = [],
@@ -492,6 +497,7 @@ export function VirtualPreview({
zoomRegions = [],
speedRegions = [],
trimRanges = [],
+ playUneditedTimeline = false,
seekTarget,
onTimeChange,
onLoadedMetadata,
@@ -503,6 +509,8 @@ export function VirtualPreview({
cropRegion,
}: VirtualPreviewProps) {
const { settings } = useEditorSettings();
+ const playbackTrimRanges = playUneditedTimeline ? NO_TRIM_RANGES : trimRanges;
+ const playbackSpeedRegions = playUneditedTimeline ? NO_SPEED_REGIONS : speedRegions;
// ponytail: an oversized, offset video inside .videoFrame's overflow:hidden
// box — the same "scale + negative-position the full frame, let the
// container clip the rest" technique the export renderer uses via a Pixi
@@ -866,8 +874,8 @@ export function VirtualPreview({
// Same reason as `clipsRef`: the rAF projects the playhead and each imported
// audio track's head raw→output every frame (see the audio-track loop), and
// must see the live trims, not the set captured when the loop was created.
- const trimRangesRef = useRef(trimRanges);
- trimRangesRef.current = trimRanges;
+ const trimRangesRef = useRef(playbackTrimRanges);
+ trimRangesRef.current = playbackTrimRanges;
// What the film no longer contains, recomputed only when the cuts move — the rAF asks
// it once per voiceover per frame, and walking every trim there would be wasteful.
// The film's pauses, placed on the raw ruler once. The projection needs them or every
@@ -876,12 +884,15 @@ export function VirtualPreview({
// it every frame per track, and walking on each would be wasteful.
const takePiecesRef = useRef