Skip to content

Commit 9938956

Browse files
committed
merge: sync main and preserve filler tool compatibility
2 parents 4763c25 + e8b8544 commit 9938956

18 files changed

Lines changed: 654 additions & 48 deletions

‎electron/ai-edition/agent-tools.test.ts‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2662,3 +2662,112 @@ describe("addTrim shapes the cut like the transcript pane", () => {
26622662
expect(lastTrim(result.document)?.endSec).toBe(15);
26632663
});
26642664
});
2665+
2666+
describe("zoom transitions are reported, not hidden (#1028)", () => {
2667+
// 1.8× (depth 3): each move lasts 0.6 + 0.55·ln 1.8 ≈ 0.923 s of screen time.
2668+
const MOVE_SEC = 0.923;
2669+
2670+
function run(document: AxcutDocument, name: string, args: unknown) {
2671+
const result = executeAgentTool(document, name, JSON.stringify(args));
2672+
expect(result.ok).toBe(true);
2673+
return { document: result.document ?? document, payload: JSON.parse(result.resultJson) };
2674+
}
2675+
2676+
function zoomedAt10to12() {
2677+
return run(shortSingleClip(), "addZoom", { startSec: 10, endSec: 12, depth: 3 });
2678+
}
2679+
2680+
function snapshotZoom(document: AxcutDocument) {
2681+
return run(document, "getCurrentDocument", {}).payload.zoomRanges[0];
2682+
}
2683+
2684+
it("gives each zoom its move windows on the timeline", () => {
2685+
const added = zoomedAt10to12();
2686+
expect(added.payload.cutTransitions).toBeUndefined();
2687+
const zoom = snapshotZoom(added.document);
2688+
expect(zoom.transitionSec).toBe(MOVE_SEC);
2689+
expect(zoom.zoomInFromSec).toBe(10 - MOVE_SEC);
2690+
expect(zoom.zoomOutUntilSec).toBe(12 + MOVE_SEC);
2691+
expect(zoom.cutByTrim).toBeUndefined();
2692+
});
2693+
2694+
it("warns when a trim at the zoom's end cuts its whole zoom-out (the issue's repro)", () => {
2695+
const trimmed = run(zoomedAt10to12().document, "addTrim", { startSec: 12, endSec: 14 });
2696+
const zoomId = snapshotZoom(trimmed.document).id;
2697+
expect(trimmed.payload.cutTransitions).toEqual([{ zoomId, side: "out", cutSec: MOVE_SEC }]);
2698+
expect(snapshotZoom(trimmed.document).cutByTrim).toBe("out");
2699+
});
2700+
2701+
it("flags a zoom on the second clip of an asset that a trim without clipId cuts", () => {
2702+
// Two clips over one recording, and a pre-v7 trim (no clipId): playback cuts it from
2703+
// both clips, while the ruler maps it through the first one only.
2704+
const base = shortSingleClip();
2705+
const clip = base.timeline.clips[0];
2706+
const length = (clip.sourceEndSec ?? 0) - clip.sourceStartSec;
2707+
const twoClips: AxcutDocument = {
2708+
...base,
2709+
timeline: {
2710+
...base.timeline,
2711+
clips: [
2712+
clip,
2713+
{
2714+
...clip,
2715+
id: "clip_2",
2716+
timelineStartSec: length,
2717+
timelineEndSec: 2 * length,
2718+
},
2719+
],
2720+
trimRanges: [
2721+
{
2722+
id: "trim_legacy",
2723+
assetId: clip.assetId,
2724+
startSec: 12,
2725+
endSec: 14,
2726+
reason: "",
2727+
origin: "user",
2728+
},
2729+
],
2730+
},
2731+
};
2732+
const added = run(twoClips, "addZoom", {
2733+
startSec: length + 10,
2734+
endSec: length + 12,
2735+
depth: 3,
2736+
});
2737+
expect(added.payload.cutTransitions).toEqual([
2738+
{ zoomId: added.payload.zoomId, side: "out", cutSec: MOVE_SEC },
2739+
]);
2740+
});
2741+
2742+
it("stays quiet about a trim clear of both windows", () => {
2743+
const trimmed = run(zoomedAt10to12().document, "addTrim", { startSec: 14, endSec: 15 });
2744+
expect(trimmed.payload.cutTransitions).toBeUndefined();
2745+
});
2746+
2747+
it("warns the zoom write too, when it lands next to an existing cut", () => {
2748+
const trimmed = run(shortSingleClip(), "addTrim", { startSec: 8, endSec: 9.5 });
2749+
const added = run(trimmed.document, "addZoom", { startSec: 10, endSec: 12, depth: 3 });
2750+
expect(added.payload.cutTransitions).toEqual([
2751+
{ zoomId: added.payload.zoomId, side: "in", cutSec: 0.423 },
2752+
]);
2753+
expect(snapshotZoom(added.document).cutByTrim).toBe("in");
2754+
});
2755+
2756+
it("repeats the warning when setZoom moves a zoom that is still cut", () => {
2757+
const trimmed = run(zoomedAt10to12().document, "addTrim", { startSec: 12, endSec: 14 });
2758+
const zoomId = snapshotZoom(trimmed.document).id;
2759+
const moved = run(trimmed.document, "setZoom", { zoomId, startSec: 9 });
2760+
expect(moved.payload.cutTransitions).toEqual([
2761+
{ zoomId: moved.payload.zoomId, side: "out", cutSec: MOVE_SEC },
2762+
]);
2763+
});
2764+
2765+
it("stretches the zoom-out across a speed region, and catches a trim placed there", () => {
2766+
const sped = run(zoomedAt10to12().document, "addSpeed", { startSec: 12, endSec: 20, speed: 3 });
2767+
expect(snapshotZoom(sped.document).zoomOutUntilSec).toBe(14.77);
2768+
const trimmed = run(sped.document, "addTrims", { ranges: [{ startSec: 14, endSec: 15 }] });
2769+
expect(trimmed.payload.applied[0].cutTransitions).toEqual([
2770+
{ zoomId: snapshotZoom(trimmed.document).id, side: "out", cutSec: 0.77 },
2771+
]);
2772+
});
2773+
});

