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
109 changes: 109 additions & 0 deletions electron/ai-edition/agent-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2505,3 +2505,112 @@ describe("addTrim shapes the cut like the transcript pane", () => {
expect(lastTrim(result.document)?.endSec).toBe(15);
});
});

describe("zoom transitions are reported, not hidden (#1028)", () => {
// 1.8× (depth 3): each move lasts 0.6 + 0.55·ln 1.8 ≈ 0.923 s of screen time.
const MOVE_SEC = 0.923;

function run(document: AxcutDocument, name: string, args: unknown) {
const result = executeAgentTool(document, name, JSON.stringify(args));
expect(result.ok).toBe(true);
return { document: result.document ?? document, payload: JSON.parse(result.resultJson) };
}

function zoomedAt10to12() {
return run(shortSingleClip(), "addZoom", { startSec: 10, endSec: 12, depth: 3 });
}

function snapshotZoom(document: AxcutDocument) {
return run(document, "getCurrentDocument", {}).payload.zoomRanges[0];
}

it("gives each zoom its move windows on the timeline", () => {
const added = zoomedAt10to12();
expect(added.payload.cutTransitions).toBeUndefined();
const zoom = snapshotZoom(added.document);
expect(zoom.transitionSec).toBe(MOVE_SEC);
expect(zoom.zoomInFromSec).toBe(10 - MOVE_SEC);
expect(zoom.zoomOutUntilSec).toBe(12 + MOVE_SEC);
expect(zoom.cutByTrim).toBeUndefined();
});

it("warns when a trim at the zoom's end cuts its whole zoom-out (the issue's repro)", () => {
const trimmed = run(zoomedAt10to12().document, "addTrim", { startSec: 12, endSec: 14 });
const zoomId = snapshotZoom(trimmed.document).id;
expect(trimmed.payload.cutTransitions).toEqual([{ zoomId, side: "out", cutSec: MOVE_SEC }]);
expect(snapshotZoom(trimmed.document).cutByTrim).toBe("out");
});

it("flags a zoom on the second clip of an asset that a trim without clipId cuts", () => {
// Two clips over one recording, and a pre-v7 trim (no clipId): playback cuts it from
// both clips, while the ruler maps it through the first one only.
const base = shortSingleClip();
const clip = base.timeline.clips[0];
const length = (clip.sourceEndSec ?? 0) - clip.sourceStartSec;
const twoClips: AxcutDocument = {
...base,
timeline: {
...base.timeline,
clips: [
clip,
{
...clip,
id: "clip_2",
timelineStartSec: length,
timelineEndSec: 2 * length,
},
],
trimRanges: [
{
id: "trim_legacy",
assetId: clip.assetId,
startSec: 12,
endSec: 14,
reason: "",
origin: "user",
},
],
},
};
const added = run(twoClips, "addZoom", {
startSec: length + 10,
endSec: length + 12,
depth: 3,
});
expect(added.payload.cutTransitions).toEqual([
{ zoomId: added.payload.zoomId, side: "out", cutSec: MOVE_SEC },
]);
});

it("stays quiet about a trim clear of both windows", () => {
const trimmed = run(zoomedAt10to12().document, "addTrim", { startSec: 14, endSec: 15 });
expect(trimmed.payload.cutTransitions).toBeUndefined();
});

it("warns the zoom write too, when it lands next to an existing cut", () => {
const trimmed = run(shortSingleClip(), "addTrim", { startSec: 8, endSec: 9.5 });
const added = run(trimmed.document, "addZoom", { startSec: 10, endSec: 12, depth: 3 });
expect(added.payload.cutTransitions).toEqual([
{ zoomId: added.payload.zoomId, side: "in", cutSec: 0.423 },
]);
expect(snapshotZoom(added.document).cutByTrim).toBe("in");
});

it("repeats the warning when setZoom moves a zoom that is still cut", () => {
const trimmed = run(zoomedAt10to12().document, "addTrim", { startSec: 12, endSec: 14 });
const zoomId = snapshotZoom(trimmed.document).id;
const moved = run(trimmed.document, "setZoom", { zoomId, startSec: 9 });
expect(moved.payload.cutTransitions).toEqual([
{ zoomId: moved.payload.zoomId, side: "out", cutSec: MOVE_SEC },
]);
});

it("stretches the zoom-out across a speed region, and catches a trim placed there", () => {
const sped = run(zoomedAt10to12().document, "addSpeed", { startSec: 12, endSec: 20, speed: 3 });
expect(snapshotZoom(sped.document).zoomOutUntilSec).toBe(14.77);
const trimmed = run(sped.document, "addTrims", { ranges: [{ startSec: 14, endSec: 15 }] });
expect(trimmed.payload.applied[0].cutTransitions).toEqual([
{ zoomId: snapshotZoom(trimmed.document).id, side: "out", cutSec: 0.77 },
]);
});
});
88 changes: 83 additions & 5 deletions electron/ai-edition/agent-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ import {
replacePillSpan,
resolvePillIds,
} from "../../src/lib/ai-edition/timeline/timelineMap";
import { trimAppliesToClip } from "../../src/lib/ai-edition/timeline/trim-mapping";
import {
trimAppliesToClip,
trimToTimelineSpan,
} from "../../src/lib/ai-edition/timeline/trim-mapping";
// ponytail: relative, and it has to stay that way — `electron/` never resolves
// the `@/` alias (the main-process build does not declare it), which is why the
// scale table was moved out of `components/video-editor/types.ts` to be
Expand All @@ -57,6 +60,10 @@ import {
effectiveZoomScale,
ZOOM_DEPTH_LEGEND,
} from "../../src/lib/ai-edition/timeline/zoom-scale";
import {
transitionCutsMs,
zoomTransitions,
} from "../../src/lib/ai-edition/timeline/zoom-transitions";
import { SETTING_BOUNDS } from "../../src/lib/projectDefaults";

