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
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
*.rs text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
# Vite's SSR hashbang detection requires LF in imported executable modules.
*.mjs text eol=lf
# Same drift, caught on the native helper's build file: an edit from Windows
# rewrote all 67 lines as CRLF and buried a 22-line change in a 156-line diff.
CMakeLists.txt text eol=lf
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
Loading
Loading