‎electron/ai-edition/agent-tools.ts‎

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ import {
4848
replacePillSpan,
4949
resolvePillIds,
5050
} from "../../src/lib/ai-edition/timeline/timelineMap";
51-
import { trimAppliesToClip } from "../../src/lib/ai-edition/timeline/trim-mapping";
51+
import {
52+
trimAppliesToClip,
53+
trimToTimelineSpan,
54+
} from "../../src/lib/ai-edition/timeline/trim-mapping";
5255
// ponytail: relative, and it has to stay that way — `electron/` never resolves
5356
// the `@/` alias (the main-process build does not declare it), which is why the
5457
// scale table was moved out of `components/video-editor/types.ts` to be
@@ -57,6 +60,10 @@ import {
5760
effectiveZoomScale,
5861
ZOOM_DEPTH_LEGEND,
5962
} from "../../src/lib/ai-edition/timeline/zoom-scale";
63+
import {
64+
transitionCutsMs,
65+
zoomTransitions,
66+
} from "../../src/lib/ai-edition/timeline/zoom-transitions";
6067
import { SETTING_BOUNDS } from "../../src/lib/projectDefaults";
6168

6269
export interface AgentToolExecution {
@@ -720,6 +727,56 @@ function roundSec(ms: number): number {
720727
return Math.round(ms) / 1000;
721728
}
722729

730+
// Each zoom pill's camera moves in virtual ms, and how much of each a trim cuts off (#1028).
731+
// The pill shows the hold only: the move in runs before it, the move out after it, and a
732+
// trim over either makes the export jump at the cut.
733+
function zoomTransitionsForAgent(document: AxcutDocument) {
734+
const legacy = document.legacyEditor as Record<string, unknown> | null;
735+
const speedRegions =
736+
(legacy?.speedRegions as
737+
| Array<{ id: string; startMs: number; endMs: number; speed: number }>
738+
| undefined) ?? [];
739+
// A trim with no clipId cuts every clip of its asset in playback, while the ruler maps it
740+
// through one: measured per clip, so a zoom on any of them learns of the cut.
741+
const trims = document.timeline.trimRanges.flatMap((trim) => {
742+
const clips =
743+
trim.clipId !== undefined
744+
? [trim]
745+
: document.timeline.clips
746+
.filter((clip) => clip.assetId === trim.assetId)
747+
.map((clip) => ({ ...trim, clipId: clip.id }));
748+
return clips.flatMap((anchored) => {
749+
const span = trimToTimelineSpan(anchored, document.timeline.clips);
750+
return span ? [{ startMs: span.start * 1000, endMs: span.end * 1000 }] : [];
751+
});
752+
});
753+
return coalesceForAgent(document.zoomRanges).map((zoom) => {
754+
const transitions = zoomTransitions({ ...zoom, scale: effectiveZoomScale(zoom) }, speedRegions);
755+
return { zoom, transitions, cut: transitionCutsMs(zoom, transitions, trims) };
756+
});
757+
}
758+
759+
/** The transitions this edit cut that were not cut, or not this way, before it: the warning a
760+
* zoom or trim write carries, like `cursorAnchor`: reported, never refused. */
761+
function cutTransitionsReport(before: AxcutDocument, after: AxcutDocument) {
762+
const cutsOf = (document: AxcutDocument) =>
763+
zoomTransitionsForAgent(document).flatMap(({ zoom, cut }) =>
764+
(["in", "out"] as const)
765+
.map((side) => ({
766+
zoomId: zoom.id,
767+
side,
768+
cutSec: roundSec(side === "in" ? cut.inMs : cut.outMs),
769+
key: `${zoom.id}:${side}:${zoom.startMs}:${zoom.endMs}`,
770+
}))
771+
.filter((c) => c.cutSec > 0),
772+
);
773+
const known = new Set(cutsOf(before).map((c) => `${c.key}:${c.cutSec}`));
774+
const cuts = cutsOf(after)
775+
.filter((c) => !known.has(`${c.key}:${c.cutSec}`))
776+
.map(({ key: _key, ...c }) => c);
777+
return cuts.length ? { cutTransitions: cuts } : {};
778+
}
779+
723780
// Compact projection of the document for the model: everything it needs to
724781
// reference ids and times, nothing it doesn't (no waveform paths, no history).
725782
//
@@ -771,7 +828,10 @@ export function documentSnapshotForModel(
771828
zoomNote:
772829
`renderedScale is what the viewer sees (depth is an ordinal, not a factor: ${ZOOM_DEPTH_LEGEND}). ` +
773830
"When a zoom carries customScale it wins over depth and depthIsOverridden is true — " +
774-
"a setZoom that only changes depth on such a zoom clears customScale so the depth takes effect.",
831+
"a setZoom that only changes depth on such a zoom clears customScale so the depth takes effect. " +
832+
"startSec–endSec is where the zoom HOLDS: it animates in over zoomInFromSec–startSec and out over " +
833+
"endSec–zoomOutUntilSec (transitionSec of screen time each; a speed region scales that on the " +
834+
"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.",
775835
project: { id: document.project.id, title: document.project.title },
776836
primaryAssetId: document.project.primaryAssetId ?? document.assets[0]?.id ?? null,
777837
autoFocusAll,
@@ -828,10 +888,16 @@ export function documentSnapshotForModel(
828888
endSec: s.endSec,
829889
reason: s.reason,
830890
})),
831-
zoomRanges: coalesceForAgent(document.zoomRanges).map((z) => ({
891+
zoomRanges: zoomTransitionsForAgent(document).map(({ zoom: z, transitions, cut }) => ({
832892
id: z.id,
833893
startSec: roundSec(z.startMs),
834894
endSec: roundSec(z.endMs),
895+
zoomInFromSec: roundSec(transitions.inFromMs),
896+
zoomOutUntilSec: roundSec(transitions.outUntilMs),
897+
transitionSec: roundSec(transitions.durationMs),
898+
...(cut.inMs > 0 || cut.outMs > 0
899+
? { cutByTrim: cut.inMs > 0 ? (cut.outMs > 0 ? "both" : "in") : "out" }
900+
: {}),
835901
depth: z.depth,
836902
renderedScale: effectiveZoomScale(z),
837903
// Emitted only when set: an unconditional `customScale: null` on every
@@ -1658,7 +1724,12 @@ export function executeAgentTool(
16581724
return {
16591725
ok: true,
16601726
document: next,
1661-
resultJson: JSON.stringify({ trimRangeId: trim.id, startSec, endSec }),
1727+
resultJson: JSON.stringify({
1728+
trimRangeId: trim.id,
1729+
startSec,
1730+
endSec,
1731+
...cutTransitionsReport(document, next),
1732+
}),
16621733
summary: `added trim ${formatSec(startSec)} – ${formatSec(endSec)}`,
16631734
};
16641735
}
@@ -1705,7 +1776,12 @@ export function executeAgentTool(
17051776
return {
17061777
ok: true,
17071778
document: next,
1708-
resultJson: JSON.stringify({ trimRangeId, startSec, endSec }),
1779+
resultJson: JSON.stringify({
1780+
trimRangeId,
1781+
startSec,
1782+
endSec,
1783+
...cutTransitionsReport(document, next),
1784+
}),
17091785
summary: `moved trim to ${formatSec(startSec)} – ${formatSec(endSec)}`,
17101786
};
17111787
}
@@ -1901,6 +1977,7 @@ export function executeAgentTool(
19011977
renderedScale: effectiveZoomScale(zoom),
19021978
...landingReport(landing, startMs / 1000, endMs / 1000),
19031979
...(anchor ? { cursorAnchor: anchor } : {}),
1980+
...cutTransitionsReport(document, next),
19041981
}),
19051982
summary:
19061983
`added zoom ${formatSec(landing.startSec)} – ${formatSec(landing.endSec)} ` +
@@ -1985,6 +2062,7 @@ export function executeAgentTool(
19852062
...(clearsCustomScale ? { clearedCustomScale: true } : {}),
19862063
...landingReport(landing, startMs / 1000, endMs / 1000),
19872064
...(anchor ? { cursorAnchor: anchor } : {}),
2065+
...cutTransitionsReport(document, next),
19882066
}),
19892067
summary:
19902068
`updated zoom ${formatSec(landing.startSec)} – ${formatSec(landing.endSec)}` +

‎electron/ai-edition/deep-agent/service.test.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,14 @@ describe("what the descriptions say about zoom strength", () => {
391391
expect(TOOL_DESCRIPTIONS.addCameraFullscreen).toMatch(/hasCameraTrack/);
392392
expect(SYSTEM_PROMPT).toMatch(/hasCameraTrack/);
393393
});
394+
395+
it("tells every zoom and trim write where a zoom's moves are (#1028)", () => {
396+
for (const name of ["addZoom", "addZooms", "setZoom", "addTrim", "addTrims", "setTrim"]) {
397+
expect(TOOL_DESCRIPTIONS[name]).toContain("zoomOutUntilSec");
398+
expect(TOOL_DESCRIPTIONS[name]).toContain("cutTransitions");
399+
}
400+
expect(SYSTEM_PROMPT).toMatch(/zoomInFromSec.*untrimmed/);
401+
});
394402
});
395403

396404
// ── D-CONSENT ───────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)