Skip to content
Open
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
132 changes: 132 additions & 0 deletions src/components/ai-edition/NewEditorShell.probedDuration.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// 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 both guards below live 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, vi } from "vitest";

vi.mock("@/contexts/ShortcutsContext", async () => {
const { DEFAULT_SHORTCUTS } = await import("@/lib/shortcuts");
return {
useShortcuts: () => ({
shortcuts: DEFAULT_SHORTCUTS,
isMac: false,
isConfigOpen: false,
openConfig: vi.fn(),
closeConfig: vi.fn(),
setShortcuts: vi.fn(),
persistShortcuts: () => Promise.resolve(true),
}),
};
});

vi.mock("@/contexts/I18nContext", () => ({
useI18n: () => ({ locale: "en", setLocale: vi.fn() }),
useScopedT: () => (key: string) => key,
}));

import { migrateProjectDataToAxcutDocument } from "@/lib/ai-edition/document/migrate";
import { type AxcutDocument, createEmptyDocument, documentSchema } from "@/lib/ai-edition/schema";
import { documentAfterProbedDuration } from "./NewEditorShell";

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();
});

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();
});
});
133 changes: 92 additions & 41 deletions src/components/ai-edition/NewEditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ import {
setDocumentWordText,
} from "@/lib/ai-edition/document/transcript";
import { isModalOpen } from "@/lib/ai-edition/modalGuard";
import { type AxcutAudioTrack, type AxcutClip, documentSchema } from "@/lib/ai-edition/schema";
import {
type AxcutAudioTrack,
type AxcutClip,
type AxcutDocument,
documentSchema,
} from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import {
useAssetTranscriptions,
Expand Down Expand Up @@ -115,6 +120,59 @@ export const DEFAULT_TIMELINE_HEIGHT_PX = 392;
export const MIN_TIMELINE_HEIGHT_PX = 160;
export const MAX_TIMELINE_HEIGHT_PX = 560;

/**
* What a `loadedmetadata` event should write, or `null` for "write nothing".
*
* Exported because it is the only part of this path a test can reach: the event
* arrives through Preview -> PreviewCanvas -> VirtualPreview and a real <video>,
* none of which jsdom fires. Keeping the decision here and the queueing in the
* component means the two guards below are testable without standing all four up.
*
* `originatingProjectId` is the project that was open when the event fired. It
* matters because the write is queued: by the time this runs the user may have
* switched, and `knownSec` came off the OLD video, so applying it to the new
* project is simply a wrong number. `saveDocument`'s epoch check cannot catch
* that — the write is issued after the switch rather than across it.
*/
export function documentAfterProbedDuration(
doc: AxcutDocument | null,
assetId: string,
knownSec: number,
originatingProjectId: string | undefined,
): AxcutDocument | null {
if (!doc || doc.assets.length === 0) return null;
if (doc.project.id !== originatingProjectId) return null;
if (doc.timeline.clips.length === 0) {
// ponytail: replaceTimeline derives clip length from asset.durationSec, which
// import never populates — without this the first auto-created clip silently
// comes out empty (normalizeIntervals clamps against a 0 duration, dropping it).
const primaryAssetId = doc.project.primaryAssetId ?? doc.assets[0]?.id;
// Only the asset that actually fired. `replaceTimeline` pins every clip it
// builds to the primary asset, and the seed sizes that clip from `knownSec` —
// so seeding on an event from any OTHER asset writes one video's length under
// another's id. Not a lost seed: the primary's own event does its own seeding.
if (!primaryAssetId || primaryAssetId !== assetId) return null;
const docWithDuration: AxcutDocument = {
...doc,
assets: doc.assets.map((a) =>
a.id === primaryAssetId ? { ...a, durationSec: knownSec } : a,
),
};
return replaceTimelineOp(
docWithDuration,
[{ startSec: 0, endSec: knownSec }],
"Auto-created full-duration clip",
);
}
// The pure document layer 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. It returns the document
// untouched when nothing is waiting, which is this function's "write nothing".
const next = applyProbedDuration(doc, assetId, knownSec);
return next === doc ? null : next;
}

export function NewEditorShell() {
const te = useScopedT("editor");
const document = useProjectStore((s) => s.document);
Expand Down Expand Up @@ -426,50 +484,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 +1606,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
Loading
Loading