From 2e32740698a97311bfd7e6b78b378059692224a7 Mon Sep 17 00:00:00 2001 From: Luis Esteban Acevedo Ladino Date: Sun, 26 Apr 2026 16:19:32 -0500 Subject: [PATCH] feat: add agent effect tools (list, get, apply) --- apps/web/src/agent/context.ts | 128 ++++++++++++++++++ apps/web/src/agent/tools/apply-effect.tool.ts | 51 +++++++ apps/web/src/agent/tools/get-effect.tool.ts | 93 +++++++++++++ apps/web/src/agent/tools/index.ts | 3 + apps/web/src/agent/tools/list-effects.tool.ts | 42 ++++++ apps/web/src/agent/tools/schemas.ts | 29 ++++ docs/agent-tool-specs.md | 111 +++++++++++++-- 7 files changed, 446 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/agent/tools/apply-effect.tool.ts create mode 100644 apps/web/src/agent/tools/get-effect.tool.ts create mode 100644 apps/web/src/agent/tools/list-effects.tool.ts diff --git a/apps/web/src/agent/context.ts b/apps/web/src/agent/context.ts index 3ca8816e..0033f565 100644 --- a/apps/web/src/agent/context.ts +++ b/apps/web/src/agent/context.ts @@ -16,12 +16,18 @@ import type { TimelineElement, TimelineTrack, TrackType, + VisualElement, } from "@/lib/timeline"; import type { TextStyleOverrides } from "@/agent/tools/add-text.tool"; import type { UpdateTextArgs } from "@/agent/tools/update-text.tool"; import { canPlaceTimeSpansOnTrack } from "@/lib/timeline/placement/overlap"; import { validateElementTrackCompatibility } from "@/lib/timeline/placement"; import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update"; +import { isVisualElement } from "@/lib/timeline"; +import { AddClipEffectCommand } from "@/lib/commands/timeline/element/effects/add-effect"; +import { UpdateClipEffectParamsCommand } from "@/lib/commands/timeline/element/effects/update-effect-params"; +import { effectsRegistry } from "@/lib/effects"; +import type { ParamValues } from "@/lib/params"; /** * Thin adapter: the ONLY file in agent/ that imports from core/. @@ -545,6 +551,85 @@ export const EditorContextAdapter = { skipped, }; }, + + addEffect({ + trackId, + elementId, + effectType, + params, + }: { + trackId: string; + elementId: string; + effectType: string; + params?: Record; + }): + | { effectId: string; elementId: string; appliedParams: ParamValues } + | { error: string } { + const core = EditorCore.getInstance(); + const activeScene = core.scenes.getActiveSceneOrNull(); + if (!activeScene) { + return { error: "No active timeline" }; + } + + if (!effectsRegistry.has(effectType)) { + return { error: `Effect not found: ${effectType}` }; + } + + const [resolved] = findTimelineElementsWithTracksByIds({ + tracks: activeScene.tracks, + elementIds: [elementId], + }); + if (!resolved) { + return { error: `Timeline element not found: ${elementId}` }; + } + if (resolved.track.id !== trackId) { + return { error: `Track not found: ${trackId}` }; + } + if (!isVisualElement(resolved.element)) { + return { error: "Element does not support effects" }; + } + + const definition = effectsRegistry.get(effectType); + + if (params) { + const validationError = validateEffectParams(definition.params, params); + if (validationError) { + return { error: validationError }; + } + } + + const addCommand = new AddClipEffectCommand({ + trackId, + elementId, + effectType, + }); + core.command.execute({ command: addCommand }); + + const effectId = addCommand.getEffectId(); + if (!effectId) { + return { error: "Failed to apply effect" }; + } + + let appliedParams: ParamValues = {}; + const effect = (resolved.element as VisualElement).effects?.find( + (e) => e.id === effectId, + ); + appliedParams = effect?.params ?? {}; + + if (params && Object.keys(params).length > 0) { + const updateCommand = new UpdateClipEffectParamsCommand({ + trackId, + elementId, + effectId, + params, + }); + core.command.execute({ command: updateCommand }); + + appliedParams = { ...appliedParams, ...params }; + } + + return { effectId, elementId, appliedParams }; + }, }; function secondsToTicks(seconds: number): number { @@ -894,4 +979,47 @@ function buildTextPatch( return patch as Partial; } +function validateEffectParams( + paramDefs: import("@/lib/params").ParamDefinition[], + params: Record, +): string | null { + for (const [key, value] of Object.entries(params)) { + const def = paramDefs.find((p) => p.key === key); + if (!def) { + return `Unknown parameter: ${key}`; + } + + if (def.type === "number") { + if (typeof value !== "number") { + return `Parameter '${key}' must be a number`; + } + if (def.min !== undefined && value < def.min) { + return `Parameter '${key}' must be >= ${def.min}`; + } + if (def.max !== undefined && value > def.max) { + return `Parameter '${key}' must be <= ${def.max}`; + } + } + + if (def.type === "boolean" && typeof value !== "boolean") { + return `Parameter '${key}' must be a boolean`; + } + + if (def.type === "color" && typeof value !== "string") { + return `Parameter '${key}' must be a string (hex color)`; + } + + if (def.type === "select") { + if (typeof value !== "string") { + return `Parameter '${key}' must be a string`; + } + const validValues = def.options.map((o) => o.value); + if (!validValues.includes(value)) { + return `Parameter '${key}' must be one of: ${validValues.join(", ")}`; + } + } + } + return null; +} + export { buildSystemPrompt } from "@/agent/system-prompt"; diff --git a/apps/web/src/agent/tools/apply-effect.tool.ts b/apps/web/src/agent/tools/apply-effect.tool.ts new file mode 100644 index 00000000..76a3e184 --- /dev/null +++ b/apps/web/src/agent/tools/apply-effect.tool.ts @@ -0,0 +1,51 @@ +import type { AgentContext, ToolDefinition } from "@/agent/types"; +import { toolRegistry } from "@/agent/tools/registry"; +import { applyEffectSchema } from "@/agent/tools/schemas"; +import { EditorContextAdapter } from "@/agent/context"; + +const applyEffectTool: ToolDefinition = { + ...applyEffectSchema, + execute: async ( + args: Record, + _context: AgentContext, + ): Promise< + | { + effectId: string; + elementId: string; + appliedParams: Record; + } + | { error: string } + > => { + const trackId = args.trackId; + const elementId = args.elementId; + const effectType = args.effectType; + const params = args.params as + | Record + | undefined; + + if (typeof trackId !== "string" || !trackId.trim()) { + return { error: "Invalid track id" }; + } + + if (typeof elementId !== "string" || !elementId.trim()) { + return { error: "Invalid element id" }; + } + + if (typeof effectType !== "string" || !effectType.trim()) { + return { error: "Invalid effect type" }; + } + + if (params !== undefined && typeof params !== "object") { + return { error: "Invalid effect parameters" }; + } + + return EditorContextAdapter.addEffect({ + trackId, + elementId, + effectType, + params, + }); + }, +}; + +toolRegistry.register(applyEffectSchema.name, applyEffectTool); diff --git a/apps/web/src/agent/tools/get-effect.tool.ts b/apps/web/src/agent/tools/get-effect.tool.ts new file mode 100644 index 00000000..916d5c69 --- /dev/null +++ b/apps/web/src/agent/tools/get-effect.tool.ts @@ -0,0 +1,93 @@ +import type { AgentContext, ToolDefinition } from "@/agent/types"; +import { toolRegistry } from "@/agent/tools/registry"; +import { getEffectSchema } from "@/agent/tools/schemas"; +import { effectsRegistry } from "@/lib/effects"; +import type { ParamDefinition } from "@/lib/params"; + +type GetEffectResult = { + id: string; + name: string; + description: string; + params: Array<{ + key: string; + label: string; + type: "number" | "boolean" | "color" | "select"; + default: number | string | boolean; + min?: number; + max?: number; + step?: number; + options?: Array<{ value: string; label: string }>; + description: string; + }>; +}; + +function buildParamDescription(param: ParamDefinition): string { + if (param.type === "number") { + const parts: string[] = [`Number between ${param.min ?? "-∞"}`]; + if (param.max !== undefined) parts.push(`and ${param.max}`); + if (param.step !== undefined) parts.push(`(step ${param.step})`); + parts.push(`Default: ${param.default}`); + return parts.join(" "); + } + if (param.type === "boolean") { + return `Boolean. Default: ${param.default}`; + } + if (param.type === "color") { + return `Color (hex string). Default: ${param.default}`; + } + if (param.type === "select") { + const options = param.options.map((o) => `${o.value} (${o.label})`).join(", "); + return `Select one of: ${options}. Default: ${param.default}`; + } + return ""; +} + +const getEffectTool: ToolDefinition = { + ...getEffectSchema, + execute: async ( + args: Record, + _context: AgentContext, + ): Promise => { + const effectType = args.effectType; + if (typeof effectType !== "string" || !effectType.trim()) { + return { error: "Invalid effect type" }; + } + + if (!effectsRegistry.has(effectType)) { + return { error: `Effect not found: ${effectType}` }; + } + + const definition = effectsRegistry.get(effectType); + + const params = definition.params.map((param) => { + const base: GetEffectResult["params"][number] = { + key: param.key, + label: param.label, + type: param.type, + default: param.default, + description: buildParamDescription(param), + }; + + if (param.type === "number") { + if (param.min !== undefined) base.min = param.min; + if (param.max !== undefined) base.max = param.max; + if (param.step !== undefined) base.step = param.step; + } + + if (param.type === "select") { + base.options = param.options; + } + + return base; + }); + + return { + id: definition.type, + name: definition.name, + description: definition.keywords.join(", "), + params, + }; + }, +}; + +toolRegistry.register(getEffectSchema.name, getEffectTool); diff --git a/apps/web/src/agent/tools/index.ts b/apps/web/src/agent/tools/index.ts index 8348956d..9c73cce2 100644 --- a/apps/web/src/agent/tools/index.ts +++ b/apps/web/src/agent/tools/index.ts @@ -20,3 +20,6 @@ import "@/agent/tools/add-media-to-timeline.tool"; import "@/agent/tools/update-timeline-element-timing.tool"; import "@/agent/tools/add-text.tool"; import "@/agent/tools/update-text.tool"; +import "@/agent/tools/list-effects.tool"; +import "@/agent/tools/get-effect.tool"; +import "@/agent/tools/apply-effect.tool"; diff --git a/apps/web/src/agent/tools/list-effects.tool.ts b/apps/web/src/agent/tools/list-effects.tool.ts new file mode 100644 index 00000000..c18234b8 --- /dev/null +++ b/apps/web/src/agent/tools/list-effects.tool.ts @@ -0,0 +1,42 @@ +import type { AgentContext, ToolDefinition } from "@/agent/types"; +import { toolRegistry } from "@/agent/tools/registry"; +import { listEffectsSchema } from "@/agent/tools/schemas"; +import { effectsRegistry } from "@/lib/effects"; + +type ListEffectsResult = { + effects: Array<{ + id: string; + name: string; + description: string; + }>; +}; + +const listEffectsTool: ToolDefinition = { + ...listEffectsSchema, + execute: async ( + args: Record, + _context: AgentContext, + ): Promise => { + const allEffects = effectsRegistry.getAll(); + const query = typeof args.query === "string" ? args.query.trim().toLowerCase() : ""; + + const effects = allEffects + .filter((effect) => { + if (!query) return true; + const nameMatch = effect.name.toLowerCase().includes(query); + const keywordMatch = effect.keywords.some((kw) => + kw.toLowerCase().includes(query), + ); + return nameMatch || keywordMatch; + }) + .map((effect) => ({ + id: effect.type, + name: effect.name, + description: effect.keywords.join(", "), + })); + + return { effects }; + }, +}; + +toolRegistry.register(listEffectsSchema.name, listEffectsTool); diff --git a/apps/web/src/agent/tools/schemas.ts b/apps/web/src/agent/tools/schemas.ts index af76ed90..6159154c 100644 --- a/apps/web/src/agent/tools/schemas.ts +++ b/apps/web/src/agent/tools/schemas.ts @@ -135,6 +135,32 @@ export const updateTextSchema: ToolSchema = { ], }; +export const listEffectsSchema: ToolSchema = { + name: "list_effects", + description: + "Lists all available effects that can be applied to visual timeline elements. Returns each effect's id, name, and description of what it does. Use this to discover effects before calling get_effect for parameter details.", + parameters: [{ key: "query", type: "string", required: false }], +}; + +export const getEffectSchema: ToolSchema = { + name: "get_effect", + description: + "Returns detailed metadata for a specific effect, including all configurable parameters with their types, ranges, defaults, and descriptions. Use this after list_effects to understand how to configure an effect before calling apply_effect.", + parameters: [{ key: "effectType", type: "string", required: true }], +}; + +export const applyEffectSchema: ToolSchema = { + name: "apply_effect", + description: + "Applies an effect to a visual timeline element (video, image, text, sticker, or graphic). Use list_effects to discover available effects, get_effect to learn their parameters, then apply_effect with the desired params. Default parameter values are used when params are omitted.", + parameters: [ + { key: "trackId", type: "string", required: true }, + { key: "elementId", type: "string", required: true }, + { key: "effectType", type: "string", required: true }, + { key: "params", type: "object", required: false }, + ], +}; + /** * The exact list of schemas exposed to the LLM. * Excludes internal-only tools (transcribe_video, mock). @@ -150,4 +176,7 @@ export const providerToolSchemas: ToolSchema[] = [ updateTimelineElementTimingSchema, addTextSchema, updateTextSchema, + listEffectsSchema, + getEffectSchema, + applyEffectSchema, ]; diff --git a/docs/agent-tool-specs.md b/docs/agent-tool-specs.md index 8a67a16f..0b102c49 100644 --- a/docs/agent-tool-specs.md +++ b/docs/agent-tool-specs.md @@ -527,20 +527,97 @@ Agregar un sticker existente al timeline. --- -## 14. `apply_effect` +## 14. `list_effects` ### Propósito -Aplicar un efecto existente a un clip. En el estado actual del repo, el efecto real disponible parece ser `blur`. +Listar todos los efectos disponibles para que el agente descubra qué efectos puede aplicar a elementos visuales del timeline. Esta es la primera tool en el flujo de efectos: `list_effects` → `get_effect` → `apply_effect`. + +### Input +```ts +{ + query?: string; +} +``` + +### Output +```ts +{ + effects: Array<{ + id: string; + name: string; + description: string; + }>; +} +``` + +### Requirements +- MUST return all registered effects by default. +- MUST support optional `query` filtering by name or keywords (case-insensitive). +- MUST NOT mutate editor state. +- MUST use the effects registry directly (static data, not project-dependent). + +### Errors +- None (always returns a list, possibly empty). + +--- + +## 15. `get_effect` + +### Propósito +Obtener metadata detallada de un efecto específico, incluyendo todos los parámetros configurables con sus tipos, rangos, valores default y descripciones. El agente usa esto después de `list_effects` para saber cómo configurar un efecto antes de llamar `apply_effect`. + +### Input +```ts +{ + effectType: string; +} +``` + +### Output +```ts +{ + id: string; + name: string; + description: string; + params: Array<{ + key: string; + label: string; + type: "number" | "boolean" | "color" | "select"; + default: number | string | boolean; + min?: number; + max?: number; + step?: number; + options?: Array<{ value: string; label: string }>; + description: string; + }>; +} +``` + +### Requirements +- MUST resolve the effect by `effectType` from the effects registry. +- MUST include full parameter metadata for each effect parameter. +- MUST generate a human-readable `description` for each parameter (e.g. "Number between 0 and 100, step 1. Default: 15"). +- MUST NOT mutate editor state. +- MUST NOT include renderer/shader details (internal only). + +### Errors +- Invalid type: `{ error: "Invalid effect type" }`. +- Effect not found: `{ error: "Effect not found: " }`. + +--- + +## 16. `apply_effect` + +### Propósito +Aplicar un efecto existente a un elemento visual del timeline (video, imagen, texto, sticker o gráfico). Soporta los 7 efectos registrados: blur, brightness-contrast, grayscale, saturation, sepia, invert, vignette. ### Input ```ts { trackId: string; elementId: string; - effectType: "blur"; - params?: { - intensity?: number; - }; + effectType: string; + params?: Record; } ``` @@ -549,20 +626,32 @@ Aplicar un efecto existente a un clip. En el estado actual del repo, el efecto r { effectId: string; elementId: string; + appliedParams: Record; } ``` ### Requirements -- MUST validate the element is visual and supports effects. - MUST validate `effectType` exists in the effects registry. +- MUST validate `trackId` + `elementId` exist in the active timeline. +- MUST validate the target element is a visual element (video, image, text, sticker, graphic). +- MUST validate `params` against the effect's parameter definitions (types, ranges). - MUST apply default params when `params` are omitted. -- MUST validate `intensity` if provided. -- MUST preserve undo/redo behavior if supported. +- MUST use `AddClipEffectCommand` for the initial effect creation. +- MUST use `UpdateClipEffectParamsCommand` when custom params are provided. +- MUST preserve undo/redo behavior (both commands go on the undo stack). +- MUST return the final applied parameter values. ### Errors -- Effect not found: `{ error: "Effect not found" }`. -- Unsupported element: `{ error: "Element does not support effects" }`. +- Invalid track id: `{ error: "Invalid track id" }`. +- Invalid element id: `{ error: "Invalid element id" }`. +- Invalid effect type: `{ error: "Invalid effect type" }`. - Invalid params: `{ error: "Invalid effect parameters" }`. +- Effect not found: `{ error: "Effect not found: " }`. +- Missing element: `{ error: "Timeline element not found: " }`. +- Missing track: `{ error: "Track not found: " }`. +- Unsupported element: `{ error: "Element does not support effects" }`. +- Unknown parameter: `{ error: "Unknown parameter: " }`. +- Out of range: `{ error: "Parameter '' must be >= " }`. ---