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
27 changes: 23 additions & 4 deletions electron/ai-edition/agent-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -771,6 +771,23 @@ describe("executeAgentTool", () => {
expect(() => documentSchema.parse(result.document)).not.toThrow();
});

it("MCP addAnnotation uses the highest saved layer after deletions", () => {
const initial = executeAgentTool(
fixtureDocument(),
"addAnnotation",
JSON.stringify({ startSec: 1, endSec: 2, text: "First" }),
);
const doc = initial.document as AxcutDocument;
doc.annotations[0].zIndex = 12;
const added = executeAgentTool(
doc,
"addAnnotation",
JSON.stringify({ startSec: 3, endSec: 4, text: "Next" }),
);
expect(added.ok).toBe(true);
expect(added.document?.annotations.map((a) => a.zIndex)).toEqual([12, 13]);
});

it("addAnnotation centres the text on a dark plate by default, and keeps it in frame", () => {
const centred = executeAgentTool(
fixtureDocument(),
Expand Down Expand Up @@ -918,7 +935,7 @@ describe("executeAgentTool", () => {
}
});

it("refuses a set that would run into another region, instead of landing it elsewhere", () => {
it("allows overlapping annotations while refusing overlapping modifiers", () => {
let document = fixtureDocument();
const add = (name: string, args: Record<string, unknown>) => {
const result = executeAgentTool(document, name, JSON.stringify(args));
Expand All @@ -933,9 +950,11 @@ describe("executeAgentTool", () => {
"setAnnotation",
JSON.stringify({ annotationId: firstId, startSec: 7, endSec: 9 }),
);
expect(blocked.ok).toBe(false);
expect(blocked.document).toBeUndefined();
expect(blocked.resultJson).toMatch(/NOT changed.*6\.0|NOT changed/);
expect(blocked.ok).toBe(true);
expect(blocked.document?.annotations.find((a) => a.id === firstId)).toMatchObject({
startMs: 7000,
endMs: 9000,
});

// The same rule for every kind of effect.
const zoom = add("addZoom", { startSec: 1, endSec: 2, depth: 2 });
Expand Down
20 changes: 11 additions & 9 deletions electron/ai-edition/agent-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,8 +385,9 @@ function placeTextOnFrame<T extends AxcutAnnotationRegion>(
// first region under each — which every set*/remove* tool accepts as the id.
function coalesceForAgent<T extends { id: string; startMs: number; endMs: number }>(
regions: T[],
annotations = false,
): T[] {
return coalesceRegionsForRuler(regions).map((pill) => ({
return coalesceRegionsForRuler(regions, 0.001, { annotations }).map((pill) => ({
...pill.member,
id: pill.ids[0],
startMs: Math.round(pill.start * 1000),
Expand Down Expand Up @@ -1459,7 +1460,7 @@ export function documentSnapshotForModel(
endSec: roundSec(s.endMs),
speed: s.speed,
})),
annotations: coalesceForAgent(document.annotations).map((a) => ({
annotations: coalesceForAgent(document.annotations, true).map((a) => ({
id: a.id,
startSec: roundSec(a.startMs),
endSec: roundSec(a.endMs),
Expand Down Expand Up @@ -3166,7 +3167,7 @@ export function executeAgentTool(
},
parsed.data,
),
zIndex: document.annotations.length + 1,
zIndex: Math.max(0, ...document.annotations.map((a) => a.zIndex)) + 1,
} as AxcutAnnotationRegion,
{ x: parsed.data.x, y: parsed.data.y },
document,
Expand Down Expand Up @@ -3225,14 +3226,12 @@ export function executeAgentTool(
if (!parsed.success) return failure(parsed.error.message);
const { annotationId } = parsed.data;
const existing = document.annotations.find((a) => a.id === annotationId);
const annPill = new Set(resolvePillIds(document.annotations, annotationId));
const annPill = new Set(
resolvePillIds(document.annotations, annotationId, 0.001, { annotations: true }),
);
if (!existing) return failure(`Unknown annotation: ${annotationId}`);
const { startMs, endMs } = resolveSpanMs(existing, parsed.data.startSec, parsed.data.endSec);
const blocked = refuseIfBlocked("annotation", document.annotations, annotationId, {
startMs,
endMs,
});
if (blocked) return blocked;

const { x, y, text, type, width, height } = parsed.data;
const mismatch = refuseShapeMismatch(type ?? existing.type, parsed.data);
if (mismatch) return mismatch;
Expand Down Expand Up @@ -3302,6 +3301,9 @@ export function executeAgentTool(
endMs,
document.timeline.clips,
() => createId("ann"),
0.001,
{},
{ annotations: true },
);
const landing = landingAfterPillEdit(
document.annotations,
Expand Down
100 changes: 99 additions & 1 deletion src/components/ai-edition/NewEditorShell.timelineHeight.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ vi.mock("@/contexts/I18nContext", () => ({
import { TooltipProvider } from "@/components/ui/tooltip";
import { EditorDialogsProvider } from "@/contexts/EditorDialogsContext";
import { AUDIO_ROW_EXPANSION_PX } from "@/lib/ai-edition/document/audioTracks";
import { createAudioTrack, createEmptyDocument } from "@/lib/ai-edition/schema";
import {
annotationRegionSchema,
createAudioTrack,
createEmptyDocument,
} from "@/lib/ai-edition/schema";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import {
DEFAULT_TIMELINE_HEIGHT_PX,
Expand Down Expand Up @@ -235,4 +239,98 @@ describe("NewEditorShell timeline height", () => {

expect(root.style.gridTemplateRows).toBe(`58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX}px`);
});

it("expands for overlapping annotation rows, caps the panel height and restores the saved base height", () => {
useProjectStore.getState().clear();
const { container } = renderShell();
const root = container.firstElementChild as HTMLElement;
const doc = createEmptyDocument({ projectId: "p", title: "t" });
doc.annotations = Array.from({ length: 8 }, (_, i) =>
annotationRegionSchema.parse({
id: `ann-${i}`,
startMs: 0,
endMs: 5000,
type: "text",
content: "Hello",
zIndex: i,
position: { x: 20, y: 20 },
size: { width: 30, height: 20 },
style: { color: "#ffffff", fontSize: 32, fontFamily: "Inter" },
}),
);
act(() => {
useProjectStore.setState({ document: { ...doc, annotations: doc.annotations.slice(0, 2) } });
});
expect(root.style.gridTemplateRows).toBe(`58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX + 27}px`);
act(() => {
useProjectStore.setState({ document: doc });
});
expect(root.style.gridTemplateRows).toBe(`58px 1fr ${MAX_TIMELINE_HEIGHT_PX}px`);
act(() => {
useProjectStore.setState({
document: {
...doc,
annotations: doc.annotations.map((ann, i) => ({
...ann,
startMs: i * 1000,
endMs: (i + 1) * 1000,
})),
},
});
});
expect(root.style.gridTemplateRows).toBe(`58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX}px`);

act(() => {
useProjectStore.setState({ document: { ...doc, annotations: [] } });
});
expect(root.style.gridTemplateRows).toBe(`58px 1fr ${DEFAULT_TIMELINE_HEIGHT_PX}px`);
});

it.each([
{ rowCount: 10, deltaY: 0, expectedBase: DEFAULT_TIMELINE_HEIGHT_PX },
{ rowCount: 10, deltaY: -1, expectedBase: DEFAULT_TIMELINE_HEIGHT_PX },
{ rowCount: 10, deltaY: 1, expectedBase: DEFAULT_TIMELINE_HEIGHT_PX - 1 },
{ rowCount: 16, deltaY: 0, expectedBase: DEFAULT_TIMELINE_HEIGHT_PX },
{ rowCount: 16, deltaY: -1, expectedBase: DEFAULT_TIMELINE_HEIGHT_PX },
{ rowCount: 16, deltaY: 1, expectedBase: DEFAULT_TIMELINE_HEIGHT_PX - 1 },
])("preserves the base after a $deltaY px resize with $rowCount capped annotation rows", async ({
rowCount,
deltaY,
expectedBase,
}) => {
useProjectStore.getState().clear();
const doc = createEmptyDocument({ projectId: "p", title: "t" });
doc.annotations = Array.from({ length: rowCount }, (_, i) =>
annotationRegionSchema.parse({
id: `ann-${i}`,
startMs: 0,
endMs: 5000,
type: "text",
content: "Hello",
zIndex: i,
position: { x: 20, y: 20 },
size: { width: 30, height: 20 },
style: { color: "#ffffff", fontSize: 32, fontFamily: "Inter" },
}),
);
useProjectStore.setState({ document: doc });
const { container } = renderShell();
const root = container.firstElementChild as HTMLElement;
const handle = container.querySelector(
'[role="separator"][aria-orientation="horizontal"]',
) as HTMLElement;
expect(root.style.gridTemplateRows).toBe(`58px 1fr ${MAX_TIMELINE_HEIGHT_PX}px`);

await act(async () => {
fireEvent.pointerDown(handle, { clientY: 400 });
fireEvent.pointerMove(window, { clientY: 400 + deltaY });
fireEvent.pointerUp(window);
});
expect(localStorage.getItem("os-editor-timeline-height")).toBe(String(expectedBase));

await act(async () => {
useProjectStore.setState({ document: { ...doc, annotations: [] } });
});
expect(root.style.gridTemplateRows).toBe(`58px 1fr ${expectedBase}px`);
});
});
21 changes: 16 additions & 5 deletions src/components/ai-edition/NewEditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { useTimeline } from "@/lib/ai-edition/store/useTimeline";
import { isGeneratedAssetId } from "@/lib/ai-edition/timeline/clip-parts";
import { mergeCloseCuts } from "@/lib/ai-edition/timeline/cut-breath";
import { newRegionDurationSec } from "@/lib/ai-edition/timeline/newRegionDuration";
import { REGION_ROW_STEP_PX } from "@/lib/ai-edition/timeline/regionTracks";
import {
dropTrimPillsByIds,
trimAppliesToClip,
Expand Down Expand Up @@ -253,9 +254,12 @@ export function NewEditorShell() {
[document?.audioTracks],
);
const hasEffects = (document?.shaderEffectRanges?.length ?? 0) > 0;
const extraAudioHeightPx = Math.max(0, audioRowCount - 1) * AUDIO_ROW_EXPANSION_PX;
// The rendered annotation layout reports its live row count, including a drag preview.
const [annotationRowCount, setAnnotationRowCount] = useState(1);
const extraAnnotationHeightPx = (annotationRowCount - 1) * REGION_ROW_STEP_PX;
const extraLaneHeightPx =
Math.max(0, audioRowCount - 1) * AUDIO_ROW_EXPANSION_PX +
(hasEffects ? EFFECT_LANE_EXPANSION_PX : 0);
extraAudioHeightPx + extraAnnotationHeightPx + (hasEffects ? EFFECT_LANE_EXPANSION_PX : 0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const timelineHeightPx = Math.min(
MAX_TIMELINE_HEIGHT_PX,
Math.max(MIN_TIMELINE_HEIGHT_PX, timelineBaseHeightPx + extraLaneHeightPx),
Expand Down Expand Up @@ -1231,6 +1235,7 @@ export function NewEditorShell() {
const region = wholePillOf(
source as Array<{ id: string; startMs: number; endMs: number }>,
sel.id,
{ annotations: sel.kind === "annotation" },
);
if (!region) return;
copyRegion({ kind: sel.kind, region: region as unknown as Record<string, unknown> });
Expand Down Expand Up @@ -1497,15 +1502,20 @@ export function NewEditorShell() {
e.preventDefault();
const startY = e.clientY;
const startBase = timelineBaseHeightPx;
const startRendered = timelineHeightPx;
let latestBase = startBase;
const move = (ev: PointerEvent) => {
// Dragging the handle up (negative clientY delta) enlarges the
// timeline, since it sits below the handle.
const renderedTarget = Math.min(
MAX_TIMELINE_HEIGHT_PX,
Math.max(MIN_TIMELINE_HEIGHT_PX, startBase + extraLaneHeightPx - (ev.clientY - startY)),
Math.max(MIN_TIMELINE_HEIGHT_PX, startRendered - (ev.clientY - startY)),
);
// Apply only the rendered-height delta, preserving the base when expansion hits the cap.
latestBase = Math.min(
MAX_TIMELINE_HEIGHT_PX,
Math.max(MIN_TIMELINE_HEIGHT_PX, startBase + (renderedTarget - startRendered)),
);
latestBase = Math.max(MIN_TIMELINE_HEIGHT_PX, renderedTarget - extraLaneHeightPx);
setTimelineBaseHeightPx(latestBase);
};
const up = () => {
Expand All @@ -1516,7 +1526,7 @@ export function NewEditorShell() {
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
},
[timelineBaseHeightPx, extraLaneHeightPx],
[timelineBaseHeightPx, timelineHeightPx],
);

const transcriptProps = {
Expand Down Expand Up @@ -1715,6 +1725,7 @@ export function NewEditorShell() {
/>
) : null}
<V4Timeline
onAnnotationRowCountChange={setAnnotationRowCount}
tl={tl}
setCurrentTime={handleSeek}
variant={mode === "media" ? "media" : "edit"}
Expand Down
Loading
Loading