export interface AgentToolExecution {
Expand Down Expand Up @@ -713,6 +720,56 @@ function roundSec(ms: number): number {
return Math.round(ms) / 1000;
}

// Each zoom pill's camera moves in virtual ms, and how much of each a trim cuts off (#1028).
// The pill shows the hold only: the move in runs before it, the move out after it, and a
// trim over either makes the export jump at the cut.
function zoomTransitionsForAgent(document: AxcutDocument) {
const legacy = document.legacyEditor as Record<string, unknown> | null;
const speedRegions =
(legacy?.speedRegions as
| Array<{ id: string; startMs: number; endMs: number; speed: number }>
| undefined) ?? [];
// A trim with no clipId cuts every clip of its asset in playback, while the ruler maps it
// through one: measured per clip, so a zoom on any of them learns of the cut.
const trims = document.timeline.trimRanges.flatMap((trim) => {
const clips =
trim.clipId !== undefined
? [trim]
: document.timeline.clips
.filter((clip) => clip.assetId === trim.assetId)
.map((clip) => ({ ...trim, clipId: clip.id }));
return clips.flatMap((anchored) => {
const span = trimToTimelineSpan(anchored, document.timeline.clips);
return span ? [{ startMs: span.start * 1000, endMs: span.end * 1000 }] : [];
});
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return coalesceForAgent(document.zoomRanges).map((zoom) => {
const transitions = zoomTransitions({ ...zoom, scale: effectiveZoomScale(zoom) }, speedRegions);
return { zoom, transitions, cut: transitionCutsMs(zoom, transitions, trims) };
});
}

/** The transitions this edit cut that were not cut, or not this way, before it: the warning a
* zoom or trim write carries, like `cursorAnchor`: reported, never refused. */
function cutTransitionsReport(before: AxcutDocument, after: AxcutDocument) {
const cutsOf = (document: AxcutDocument) =>
zoomTransitionsForAgent(document).flatMap(({ zoom, cut }) =>
(["in", "out"] as const)
.map((side) => ({
zoomId: zoom.id,
side,
cutSec: roundSec(side === "in" ? cut.inMs : cut.outMs),
key: `${zoom.id}:${side}:${zoom.startMs}:${zoom.endMs}`,
}))
.filter((c) => c.cutSec > 0),
);
const known = new Set(cutsOf(before).map((c) => `${c.key}:${c.cutSec}`));
const cuts = cutsOf(after)
.filter((c) => !known.has(`${c.key}:${c.cutSec}`))
.map(({ key: _key, ...c }) => c);
return cuts.length ? { cutTransitions: cuts } : {};
}

// Compact projection of the document for the model: everything it needs to
// reference ids and times, nothing it doesn't (no waveform paths, no history).
//
Expand Down Expand Up @@ -764,7 +821,10 @@ export function documentSnapshotForModel(
zoomNote:
`renderedScale is what the viewer sees (depth is an ordinal, not a factor: ${ZOOM_DEPTH_LEGEND}). ` +
"When a zoom carries customScale it wins over depth and depthIsOverridden is true — " +
"a setZoom that only changes depth on such a zoom clears customScale so the depth takes effect.",
"a setZoom that only changes depth on such a zoom clears customScale so the depth takes effect. " +
"startSec–endSec is where the zoom HOLDS: it animates in over zoomInFromSec–startSec and out over " +
"endSec–zoomOutUntilSec (transitionSec of screen time each; a speed region scales that on the " +
"timeline, longer above 1x and shorter below, so read the bounds rather than adding transitionSec). cutByTrim names the move a trim cuts into (in, out or both): the export jumps at that cut.",
project: { id: document.project.id, title: document.project.title },
primaryAssetId: document.project.primaryAssetId ?? document.assets[0]?.id ?? null,
autoFocusAll,
Expand Down Expand Up @@ -821,10 +881,16 @@ export function documentSnapshotForModel(
endSec: s.endSec,
reason: s.reason,
})),
zoomRanges: coalesceForAgent(document.zoomRanges).map((z) => ({
zoomRanges: zoomTransitionsForAgent(document).map(({ zoom: z, transitions, cut }) => ({
id: z.id,
startSec: roundSec(z.startMs),
endSec: roundSec(z.endMs),
zoomInFromSec: roundSec(transitions.inFromMs),
zoomOutUntilSec: roundSec(transitions.outUntilMs),
transitionSec: roundSec(transitions.durationMs),
...(cut.inMs > 0 || cut.outMs > 0
? { cutByTrim: cut.inMs > 0 ? (cut.outMs > 0 ? "both" : "in") : "out" }
: {}),
depth: z.depth,
renderedScale: effectiveZoomScale(z),
// Emitted only when set: an unconditional `customScale: null` on every
Expand Down Expand Up @@ -1501,7 +1567,12 @@ export function executeAgentTool(
return {
ok: true,
document: next,
resultJson: JSON.stringify({ trimRangeId: trim.id, startSec, endSec }),
resultJson: JSON.stringify({
trimRangeId: trim.id,
startSec,
endSec,
...cutTransitionsReport(document, next),
}),
summary: `added trim ${formatSec(startSec)} – ${formatSec(endSec)}`,
};
}
Expand Down Expand Up @@ -1548,7 +1619,12 @@ export function executeAgentTool(
return {
ok: true,
document: next,
resultJson: JSON.stringify({ trimRangeId, startSec, endSec }),
resultJson: JSON.stringify({
trimRangeId,
startSec,
endSec,
...cutTransitionsReport(document, next),
}),
summary: `moved trim to ${formatSec(startSec)} – ${formatSec(endSec)}`,
};
}
Expand Down Expand Up @@ -1744,6 +1820,7 @@ export function executeAgentTool(
renderedScale: effectiveZoomScale(zoom),
...landingReport(landing, startMs / 1000, endMs / 1000),
...(anchor ? { cursorAnchor: anchor } : {}),
...cutTransitionsReport(document, next),
}),
summary:
`added zoom ${formatSec(landing.startSec)} – ${formatSec(landing.endSec)} ` +
Expand Down Expand Up @@ -1828,6 +1905,7 @@ export function executeAgentTool(
...(clearsCustomScale ? { clearedCustomScale: true } : {}),
...landingReport(landing, startMs / 1000, endMs / 1000),
...(anchor ? { cursorAnchor: anchor } : {}),
...cutTransitionsReport(document, next),
}),
summary:
`updated zoom ${formatSec(landing.startSec)} – ${formatSec(landing.endSec)}` +
Expand Down
8 changes: 8 additions & 0 deletions electron/ai-edition/deep-agent/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,14 @@ describe("what the descriptions say about zoom strength", () => {
expect(TOOL_DESCRIPTIONS.addCameraFullscreen).toMatch(/hasCameraTrack/);
expect(SYSTEM_PROMPT).toMatch(/hasCameraTrack/);
});

it("tells every zoom and trim write where a zoom's moves are (#1028)", () => {
for (const name of ["addZoom", "addZooms", "setZoom", "addTrim", "addTrims", "setTrim"]) {
expect(TOOL_DESCRIPTIONS[name]).toContain("zoomOutUntilSec");
expect(TOOL_DESCRIPTIONS[name]).toContain("cutTransitions");
}
expect(SYSTEM_PROMPT).toMatch(/zoomInFromSec.*untrimmed/);
});
});

// ── D-CONSENT ───────────────────────────────────────────────────────────────
Expand Down
Loading
Loading