Skip to content
78 changes: 34 additions & 44 deletions src/components/ai-edition/NewEditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@ import {
migrateProjectDataToAxcutDocument,
migrateRawDocumentToCurrent,
} from "@/lib/ai-edition/document/migrate";
import {
applyProbedDuration,
replaceTimeline as replaceTimelineOp,
} from "@/lib/ai-edition/document/timeline";
import { documentAfterProbedDuration } from "@/lib/ai-edition/document/timeline";
import {
type InsertSide,
insertDocumentWord,
Expand Down Expand Up @@ -426,50 +423,39 @@ export function NewEditorShell() {
// ponytail: WebM recordings from MediaRecorder report NaN/Infinity
// until the main-process EBML fix lands. Fall back to a 60s seed if
// duration is unknown so the timeline never gets stuck on an empty
// placeholder. All store reads go through getState() to avoid
// stale-closure bugs.
// placeholder.
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",
// Read before queueing: this is the project the event belongs to. What the
// decision does with it is `documentAfterProbedDuration`'s business.
const originatingProjectId = useProjectStore.getState().document?.project.id;
// On the shared write queue, and reading the document inside it. Folding a
// probed duration in is a read-modify-write of the whole document, which is
// what `useSequentialTimelineOps` exists for -- its header says anything that
// reads the doc and saves it back belongs there. Off the queue, `getState()`
// returns the PRE-edit document while a user's save is still in flight (the
// store is only written once the bridge answers), and the full snapshot built
// from it lands after theirs and takes their edit with it.
void enqueueTimelineWrite(async () => {
const state = useProjectStore.getState();
const next = documentAfterProbedDuration(
state.document,
assetId,
known,
originatingProjectId,
);
// `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 });
}
if (!next) return;
// `history: false`: this is the probed duration being folded into the
// document on load, not something the user did — an undo landing on it would
// empty their timeline.
//
// Awaited, not `void`ed: the queue only serialises what it can see finish, so
// a fire-and-forget write would let the next queued edit read a document this
// one has not committed yet.
await state.saveDocument(next, { history: false });
});
},
[setSourceDuration],
[setSourceDuration, enqueueTimelineWrite],
);

