diff --git a/apps/web/src/agent/context-mapper.ts b/apps/web/src/agent/context-mapper.ts index 09aef1fe..93cfeb0c 100644 --- a/apps/web/src/agent/context-mapper.ts +++ b/apps/web/src/agent/context-mapper.ts @@ -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)[key]; + return Array.isArray(value) && value.length > 0; +} diff --git a/apps/web/src/agent/context.ts b/apps/web/src/agent/context.ts index 745b425f..84506129 100644 --- a/apps/web/src/agent/context.ts +++ b/apps/web/src/agent/context.ts @@ -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, }: { diff --git a/apps/web/src/agent/tools/duplicate-elements.tool.ts b/apps/web/src/agent/tools/duplicate-elements.tool.ts new file mode 100644 index 00000000..e922815e --- /dev/null +++ b/apps/web/src/agent/tools/duplicate-elements.tool.ts @@ -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, + _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); diff --git a/apps/web/src/agent/tools/index.ts b/apps/web/src/agent/tools/index.ts index 94d9c0f6..0ef6b470 100644 --- a/apps/web/src/agent/tools/index.ts +++ b/apps/web/src/agent/tools/index.ts @@ -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"; diff --git a/apps/web/src/agent/tools/schemas.ts b/apps/web/src/agent/tools/schemas.ts index 1fff15fb..12861f11 100644 --- a/apps/web/src/agent/tools/schemas.ts +++ b/apps/web/src/agent/tools/schemas.ts @@ -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, ]; diff --git a/apps/web/src/agent/tools/undo.tool.ts b/apps/web/src/agent/tools/undo.tool.ts new file mode 100644 index 00000000..99e4806b --- /dev/null +++ b/apps/web/src/agent/tools/undo.tool.ts @@ -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, + _context: AgentContext, + ): Promise<{ remainingUndoDepth: number } | { error: string }> => { + return EditorContextAdapter.undo(); + }, +}; + +toolRegistry.register(undoSchema.name, undoTool); diff --git a/apps/web/src/agent/types.ts b/apps/web/src/agent/types.ts index c8fa448f..3c4fe233 100644 --- a/apps/web/src/agent/types.ts +++ b/apps/web/src/agent/types.ts @@ -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. */