diff --git a/apps/web/src/agent/context.ts b/apps/web/src/agent/context.ts index c7bac4d2..72945dea 100644 --- a/apps/web/src/agent/context.ts +++ b/apps/web/src/agent/context.ts @@ -31,6 +31,24 @@ import type { MaskType } from "@/lib/masks/types"; import type { MaskableElement } from "@/lib/timeline"; import { isMaskableElement } from "@/lib/timeline/element-utils"; import type { BlendMode, Transform } from "@/lib/rendering"; +import { UpsertKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/upsert-keyframe"; +import { RemoveKeyframeCommand } from "@/lib/commands/timeline/element/keyframes/remove-keyframe"; +import { UpdateScalarKeyframeCurveCommand } from "@/lib/commands/timeline/element/keyframes/update-scalar-keyframe-curve"; +import { + type AnimationInterpolation, + type ScalarCurveKeyframePatch, + ANIMATION_PROPERTY_PATHS, +} from "@/lib/animation/types"; +import { + getElementKeyframes, + supportsAnimationProperty, + getElementBaseValueForProperty, + getKeyframeAtTime, +} from "@/lib/animation"; +import { + isAnimationPropertyPath, + getAnimationPropertyDefinition, +} from "@/lib/animation/property-registry"; /** * Thin adapter: the ONLY file in agent/ that imports from core/. @@ -795,6 +813,415 @@ export const EditorContextAdapter = { }; }, + listKeyframes({ elementId }: { elementId: string }): + | { + elementId: string; + keyframes: Array<{ + propertyPath: string; + id: string; + time: number; + value: unknown; + interpolation: string; + }>; + } + | { error: string } { + const core = EditorCore.getInstance(); + const activeScene = core.scenes.getActiveSceneOrNull(); + if (!activeScene) { + return { error: "No active timeline" }; + } + + const [resolved] = findTimelineElementsWithTracksByIds({ + tracks: activeScene.tracks, + elementIds: [elementId], + }); + if (!resolved) { + return { error: `Element not found: ${elementId}` }; + } + + const element = resolved.element; + const rawKeyframes = getElementKeyframes({ + animations: element.animations, + }); + + const keyframes = rawKeyframes.map((kf) => ({ + propertyPath: kf.propertyPath, + id: kf.id, + time: ticksToSeconds(kf.time), + value: kf.value, + interpolation: kf.interpolation, + })); + + return { elementId, keyframes }; + }, + + upsertKeyframe({ + elementId, + propertyPath, + time, + value, + interpolation, + keyframeId, + }: { + elementId: string; + propertyPath: string; + time: number; + value?: number; + interpolation?: AnimationInterpolation; + keyframeId?: string; + }): + | { + success: boolean; + elementId: string; + propertyPath: string; + keyframeId: string; + time: number; + value: unknown; + interpolation: string; + } + | { error: string } { + const core = EditorCore.getInstance(); + const activeScene = core.scenes.getActiveSceneOrNull(); + if (!activeScene) { + return { error: "No active timeline" }; + } + + if (!isAnimationPropertyPath(propertyPath)) { + return { + error: `Unknown property path: ${propertyPath}. Use list_animatable_properties to discover valid paths.`, + }; + } + + const [resolved] = findTimelineElementsWithTracksByIds({ + tracks: activeScene.tracks, + elementIds: [elementId], + }); + if (!resolved) { + return { error: `Element not found: ${elementId}` }; + } + + const element = resolved.element; + if (!supportsAnimationProperty({ element, propertyPath })) { + return { + error: `Element type '${element.type}' does not support animating '${propertyPath}'`, + }; + } + + if (value === undefined) { + return { error: "value is required" }; + } + + const timeTicks = secondsToTicks(time); + if (timeTicks < 0 || timeTicks > element.duration) { + return { + error: `Time ${time}s is outside element range (0 to ${ticksToSeconds(element.duration)}s)`, + }; + } + + core.command.execute({ + command: new UpsertKeyframeCommand({ + trackId: resolved.track.id, + elementId: element.id, + propertyPath, + time: timeTicks, + value, + interpolation, + keyframeId, + }), + }); + + const updatedScene = core.scenes.getActiveSceneOrNull(); + const [updated] = updatedScene + ? findTimelineElementsWithTracksByIds({ + tracks: updatedScene.tracks, + elementIds: [elementId], + }) + : []; + const updatedElement = updated?.element ?? element; + + const resultKeyframe = getKeyframeAtTime({ + animations: updatedElement.animations, + propertyPath, + time: timeTicks, + }); + + return { + success: true, + elementId, + propertyPath, + keyframeId: resultKeyframe?.id ?? keyframeId ?? "unknown", + time, + value: resultKeyframe?.value ?? value, + interpolation: resultKeyframe?.interpolation ?? interpolation ?? "linear", + }; + }, + + upsertColorKeyframe({ + elementId, + propertyPath, + time, + colorValue, + interpolation, + keyframeId, + }: { + elementId: string; + propertyPath: string; + time: number; + colorValue: string; + interpolation?: AnimationInterpolation; + keyframeId?: string; + }): + | { + success: boolean; + elementId: string; + propertyPath: string; + keyframeId: string; + time: number; + value: unknown; + interpolation: string; + } + | { error: string } { + const core = EditorCore.getInstance(); + const activeScene = core.scenes.getActiveSceneOrNull(); + if (!activeScene) { + return { error: "No active timeline" }; + } + + if (!isAnimationPropertyPath(propertyPath)) { + return { + error: `Unknown property path: ${propertyPath}. Use list_animatable_properties to discover valid paths.`, + }; + } + + const [resolved] = findTimelineElementsWithTracksByIds({ + tracks: activeScene.tracks, + elementIds: [elementId], + }); + if (!resolved) { + return { error: `Element not found: ${elementId}` }; + } + + const element = resolved.element; + if (!supportsAnimationProperty({ element, propertyPath })) { + return { + error: `Element type '${element.type}' does not support animating '${propertyPath}'`, + }; + } + + const timeTicks = secondsToTicks(time); + if (timeTicks < 0 || timeTicks > element.duration) { + return { + error: `Time ${time}s is outside element range (0 to ${ticksToSeconds(element.duration)}s)`, + }; + } + + core.command.execute({ + command: new UpsertKeyframeCommand({ + trackId: resolved.track.id, + elementId: element.id, + propertyPath, + time: timeTicks, + value: colorValue, + interpolation, + keyframeId, + }), + }); + + const updatedScene = core.scenes.getActiveSceneOrNull(); + const [updated] = updatedScene + ? findTimelineElementsWithTracksByIds({ + tracks: updatedScene.tracks, + elementIds: [elementId], + }) + : []; + const updatedElement = updated?.element ?? element; + + const resultKeyframe = getKeyframeAtTime({ + animations: updatedElement.animations, + propertyPath, + time: timeTicks, + }); + + return { + success: true, + elementId, + propertyPath, + keyframeId: resultKeyframe?.id ?? keyframeId ?? "unknown", + time, + value: resultKeyframe?.value ?? colorValue, + interpolation: resultKeyframe?.interpolation ?? interpolation ?? "linear", + }; + }, + + removeKeyframe({ + elementId, + propertyPath, + keyframeId, + }: { + elementId: string; + propertyPath: string; + keyframeId: string; + }): { success: boolean; removedKeyframeId: string } | { error: string } { + const core = EditorCore.getInstance(); + const activeScene = core.scenes.getActiveSceneOrNull(); + if (!activeScene) { + return { error: "No active timeline" }; + } + + const [resolved] = findTimelineElementsWithTracksByIds({ + tracks: activeScene.tracks, + elementIds: [elementId], + }); + if (!resolved) { + return { error: `Element not found: ${elementId}` }; + } + + core.command.execute({ + command: new RemoveKeyframeCommand({ + trackId: resolved.track.id, + elementId: resolved.element.id, + propertyPath, + keyframeId, + valueAtPlayhead: null, + }), + }); + + return { success: true, removedKeyframeId: keyframeId }; + }, + + updateKeyframeCurve({ + elementId, + propertyPath, + keyframeId, + interpolation, + rightHandle, + leftHandle, + tangentMode, + }: { + elementId: string; + propertyPath: string; + keyframeId: string; + interpolation?: string; + rightHandle?: { dt: number; dv: number }; + leftHandle?: { dt: number; dv: number }; + tangentMode?: string; + }): + | { + success: boolean; + elementId: string; + keyframeId: string; + applied: Record; + } + | { error: string } { + const core = EditorCore.getInstance(); + const activeScene = core.scenes.getActiveSceneOrNull(); + if (!activeScene) { + return { error: "No active timeline" }; + } + + const [resolved] = findTimelineElementsWithTracksByIds({ + tracks: activeScene.tracks, + elementIds: [elementId], + }); + if (!resolved) { + return { error: `Element not found: ${elementId}` }; + } + + const patch: ScalarCurveKeyframePatch = {}; + if (interpolation === "linear") patch.segmentToNext = "linear"; + else if (interpolation === "bezier") patch.segmentToNext = "bezier"; + else if (interpolation === "step") patch.segmentToNext = "step"; + + if (rightHandle !== undefined) { + patch.rightHandle = { dt: rightHandle.dt, dv: rightHandle.dv }; + } else if (interpolation && interpolation !== "bezier") { + patch.rightHandle = null; + } + + if (leftHandle !== undefined) { + patch.leftHandle = { dt: leftHandle.dt, dv: leftHandle.dv }; + } else if (interpolation && interpolation !== "bezier") { + patch.leftHandle = null; + } + + if (tangentMode) { + if (!["auto", "aligned", "broken", "flat"].includes(tangentMode)) { + return { + error: `Invalid tangentMode: ${tangentMode}. Must be auto, aligned, broken, or flat.`, + }; + } + patch.tangentMode = + tangentMode as ScalarCurveKeyframePatch["tangentMode"]; + } + + core.command.execute({ + command: new UpdateScalarKeyframeCurveCommand({ + trackId: resolved.track.id, + elementId: resolved.element.id, + propertyPath, + componentKey: "value", + keyframeId, + patch, + }), + }); + + const applied: Record = {}; + if (interpolation) applied.interpolation = interpolation; + if (rightHandle) applied.rightHandle = rightHandle; + if (leftHandle) applied.leftHandle = leftHandle; + if (tangentMode) applied.tangentMode = tangentMode; + + return { success: true, elementId, keyframeId, applied }; + }, + + listAnimatableProperties({ elementId }: { elementId: string }): + | { + elementId: string; + elementType: string; + properties: Array<{ + path: string; + valueType: string; + currentValue: unknown; + }>; + } + | { error: string } { + const core = EditorCore.getInstance(); + const activeScene = core.scenes.getActiveSceneOrNull(); + if (!activeScene) { + return { error: "No active timeline" }; + } + + const [resolved] = findTimelineElementsWithTracksByIds({ + tracks: activeScene.tracks, + elementIds: [elementId], + }); + if (!resolved) { + return { error: `Element not found: ${elementId}` }; + } + + const element = resolved.element; + const properties = ANIMATION_PROPERTY_PATHS.filter((path) => + supportsAnimationProperty({ element, propertyPath: path }), + ).map((path) => { + const definition = getAnimationPropertyDefinition({ propertyPath: path }); + const currentValue = getElementBaseValueForProperty({ + element, + propertyPath: path, + }); + return { + path, + valueType: definition.kind === "color" ? "color" : "number", + currentValue: currentValue !== null ? currentValue : null, + }; + }); + + return { + elementId, + elementType: element.type, + properties, + }; + }, + getElement({ elementId, }: { @@ -1483,6 +1910,25 @@ function serializeElement( trimEnd: ticksToSeconds(element.trimEnd), }; + const rawKeyframes = getElementKeyframes({ + animations: element.animations, + }); + const keyframes = + rawKeyframes.length > 0 + ? rawKeyframes.map((kf) => ({ + propertyPath: kf.propertyPath, + id: kf.id, + time: ticksToSeconds(kf.time), + value: kf.value, + interpolation: kf.interpolation, + })) + : undefined; + + const animations = + keyframes && keyframes.length > 0 + ? { keyframeCount: keyframes.length, keyframes } + : undefined; + switch (element.type) { case "video": return { @@ -1496,6 +1942,7 @@ function serializeElement( muted: element.muted ?? false, masks: element.masks ?? [], effects: element.effects ?? [], + animations, }; case "image": return { @@ -1507,6 +1954,7 @@ function serializeElement( hidden: element.hidden ?? false, masks: element.masks ?? [], effects: element.effects ?? [], + animations, }; case "text": return { @@ -1526,6 +1974,7 @@ function serializeElement( blendMode: element.blendMode ?? null, hidden: element.hidden ?? false, effects: element.effects ?? [], + animations, }; case "sticker": return { @@ -1536,6 +1985,7 @@ function serializeElement( blendMode: element.blendMode ?? null, hidden: element.hidden ?? false, effects: element.effects ?? [], + animations, }; case "graphic": return { @@ -1548,6 +1998,7 @@ function serializeElement( hidden: element.hidden ?? false, masks: element.masks ?? [], effects: element.effects ?? [], + animations, }; case "audio": return { @@ -1556,6 +2007,7 @@ function serializeElement( sourceType: element.sourceType, volume: element.volume, muted: element.muted ?? false, + animations, }; case "effect": return { diff --git a/apps/web/src/agent/tools/index.ts b/apps/web/src/agent/tools/index.ts index 9f73e068..800c6bfa 100644 --- a/apps/web/src/agent/tools/index.ts +++ b/apps/web/src/agent/tools/index.ts @@ -31,3 +31,8 @@ import "@/agent/tools/undo.tool"; import "@/agent/tools/redo.tool"; import "@/agent/tools/toggle-track-mute.tool"; import "@/agent/tools/toggle-track-visibility.tool"; +import "@/agent/tools/list-keyframes.tool"; +import "@/agent/tools/upsert-keyframe.tool"; +import "@/agent/tools/remove-keyframe.tool"; +import "@/agent/tools/update-keyframe-curve.tool"; +import "@/agent/tools/list-animatable-properties.tool"; diff --git a/apps/web/src/agent/tools/list-animatable-properties.tool.ts b/apps/web/src/agent/tools/list-animatable-properties.tool.ts new file mode 100644 index 00000000..580aa187 --- /dev/null +++ b/apps/web/src/agent/tools/list-animatable-properties.tool.ts @@ -0,0 +1,22 @@ +import type { AgentContext, ToolDefinition } from "@/agent/types"; +import { toolRegistry } from "@/agent/tools/registry"; +import { listAnimatablePropertiesSchema } from "@/agent/tools/schemas"; +import { EditorContextAdapter } from "@/agent/context"; + +const listAnimatablePropertiesTool: ToolDefinition = { + ...listAnimatablePropertiesSchema, + execute: async (args: Record, _context: AgentContext) => { + const elementId = args.elementId; + + if (typeof elementId !== "string" || !elementId.trim()) { + return { error: "Invalid elementId" }; + } + + return EditorContextAdapter.listAnimatableProperties({ elementId }); + }, +}; + +toolRegistry.register( + listAnimatablePropertiesSchema.name, + listAnimatablePropertiesTool, +); diff --git a/apps/web/src/agent/tools/list-keyframes.tool.ts b/apps/web/src/agent/tools/list-keyframes.tool.ts new file mode 100644 index 00000000..c464c0c0 --- /dev/null +++ b/apps/web/src/agent/tools/list-keyframes.tool.ts @@ -0,0 +1,19 @@ +import type { AgentContext, ToolDefinition } from "@/agent/types"; +import { toolRegistry } from "@/agent/tools/registry"; +import { listKeyframesSchema } from "@/agent/tools/schemas"; +import { EditorContextAdapter } from "@/agent/context"; + +const listKeyframesTool: ToolDefinition = { + ...listKeyframesSchema, + execute: async (args: Record, _context: AgentContext) => { + const elementId = args.elementId; + + if (typeof elementId !== "string" || !elementId.trim()) { + return { error: "Invalid elementId" }; + } + + return EditorContextAdapter.listKeyframes({ elementId }); + }, +}; + +toolRegistry.register(listKeyframesSchema.name, listKeyframesTool); diff --git a/apps/web/src/agent/tools/remove-keyframe.tool.ts b/apps/web/src/agent/tools/remove-keyframe.tool.ts new file mode 100644 index 00000000..05ee9603 --- /dev/null +++ b/apps/web/src/agent/tools/remove-keyframe.tool.ts @@ -0,0 +1,33 @@ +import type { AgentContext, ToolDefinition } from "@/agent/types"; +import { toolRegistry } from "@/agent/tools/registry"; +import { removeKeyframeSchema } from "@/agent/tools/schemas"; +import { EditorContextAdapter } from "@/agent/context"; + +const removeKeyframeTool: ToolDefinition = { + ...removeKeyframeSchema, + execute: async (args: Record, _context: AgentContext) => { + const elementId = args.elementId as string; + const propertyPath = args.propertyPath as string; + const keyframeId = args.keyframeId as string; + + if (typeof elementId !== "string" || !elementId.trim()) { + return { error: "Invalid elementId" }; + } + + if (typeof propertyPath !== "string" || !propertyPath.trim()) { + return { error: "Invalid propertyPath" }; + } + + if (typeof keyframeId !== "string" || !keyframeId.trim()) { + return { error: "Invalid keyframeId" }; + } + + return EditorContextAdapter.removeKeyframe({ + elementId, + propertyPath, + keyframeId, + }); + }, +}; + +toolRegistry.register(removeKeyframeSchema.name, removeKeyframeTool); diff --git a/apps/web/src/agent/tools/schemas.ts b/apps/web/src/agent/tools/schemas.ts index 4fb67640..e1a9f826 100644 --- a/apps/web/src/agent/tools/schemas.ts +++ b/apps/web/src/agent/tools/schemas.ts @@ -236,6 +236,61 @@ export const updateClipSchema: ToolSchema = { ], }; +export const listKeyframesSchema: ToolSchema = { + name: "list_keyframes", + description: + "Returns all keyframes for a timeline element, grouped by animated property. Each keyframe includes an id, time (seconds from element start), value, and interpolation type. Use this to inspect existing animations before modifying them. Use list_timeline to discover elementIds.", + parameters: [{ key: "elementId", type: "string", required: true }], +}; + +export const upsertKeyframeSchema: ToolSchema = { + name: "upsert_keyframe", + description: + "Adds or updates a keyframe on a timeline element's animated property. time is seconds from element start. For numeric properties (opacity, position, scale, rotation, volume, padding, cornerRadius), pass value as a number. For color properties (color, background.color), pass colorValue as a hex string like '#ff0000'. interpolation can be linear, hold, or bezier. Use list_animatable_properties to discover valid propertyPaths for an element. Use list_keyframes to get existing keyframeId for updates.", + parameters: [ + { key: "elementId", type: "string", required: true }, + { key: "propertyPath", type: "string", required: true }, + { key: "time", type: "number", required: true }, + { key: "value", type: "number", required: false }, + { key: "colorValue", type: "string", required: false }, + { key: "interpolation", type: "string", required: false }, + { key: "keyframeId", type: "string", required: false }, + ], +}; + +export const removeKeyframeSchema: ToolSchema = { + name: "remove_keyframe", + description: + "Removes a specific keyframe from a timeline element. Use list_keyframes to discover keyframeIds. When the last keyframe on a property is removed, the property reverts to its static value.", + parameters: [ + { key: "elementId", type: "string", required: true }, + { key: "propertyPath", type: "string", required: true }, + { key: "keyframeId", type: "string", required: true }, + ], +}; + +export const updateKeyframeCurveSchema: ToolSchema = { + name: "update_keyframe_curve", + description: + "Updates the curve/interpolation of an existing scalar keyframe. interpolation can be linear, bezier, or step. For bezier curves, optionally pass rightHandle and leftHandle as {dt, dv} offsets from the keyframe point. tangentMode can be auto, aligned, broken, or flat. Use list_keyframes to discover keyframeIds.", + parameters: [ + { key: "elementId", type: "string", required: true }, + { key: "propertyPath", type: "string", required: true }, + { key: "keyframeId", type: "string", required: true }, + { key: "interpolation", type: "string", required: false }, + { key: "rightHandle", type: "object", required: false }, + { key: "leftHandle", type: "object", required: false }, + { key: "tangentMode", type: "string", required: false }, + ], +}; + +export const listAnimatablePropertiesSchema: ToolSchema = { + name: "list_animatable_properties", + description: + "Returns the list of property paths that support animation for a given timeline element. Each property includes its path, value type (number or color), and current static value. Use this before calling upsert_keyframe to discover which properties can be animated.", + parameters: [{ key: "elementId", type: "string", required: true }], +}; + /** * The exact list of schemas exposed to the LLM. * Excludes internal-only tools (transcribe_video, mock). @@ -262,4 +317,9 @@ export const providerToolSchemas: ToolSchema[] = [ redoSchema, toggleTrackMuteSchema, toggleTrackVisibilitySchema, + listKeyframesSchema, + upsertKeyframeSchema, + removeKeyframeSchema, + updateKeyframeCurveSchema, + listAnimatablePropertiesSchema, ]; diff --git a/apps/web/src/agent/tools/update-keyframe-curve.tool.ts b/apps/web/src/agent/tools/update-keyframe-curve.tool.ts new file mode 100644 index 00000000..e5d03af3 --- /dev/null +++ b/apps/web/src/agent/tools/update-keyframe-curve.tool.ts @@ -0,0 +1,78 @@ +import type { AgentContext, ToolDefinition } from "@/agent/types"; +import { toolRegistry } from "@/agent/tools/registry"; +import { updateKeyframeCurveSchema } from "@/agent/tools/schemas"; +import { EditorContextAdapter } from "@/agent/context"; + +const updateKeyframeCurveTool: ToolDefinition = { + ...updateKeyframeCurveSchema, + execute: async (args: Record, _context: AgentContext) => { + const elementId = args.elementId as string; + const propertyPath = args.propertyPath as string; + const keyframeId = args.keyframeId as string; + const interpolation = args.interpolation as string | undefined; + const rightHandle = args.rightHandle as + | { dt: number; dv: number } + | undefined; + const leftHandle = args.leftHandle as + | { dt: number; dv: number } + | undefined; + const tangentMode = args.tangentMode as string | undefined; + + if (typeof elementId !== "string" || !elementId.trim()) { + return { error: "Invalid elementId" }; + } + + if (typeof propertyPath !== "string" || !propertyPath.trim()) { + return { error: "Invalid propertyPath" }; + } + + if (typeof keyframeId !== "string" || !keyframeId.trim()) { + return { error: "Invalid keyframeId" }; + } + + if ( + interpolation !== undefined && + !["linear", "bezier", "step"].includes(interpolation) + ) { + return { + error: "interpolation must be 'linear', 'bezier', or 'step'", + }; + } + + if (rightHandle !== undefined) { + if ( + typeof rightHandle !== "object" || + typeof rightHandle.dt !== "number" || + typeof rightHandle.dv !== "number" + ) { + return { + error: "rightHandle must be an object with numeric dt and dv", + }; + } + } + + if (leftHandle !== undefined) { + if ( + typeof leftHandle !== "object" || + typeof leftHandle.dt !== "number" || + typeof leftHandle.dv !== "number" + ) { + return { + error: "leftHandle must be an object with numeric dt and dv", + }; + } + } + + return EditorContextAdapter.updateKeyframeCurve({ + elementId, + propertyPath, + keyframeId, + interpolation, + rightHandle, + leftHandle, + tangentMode, + }); + }, +}; + +toolRegistry.register(updateKeyframeCurveSchema.name, updateKeyframeCurveTool); diff --git a/apps/web/src/agent/tools/upsert-keyframe.tool.ts b/apps/web/src/agent/tools/upsert-keyframe.tool.ts new file mode 100644 index 00000000..3f7011c1 --- /dev/null +++ b/apps/web/src/agent/tools/upsert-keyframe.tool.ts @@ -0,0 +1,70 @@ +import type { AgentContext, ToolDefinition } from "@/agent/types"; +import { toolRegistry } from "@/agent/tools/registry"; +import { upsertKeyframeSchema } from "@/agent/tools/schemas"; +import { EditorContextAdapter } from "@/agent/context"; +import type { AnimationInterpolation } from "@/lib/animation/types"; + +const COLOR_PROPERTY_PATHS = new Set(["color", "background.color"]); + +const upsertKeyframeTool: ToolDefinition = { + ...upsertKeyframeSchema, + execute: async (args: Record, _context: AgentContext) => { + const elementId = args.elementId as string; + const propertyPath = args.propertyPath as string; + const time = args.time as number; + const value = args.value as number | undefined; + const colorValue = args.colorValue as string | undefined; + const interpolation = args.interpolation as + | AnimationInterpolation + | undefined; + const keyframeId = args.keyframeId as string | undefined; + + if (typeof elementId !== "string" || !elementId.trim()) { + return { error: "Invalid elementId" }; + } + + if (typeof propertyPath !== "string" || !propertyPath.trim()) { + return { error: "Invalid propertyPath" }; + } + + if (typeof time !== "number" || time < 0) { + return { error: "time must be a non-negative number (seconds)" }; + } + + const isColorProperty = COLOR_PROPERTY_PATHS.has(propertyPath); + + if (isColorProperty) { + if (!colorValue || typeof colorValue !== "string") { + return { + error: + "colorValue is required for color properties (hex string like '#ff0000')", + }; + } + return EditorContextAdapter.upsertColorKeyframe({ + elementId, + propertyPath, + time, + colorValue, + interpolation, + keyframeId, + }); + } + + if (value === undefined || typeof value !== "number") { + return { + error: "value is required and must be a number for numeric properties", + }; + } + + return EditorContextAdapter.upsertKeyframe({ + elementId, + propertyPath, + time, + value, + interpolation, + keyframeId, + }); + }, +}; + +toolRegistry.register(upsertKeyframeSchema.name, upsertKeyframeTool); diff --git a/apps/web/src/components/editor/panels/chat/tool-formatters.ts b/apps/web/src/components/editor/panels/chat/tool-formatters.ts index c6c50db5..e6db7be6 100644 --- a/apps/web/src/components/editor/panels/chat/tool-formatters.ts +++ b/apps/web/src/components/editor/panels/chat/tool-formatters.ts @@ -161,6 +161,31 @@ const TOOL_CALL_FORMATTERS: Record< description: id ? `${targetType} ${String(id)}` : targetType, }; }, + list_keyframes: (args) => ({ + label: "List Keyframes", + description: `for ${String(args.elementId ?? "element")}`, + }), + upsert_keyframe: (args) => { + const path = String(args.propertyPath ?? "?"); + const time = formatSeconds(args.time); + const val = args.value ?? args.colorValue ?? "?"; + return { + label: "Add Keyframe", + description: `${path} = ${String(val)} at ${time}`, + }; + }, + remove_keyframe: (args) => ({ + label: "Remove Keyframe", + description: `${String(args.propertyPath ?? "?")} keyframe ${String(args.keyframeId ?? "")}`, + }), + update_keyframe_curve: (args) => ({ + label: "Update Curve", + description: `${String(args.interpolation ?? "curve")} on ${String(args.keyframeId ?? "")}`, + }), + list_animatable_properties: (args) => ({ + label: "Animatable Props", + description: `for ${String(args.elementId ?? "element")}`, + }), list_timeline: () => ({ label: "List Timeline", description: "Fetching timeline tracks", @@ -332,4 +357,60 @@ const TOOL_RESULT_FORMATTERS: Record< description: `${data.assetName ?? "audio"} · ${data.segmentCount ?? 0} segments`, }; }, + list_keyframes: (parsed) => { + const data = parsed as { keyframes?: unknown[] } | null; + if (!data) return null; + const count = data.keyframes?.length ?? 0; + return { + label: "Keyframes", + description: `${count} keyframe${count !== 1 ? "s" : ""}`, + }; + }, + upsert_keyframe: (parsed) => { + const data = parsed as { + success?: boolean; + propertyPath?: string; + time?: number; + } | null; + if (!data) return null; + return { + label: "Keyframe Set", + description: data.success + ? `${data.propertyPath ?? "?"} at ${formatSeconds(data.time)}` + : "Failed", + }; + }, + remove_keyframe: (parsed) => { + const data = parsed as { success?: boolean } | null; + if (!data) return null; + return { + label: "Keyframe Removed", + description: data.success ? "Done" : "Failed", + }; + }, + update_keyframe_curve: (parsed) => { + const data = parsed as { + success?: boolean; + applied?: Record; + } | null; + if (!data) return null; + return { + label: "Curve Updated", + description: data.success + ? Object.keys(data.applied ?? {}).join(", ") || "applied" + : "Failed", + }; + }, + list_animatable_properties: (parsed) => { + const data = parsed as { + elementType?: string; + properties?: unknown[]; + } | null; + if (!data) return null; + const count = data.properties?.length ?? 0; + return { + label: "Animatable", + description: `${data.elementType ?? "element"} · ${count} propert${count !== 1 ? "ies" : "y"}`, + }; + }, };