feat: add undo, duplicate_elements tools and enrich list_timeline hints

- undo: reverts the last editing action via core.command.undo()
- duplicate_elements: duplicates elements onto new tracks using
  DuplicateElementsCommand
- list_timeline now includes hasMask, hasEffects, and isHidden hints
  on elements so the agent can identify clips needing deeper inspection
This commit is contained in:
Luis Esteban Acevedo Ladino 2026-04-26 18:54:36 -05:00
parent b48062a5f7
commit 28b825267f
7 changed files with 130 additions and 0 deletions

View File

@ -120,6 +120,9 @@ function toTimelineTrack(
...(hasMediaId(element) ? { assetId: element.mediaId } : {}),
...(element.name ? { name: element.name } : {}),
...(hasTextContent(element) ? { content: element.content } : {}),
...(hasNonEmptyArray(element, "masks") ? { hasMask: true } : {}),
...(hasNonEmptyArray(element, "effects") ? { hasEffects: true } : {}),
...(element.hidden === true ? { isHidden: true } : {}),
start: toSeconds(element.startTime, ticksPerSecond),
end: toSeconds(element.startTime + element.duration, ticksPerSecond),
})),
@ -185,6 +188,9 @@ function hasTimelineElementShape(element: unknown): element is {
name?: string;
startTime: number;
duration: number;
masks?: unknown[];
effects?: unknown[];
hidden?: boolean;
} {
return (
typeof element === "object" &&
@ -200,3 +206,9 @@ function hasTimelineElementShape(element: unknown): element is {
typeof element.duration === "number"
);
}
function hasNonEmptyArray(obj: unknown, key: string): boolean {
if (typeof obj !== "object" || obj === null || !(key in obj)) return false;
const value = (obj as Record<string, unknown>)[key];
return Array.isArray(value) && value.length > 0;
}

View File

@ -79,6 +79,55 @@ export const EditorContextAdapter = {
return asset?.hasAudio;
},
undo(): { remainingUndoDepth: number } | { error: string } {
const core = EditorCore.getInstance();
if (!core.command.canUndo()) {
return { error: "Nothing to undo" };
}
core.command.undo();
const remainingUndoDepth = core.command.canUndo() ? 1 : 0;
return { remainingUndoDepth };
},
duplicateElements({ elementIds }: { elementIds: string[] }):
| {
success: boolean;
duplicated: Array<{ elementId: string; trackId: string }>;
}
| { error: string } {
const core = EditorCore.getInstance();
const activeScene = core.scenes.getActiveSceneOrNull();
if (!activeScene) {
return { error: "No active timeline" };
}
if (!hasTimelineContent(activeScene.tracks)) {
return { error: "No timeline content" };
}
const requestedIds = [...new Set(elementIds)];
const elements = findTimelineElementsWithTracksByIds({
tracks: activeScene.tracks,
elementIds: requestedIds,
});
const foundIds = new Set(elements.map(({ element }) => element.id));
const missingIds = requestedIds.filter((id) => !foundIds.has(id));
if (missingIds.length > 0) {
return {
error: `Timeline elements not found: ${missingIds.join(", ")}`,
};
}
const refs = elements.map(({ element, track }) => ({
trackId: track.id,
elementId: element.id,
}));
const duplicated = core.timeline.duplicateElements({ elements: refs });
return { success: true, duplicated };
},
splitTimeline({
times,
}: {

View File

@ -0,0 +1,32 @@
import { EditorContextAdapter } from "@/agent/context";
import type { AgentContext, ToolDefinition } from "@/agent/types";
import { toolRegistry } from "@/agent/tools/registry";
import { resolveElementIds } from "@/agent/tools/resolve-element-ids";
import { duplicateElementsSchema } from "@/agent/tools/schemas";
const duplicateElementsTool: ToolDefinition = {
...duplicateElementsSchema,
execute: async (
args: Record<string, unknown>,
_context: AgentContext,
): Promise<
| {
success: boolean;
duplicated: Array<{ elementId: string; trackId: string }>;
}
| { error: string }
> => {
const elementIds = resolveElementIds(args.elementIds);
if (!elementIds) {
return {
error:
'elementIds must be a non-empty JSON array of strings, e.g. ["id1","id2"]',
};
}
return EditorContextAdapter.duplicateElements({ elementIds });
},
};
toolRegistry.register(duplicateElementsSchema.name, duplicateElementsTool);

View File

@ -17,6 +17,7 @@ import "@/agent/tools/get-element.tool";
import "@/agent/tools/split.tool";
import "@/agent/tools/delete-timeline-elements.tool";
import "@/agent/tools/move-timeline-elements.tool";
import "@/agent/tools/duplicate-elements.tool";
import "@/agent/tools/add-media-to-timeline.tool";
import "@/agent/tools/update-timeline-element-timing.tool";
import "@/agent/tools/add-text.tool";
@ -26,3 +27,4 @@ import "@/agent/tools/get-effect.tool";
import "@/agent/tools/apply-effect.tool";
import "@/agent/tools/update-effect.tool";
import "@/agent/tools/update-clip.tool";
import "@/agent/tools/undo.tool";

View File

@ -171,6 +171,20 @@ export const updateEffectSchema: ToolSchema = {
],
};
export const undoSchema: ToolSchema = {
name: "undo",
description:
"Undoes the last editing action performed by any tool. Use this to revert mistakes. Returns the remaining undo stack depth. Consecutive calls undo earlier actions.",
parameters: [],
};
export const duplicateElementsSchema: ToolSchema = {
name: "duplicate_elements",
description:
"Duplicates one or more timeline elements. The copies are placed on new tracks above the originals. Use list_timeline to discover elementIds first.",
parameters: [{ key: "elementIds", type: "string[]", required: true }],
};
export const getElementSchema: ToolSchema = {
name: "get_element",
description:
@ -211,6 +225,7 @@ export const providerToolSchemas: ToolSchema[] = [
splitSchema,
deleteTimelineElementsSchema,
moveTimelineElementsSchema,
duplicateElementsSchema,
addMediaToTimelineSchema,
updateTimelineElementTimingSchema,
addTextSchema,
@ -220,4 +235,5 @@ export const providerToolSchemas: ToolSchema[] = [
applyEffectSchema,
updateEffectSchema,
updateClipSchema,
undoSchema,
];

View File

@ -0,0 +1,16 @@
import type { AgentContext, ToolDefinition } from "@/agent/types";
import { toolRegistry } from "@/agent/tools/registry";
import { undoSchema } from "@/agent/tools/schemas";
import { EditorContextAdapter } from "@/agent/context";
const undoTool: ToolDefinition = {
...undoSchema,
execute: async (
_args: Record<string, unknown>,
_context: AgentContext,
): Promise<{ remainingUndoDepth: number } | { error: string }> => {
return EditorContextAdapter.undo();
},
};
toolRegistry.register(undoSchema.name, undoTool);

View File

@ -58,6 +58,9 @@ export type AgentTimelineTrack = {
assetId?: string;
name?: string;
content?: string;
hasMask?: boolean;
hasEffects?: boolean;
isHidden?: boolean;
/** Timeline start in seconds. */
start: number;
/** Timeline end in seconds. */