const handleSeek = useCallback(
Expand Down Expand Up @@ -1559,6 +1545,10 @@ export function NewEditorShell() {
hasProject={hasProject}
hasAsset={hasAsset}
videoSources={videoSources}
// While the timeline is empty the preview mounts this asset rather
// than whichever one sorts first, so the clip `handleLoadedMetadata`
// seeds comes from the video it is sized against.
primaryAssetId={document?.project.primaryAssetId}
// Imported audio tracks (issue #350). `videoSources` already
// resolves a URL for every asset (audio included), so it doubles as
// the audio source list; VirtualPreview looks each track up by assetId.
Expand Down
40 changes: 39 additions & 1 deletion src/components/ai-edition/Preview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ function source(id: string): VideoSource {
function previewProps(props: {
videoSources: VideoSource[];
clips: AxcutClip[];
primaryAssetId?: string;
hasAsset?: boolean;
hasProject?: boolean;
}) {
Expand All @@ -84,6 +85,7 @@ function previewProps(props: {
hasProject={props.hasProject ?? true}
hasAsset={props.hasAsset ?? true}
videoSources={props.videoSources}
primaryAssetId={props.primaryAssetId}
clips={props.clips}
seekTarget={null}
onTimeChange={vi.fn()}
Expand Down Expand Up @@ -157,12 +159,48 @@ describe("Preview follows the timeline, not the asset list", () => {
// The bootstrap path: `handleLoadedMetadata` mints the very first clip from
// the <video>'s own metadata, so a just-imported asset has to be mounted
// while nothing references it yet.
it("falls back to every asset while the timeline is empty", () => {
it("mounts the asset while the timeline is empty", () => {
renderPreview({ videoSources: [source("fresh_import")], clips: [] });

expect(canvas()).toHaveAttribute("data-sources", "fresh_import");
});

// A project whose first import was audio: audio never claims the empty primary
// slot, so `assets[0]` is the audio track and the primary is the video added
// after it. Only one source is mounted at a time and nothing on an empty
// timeline moves that index off 0 — so mounting the audio would hand
// `handleLoadedMetadata` an event for an asset it refuses to seed from, and the
// timeline would never get its first clip at all.
it("mounts the primary asset, not the one that sorts first", () => {
renderPreview({
videoSources: [source("bgm"), source("screen")],
primaryAssetId: "screen",
clips: [],
});

expect(canvas()).toHaveAttribute("data-sources", "screen");
});

// No primary recorded (a v1.7 project that predates the field): fall back to
// `assets[0]`, which is what the seed itself falls back to.
it("mounts the first asset when the project has no primary", () => {
renderPreview({ videoSources: [source("first"), source("second")], clips: [] });

expect(canvas()).toHaveAttribute("data-sources", "first");
});

// A primary id pointing at an asset with no source would otherwise mount
// nothing and collapse the stage to the empty state.
it("keeps every asset when the primary has no source", () => {
renderPreview({
videoSources: [source("a"), source("b")],
primaryAssetId: "gone",
clips: [],
});

expect(canvas()).toHaveAttribute("data-sources", "a,b");
});

// A clip landing on a healthy asset takes over the preview regardless of what
// happened to the asset that was mounted before it.
it("switches to the asset a new clip references", () => {
Expand Down
23 changes: 21 additions & 2 deletions src/components/ai-edition/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ interface PreviewProps {
hasProject: boolean;
hasAsset: boolean;
videoSources: VideoSource[];
/** `document.project.primaryAssetId`, when the project has one. Read only while
* the timeline is empty — see `previewSources`. */
primaryAssetId?: string;
/** Imported audio tracks and the (unfiltered) asset URLs they resolve to
* (issue #350). Passed straight through to VirtualPreview — unlike the video
* `previewSources` below, these are NOT narrowed to clip-referenced assets,
Expand Down Expand Up @@ -61,6 +64,7 @@ export function Preview({
hasProject,
hasAsset,
videoSources,
primaryAssetId,
audioTracks = [],
audioSources = [],
clips,
Expand Down Expand Up @@ -116,8 +120,23 @@ export function Preview({
const source = videoSources.find((s) => s.id === clip.assetId);
if (source) referenced.push(source);
}
return referenced.length > 0 ? referenced : videoSources;
}, [clips, videoSources]);
if (referenced.length > 0) return referenced;
// Empty timeline: mount the asset the seed is minted FOR, not whichever asset
// happens to sort first. `handleLoadedMetadata` sizes that first clip against
// `primaryAssetId ?? assets[0]` and ignores an event from any other asset, and
// only ONE source is ever mounted (`videoSources[sourceIndex]` in
// VirtualPreview, index 0 while nothing on the timeline moves it) — so mounting
// a non-primary asset here fires an event nothing acts on and the timeline
// stays empty for good. A project whose first import was audio is exactly that
// case: audio never claims the empty primary slot (document-service.addAsset),
// so `assets[0]` is the audio and the primary is the video added after it.
// `videoSources` mirrors `document.assets` in order, so index 0 is the same
// `assets[0]` the seed itself falls back to.
const primary = primaryAssetId
? videoSources.find((source) => source.id === primaryAssetId)
: videoSources[0];
return primary ? [primary] : videoSources;
}, [clips, videoSources, primaryAssetId]);

// ponytail: a media failure used to fall through to `EditorEmptyState`, and
// that is issue #395: ONE `error` event on the hidden <video> — including the
Expand Down
47 changes: 29 additions & 18 deletions src/components/ai-edition/v4/V4Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1491,27 +1491,38 @@ export function V4Timeline({
}
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 }));
// Telemetry first, and nothing derived from the document until it is back.
// `Promise.all` preserves input order, so the suggestions still come out in the
// same sequence a loop would have produced.
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,
});
sources.map(async (source) => ({
assetId: source.id,
telemetry: (await nativeBridgeClient.cursor.getTelemetry(fromFileUrl(source.src))) ?? [],
})),
);
// Read AFTER the round trip, not before it. `addZoomsBulk` anchors what comes out
// of here against the document IT reads at write time, so building the spans from
// the pre-await `clips` puts the two halves on different rulers: a trim landing
// during the wait moves every clip, and a span that no longer falls in one is
// stored unanchored. A stale `zoomRegions` is the same shape one step over — a
// zoom the user added during the wait would not be reserved, and the region
// minted here would sit on top of it.
//
// Still read ONCE for every asset rather than per asset: each clip reserves
// against the zooms the document already holds, and two clips can never contest
// the same stretch of ruler, so nothing depends on the order they are visited.
const doc = useProjectStore.getState().document;
if (!doc) return;
const existingRegions = doc.zoomRanges.map((z) => ({ startMs: z.startMs, endMs: z.endMs }));
const suggestions: AutoZoomSuggestion[] = perSource.flatMap(({ assetId, telemetry }) =>
buildAutoZoomSuggestionsForClips({
cursorTelemetry: telemetry,
assetId,
clips: doc.timeline.clips,
existingRegions,
defaultDurationMs: 2000,
}),
);
const suggestions: AutoZoomSuggestion[] = perSource.flat();
if (suggestions.length === 0) {
toast.info(t("toolbar.noAutoZoomMoments"), {
description: t("toolbar.noAutoZoomMomentsDescription"),
Expand Down
127 changes: 127 additions & 0 deletions src/lib/ai-edition/document/probedDuration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
// The decision a `loadedmetadata` event makes, on its own.
//
// The event itself arrives through Preview -> PreviewCanvas -> VirtualPreview and a
// real <video>, which no test environment here can decode, so the component cannot be
// driven end to end. `documentAfterProbedDuration` is the part that decides what gets
// written, and every guard below lives in it: the queue the shell puts this write on
// is what makes them necessary, because it puts real time between the event and the
// write. The queue's own serialization is covered by useSequentialTimelineOps.test.
import { describe, expect, it } from "vitest";
import { type AxcutDocument, createEmptyDocument, documentSchema } from "@/lib/ai-edition/schema";
import { migrateProjectDataToAxcutDocument } from "./migrate";
import { documentAfterProbedDuration } from "./timeline";

const PROJECT = "proj_a";

/** A fresh import: assets on the document, nothing on the timeline yet. */
function emptyTimeline(primaryAssetId: string, assetIds: string[]): AxcutDocument {
const doc = createEmptyDocument({ projectId: PROJECT, title: "A" });
return {
...doc,
project: { ...doc.project, primaryAssetId },
assets: assetIds.map((id) => ({
id,
kind: "video" as const,
label: `${id}.mp4`,
originalPath: `/tmp/${id}.mp4`,
cameraTrack: null,
})),
};
}

/** A v1.7 project: one clip with no source extent, waiting for a real length. */
function legacyWithClip(): AxcutDocument {
return documentSchema.parse(
migrateProjectDataToAxcutDocument({
version: 2,
videoPath: "C:/rec/screen.webm",
media: { videoPath: "C:/rec/screen.webm" },
editor: {
zoomRegions: [],
annotationRegions: [],
trimRegions: [],
speedRegions: [],
cameraFullscreenRegions: [],
},
} as never),
);
}

describe("documentAfterProbedDuration", () => {
it("seeds a full-duration clip when the primary asset reports its length", () => {
const doc = emptyTimeline("asset_1", ["asset_1"]);

const next = documentAfterProbedDuration(doc, "asset_1", 30, PROJECT);

expect(next).not.toBeNull();
expect(next?.assets[0].durationSec).toBe(30);
expect(next?.timeline.clips).toHaveLength(1);
expect(next?.timeline.clips[0].timelineStartSec).toBe(0);
expect(next?.timeline.clips[0].timelineEndSec).toBe(30);
});

// The write is queued, so the user can switch projects between the event and this
// decision. `knownSec` came off the OLD video; applying it to whatever is loaded
// now writes one recording's length into another project.
it("writes nothing when the project changed after the event fired", () => {
const doc = emptyTimeline("asset_1", ["asset_1"]);

expect(documentAfterProbedDuration(doc, "asset_1", 30, "proj_switched_to")).toBeNull();
expect(documentAfterProbedDuration(doc, "asset_1", 30, undefined)).toBeNull();
});

// `replaceTimeline` pins every clip it builds to the primary asset, and the seed
// sizes that clip from `knownSec` — so seeding on another asset's event would put
// one video's length under a different asset's id. The primary's own event seeds it.
it("does not seed from an asset that is not the one the seed is about", () => {
const doc = emptyTimeline("asset_1", ["asset_1", "asset_2"]);

expect(documentAfterProbedDuration(doc, "asset_2", 30, PROJECT)).toBeNull();
// And the primary still seeds normally on the same document.
expect(documentAfterProbedDuration(doc, "asset_1", 30, PROJECT)).not.toBeNull();
});

it("folds the length into a clip that was waiting for one", () => {
const doc = legacyWithClip();
const assetId = doc.assets[0].id;

const next = documentAfterProbedDuration(doc, assetId, 30, doc.project.id);

expect(next?.timeline.clips[0].sourceEndSec).toBe(30);
expect(next?.assets[0].durationSec).toBe(30);
});

it("writes nothing when the length is already recorded", () => {
const doc = legacyWithClip();
const assetId = doc.assets[0].id;
const settled = documentAfterProbedDuration(doc, assetId, 30, doc.project.id);

expect(settled).not.toBeNull();
expect(
documentAfterProbedDuration(settled as AxcutDocument, assetId, 30, doc.project.id),
).toBeNull();
});

// Empty is not the same as unseeded — the user can delete their only clip — and
// `knownSec` is 60 whenever the <video> reports a non-finite duration. Overwriting
// here traded a real length for the fallback under `history: false`.
it("keeps a length the asset already carries, and sizes the seed against it", () => {
const doc = emptyTimeline("asset_1", ["asset_1"]);
const measured: AxcutDocument = {
...doc,
assets: doc.assets.map((a) => ({ ...a, durationSec: 26.517 })),
};

const next = documentAfterProbedDuration(measured, "asset_1", 60, PROJECT);

expect(next?.assets[0].durationSec).toBe(26.517);
expect(next?.timeline.clips[0].timelineEndSec).toBeCloseTo(26.517, 3);
});

it("writes nothing without a document or without assets", () => {
expect(documentAfterProbedDuration(null, "asset_1", 30, PROJECT)).toBeNull();
expect(
documentAfterProbedDuration(emptyTimeline("asset_1", []), "asset_1", 30, PROJECT),
).toBeNull();
});
});
Loading
Loading