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 1/6] 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 >= " }`. --- From 277321628c808d9831e428713bcda872bf60e446 Mon Sep 17 00:00:00 2001 From: Luis Esteban Acevedo Ladino Date: Sun, 26 Apr 2026 16:47:01 -0500 Subject: [PATCH 2/6] fix: apply_effect creates standalone effect elements on effect tracks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of adding inline effects to existing clips (which didn't show in the timeline), apply_effect now creates EffectElement items on effect tracks via InsertElementCommand — matching the drag-and-drop behavior from the effects panel. --- apps/web/src/agent/context.ts | 87 ++++++++----------- apps/web/src/agent/tools/apply-effect.tool.ts | 28 +++--- apps/web/src/agent/tools/schemas.ts | 6 +- docs/agent-tool-specs.md | 29 +++---- 4 files changed, 68 insertions(+), 82 deletions(-) diff --git a/apps/web/src/agent/context.ts b/apps/web/src/agent/context.ts index 0033f565..5b6b9557 100644 --- a/apps/web/src/agent/context.ts +++ b/apps/web/src/agent/context.ts @@ -6,6 +6,7 @@ import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline"; import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation"; import { buildElementFromMedia, + buildEffectElement, buildTextElement, } from "@/lib/timeline/element-utils"; import { DEFAULTS } from "@/lib/timeline/defaults"; @@ -16,18 +17,13 @@ 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/. @@ -552,18 +548,22 @@ export const EditorContextAdapter = { }; }, - addEffect({ - trackId, - elementId, + addEffectElement({ effectType, + start, + end, params, }: { - trackId: string; - elementId: string; effectType: string; + start: number; + end: number; params?: Record; }): - | { effectId: string; elementId: string; appliedParams: ParamValues } + | { + elementId: string; + trackId: string; + appliedParams: Record; + } | { error: string } { const core = EditorCore.getInstance(); const activeScene = core.scenes.getActiveSceneOrNull(); @@ -575,20 +575,6 @@ export const EditorContextAdapter = { 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) { @@ -598,37 +584,38 @@ export const EditorContextAdapter = { } } - const addCommand = new AddClipEffectCommand({ - trackId, - elementId, - effectType, - }); - core.command.execute({ command: addCommand }); - - const effectId = addCommand.getEffectId(); - if (!effectId) { - return { error: "Failed to apply effect" }; + const startTimeTicks = secondsToTicks(start); + const durationTicks = secondsToTicks(end) - startTimeTicks; + if (durationTicks <= 0) { + return { error: "Invalid time range" }; } - let appliedParams: ParamValues = {}; - const effect = (resolved.element as VisualElement).effects?.find( - (e) => e.id === effectId, - ); - appliedParams = effect?.params ?? {}; + const element = buildEffectElement({ + effectType, + startTime: startTimeTicks, + duration: durationTicks, + }); if (params && Object.keys(params).length > 0) { - const updateCommand = new UpdateClipEffectParamsCommand({ - trackId, - elementId, - effectId, - params, - }); - core.command.execute({ command: updateCommand }); - - appliedParams = { ...appliedParams, ...params }; + element.params = { ...element.params, ...params }; } - return { effectId, elementId, appliedParams }; + const insertCommand = new InsertElementCommand({ + element, + placement: { mode: "auto", trackType: "effect" }, + }); + core.command.execute({ command: insertCommand }); + + const trackId = insertCommand.getTrackId(); + if (!trackId) { + return { error: "Failed to place effect element" }; + } + + return { + elementId: insertCommand.getElementId(), + trackId, + appliedParams: element.params, + }; }, }; diff --git a/apps/web/src/agent/tools/apply-effect.tool.ts b/apps/web/src/agent/tools/apply-effect.tool.ts index 76a3e184..dddfeca1 100644 --- a/apps/web/src/agent/tools/apply-effect.tool.ts +++ b/apps/web/src/agent/tools/apply-effect.tool.ts @@ -10,39 +10,39 @@ const applyEffectTool: ToolDefinition = { _context: AgentContext, ): Promise< | { - effectId: string; elementId: string; + trackId: string; appliedParams: Record; } | { error: string } > => { - const trackId = args.trackId; - const elementId = args.elementId; const effectType = args.effectType; + const start = args.start; + const end = args.end; 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 (typeof start !== "number" || !Number.isFinite(start) || start < 0) { + return { error: "Invalid start time" }; + } + + if (typeof end !== "number" || !Number.isFinite(end) || end <= start) { + return { error: "Invalid end time" }; + } + if (params !== undefined && typeof params !== "object") { return { error: "Invalid effect parameters" }; } - return EditorContextAdapter.addEffect({ - trackId, - elementId, + return EditorContextAdapter.addEffectElement({ effectType, + start, + end, params, }); }, diff --git a/apps/web/src/agent/tools/schemas.ts b/apps/web/src/agent/tools/schemas.ts index 6159154c..24e788a1 100644 --- a/apps/web/src/agent/tools/schemas.ts +++ b/apps/web/src/agent/tools/schemas.ts @@ -152,11 +152,11 @@ export const getEffectSchema: ToolSchema = { 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.", + "Adds an effect element to the timeline on an effect track, like dragging an effect from the effects panel. The effect covers the time range from start to end (in seconds). 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: "start", type: "number", required: true }, + { key: "end", type: "number", required: true }, { key: "params", type: "object", required: false }, ], }; diff --git a/docs/agent-tool-specs.md b/docs/agent-tool-specs.md index 0b102c49..c29a75b6 100644 --- a/docs/agent-tool-specs.md +++ b/docs/agent-tool-specs.md @@ -609,14 +609,14 @@ Obtener metadata detallada de un efecto específico, incluyendo todos los parám ## 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. +Agregar un efecto como elemento standalone al timeline en una pista de efectos, equivalente a drag & dropear un efecto desde el panel de efectos. Soporta los 7 efectos registrados: blur, brightness-contrast, grayscale, saturation, sepia, invert, vignette. ### Input ```ts { - trackId: string; - elementId: string; effectType: string; + start: number; + end: number; params?: Record; } ``` @@ -624,32 +624,31 @@ Aplicar un efecto existente a un elemento visual del timeline (video, imagen, te ### Output ```ts { - effectId: string; elementId: string; + trackId: string; appliedParams: Record; } ``` ### Requirements - 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 `start` and `end` as valid timeline seconds with `start < end`. - MUST validate `params` against the effect's parameter definitions (types, ranges). -- MUST apply default params when `params` are omitted. -- 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 create an `EffectElement` via `buildEffectElement()` with the requested time range. +- MUST merge custom `params` into the default effect instance before insertion. +- MUST use `InsertElementCommand` with `{ mode: "auto", trackType: "effect" }` to place on an effect track (creating one if needed). +- MUST preserve undo/redo behavior. - MUST return the final applied parameter values. ### Errors -- Invalid track id: `{ error: "Invalid track id" }`. -- Invalid element id: `{ error: "Invalid element id" }`. - Invalid effect type: `{ error: "Invalid effect type" }`. +- Invalid start time: `{ error: "Invalid start time" }`. +- Invalid end time: `{ error: "Invalid end time" }`. - 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" }`. +- Invalid time range: `{ error: "Invalid time range" }`. +- No active timeline: `{ error: "No active timeline" }`. +- Failed placement: `{ error: "Failed to place effect element" }`. - Unknown parameter: `{ error: "Unknown parameter: " }`. - Out of range: `{ error: "Parameter '' must be >= " }`. From 9775f6f30cdf2e5944f4d295faafee8ab2830158 Mon Sep 17 00:00:00 2001 From: Luis Esteban Acevedo Ladino Date: Sun, 26 Apr 2026 17:03:55 -0500 Subject: [PATCH 3/6] feat: add update_effect tool for modifying existing effect params --- apps/web/src/agent/context.ts | 60 +++++++++++++++++++ apps/web/src/agent/tools/index.ts | 1 + apps/web/src/agent/tools/schemas.ts | 11 ++++ .../web/src/agent/tools/update-effect.tool.ts | 39 ++++++++++++ docs/agent-tool-specs.md | 43 +++++++++++++ 5 files changed, 154 insertions(+) create mode 100644 apps/web/src/agent/tools/update-effect.tool.ts diff --git a/apps/web/src/agent/context.ts b/apps/web/src/agent/context.ts index 5b6b9557..2cbf8a91 100644 --- a/apps/web/src/agent/context.ts +++ b/apps/web/src/agent/context.ts @@ -617,6 +617,66 @@ export const EditorContextAdapter = { appliedParams: element.params, }; }, + + updateEffectElement({ + elementId, + params, + }: { + elementId: string; + params: Record; + }): + | { + success: boolean; + elementId: string; + appliedParams: 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: `Timeline element not found: ${elementId}` }; + } + if (resolved.element.type !== "effect") { + return { error: "Element is not an effect" }; + } + + const effectElement = resolved.element as import("@/lib/timeline/types").EffectElement; + const definition = effectsRegistry.get(effectElement.effectType); + if (!definition) { + return { error: `Effect not found: ${effectElement.effectType}` }; + } + + const validationError = validateEffectParams(definition.params, params); + if (validationError) { + return { error: validationError }; + } + + const mergedParams = { ...effectElement.params, ...params }; + + core.timeline.updateElements({ + updates: [ + { + trackId: resolved.track.id, + elementId, + patch: { params: mergedParams } as Partial, + }, + ], + }); + + return { + success: true, + elementId, + appliedParams: mergedParams, + }; + }, }; function secondsToTicks(seconds: number): number { diff --git a/apps/web/src/agent/tools/index.ts b/apps/web/src/agent/tools/index.ts index 9c73cce2..c21904fe 100644 --- a/apps/web/src/agent/tools/index.ts +++ b/apps/web/src/agent/tools/index.ts @@ -23,3 +23,4 @@ import "@/agent/tools/update-text.tool"; import "@/agent/tools/list-effects.tool"; import "@/agent/tools/get-effect.tool"; import "@/agent/tools/apply-effect.tool"; +import "@/agent/tools/update-effect.tool"; diff --git a/apps/web/src/agent/tools/schemas.ts b/apps/web/src/agent/tools/schemas.ts index 24e788a1..cb0bc7c1 100644 --- a/apps/web/src/agent/tools/schemas.ts +++ b/apps/web/src/agent/tools/schemas.ts @@ -161,6 +161,16 @@ export const applyEffectSchema: ToolSchema = { ], }; +export const updateEffectSchema: ToolSchema = { + name: "update_effect", + description: + "Updates parameters of an existing effect element on the timeline. Use list_timeline to find the elementId of the effect, then pass the params you want to change. Only the provided parameters are updated; others keep their current values. Use get_effect to discover valid parameter keys and ranges.", + parameters: [ + { key: "elementId", type: "string", required: true }, + { key: "params", type: "object", required: true }, + ], +}; + /** * The exact list of schemas exposed to the LLM. * Excludes internal-only tools (transcribe_video, mock). @@ -179,4 +189,5 @@ export const providerToolSchemas: ToolSchema[] = [ listEffectsSchema, getEffectSchema, applyEffectSchema, + updateEffectSchema, ]; diff --git a/apps/web/src/agent/tools/update-effect.tool.ts b/apps/web/src/agent/tools/update-effect.tool.ts new file mode 100644 index 00000000..f81d8990 --- /dev/null +++ b/apps/web/src/agent/tools/update-effect.tool.ts @@ -0,0 +1,39 @@ +import type { AgentContext, ToolDefinition } from "@/agent/types"; +import { toolRegistry } from "@/agent/tools/registry"; +import { updateEffectSchema } from "@/agent/tools/schemas"; +import { EditorContextAdapter } from "@/agent/context"; + +const updateEffectTool: ToolDefinition = { + ...updateEffectSchema, + execute: async ( + args: Record, + _context: AgentContext, + ): Promise< + | { + success: boolean; + elementId: string; + appliedParams: Record; + } + | { error: string } + > => { + const elementId = args.elementId; + const params = args.params as + | Record + | undefined; + + if (typeof elementId !== "string" || !elementId.trim()) { + return { error: "Invalid element id" }; + } + + if (!params || typeof params !== "object" || Object.keys(params).length === 0) { + return { error: "params is required and must be a non-empty object" }; + } + + return EditorContextAdapter.updateEffectElement({ + elementId, + params, + }); + }, +}; + +toolRegistry.register(updateEffectSchema.name, updateEffectTool); diff --git a/docs/agent-tool-specs.md b/docs/agent-tool-specs.md index c29a75b6..6b07c2be 100644 --- a/docs/agent-tool-specs.md +++ b/docs/agent-tool-specs.md @@ -654,6 +654,49 @@ Agregar un efecto como elemento standalone al timeline en una pista de efectos, --- +## 17. `update_effect` + +### Propósito +Actualizar los parámetros de un elemento de efecto existente en el timeline. Solo se actualizan los parámetros proporcionados; el resto mantiene sus valores actuales. + +### Input +```ts +{ + elementId: string; + params: Record; +} +``` + +### Output +```ts +{ + success: boolean; + elementId: string; + appliedParams: Record; +} +``` + +### Requirements +- MUST validate `elementId` exists in the active timeline. +- MUST validate the target element is an effect element (`type: "effect"`). +- MUST validate `params` against the effect's parameter definitions (types, ranges). +- MUST merge provided `params` with existing params (only override specified keys). +- MUST use `updateElements` to apply the param patch. +- MUST preserve undo/redo behavior. +- MUST return the full merged params after update. + +### Errors +- Invalid element id: `{ error: "Invalid element id" }`. +- Missing params: `{ error: "params is required and must be a non-empty object" }`. +- No active timeline: `{ error: "No active timeline" }`. +- Missing element: `{ error: "Timeline element not found: " }`. +- Wrong type: `{ error: "Element is not an effect" }`. +- Effect not found: `{ error: "Effect not found: " }`. +- Unknown parameter: `{ error: "Unknown parameter: " }`. +- Out of range: `{ error: "Parameter '' must be >= " }`. + +--- + ## Existing/secondary tool: `transcribe_video` ### Propósito From b48062a5f73524bd708c3268bf46ebd237f2c800 Mon Sep 17 00:00:00 2001 From: Luis Esteban Acevedo Ladino Date: Sun, 26 Apr 2026 18:11:21 -0500 Subject: [PATCH 4/6] feat: add get_element and update_clip agent tools Add two new agent tools for deep element inspection and property mutation: - get_element: returns full type-specific metadata for any timeline element (video, image, text, graphic, sticker, audio, effect) - update_clip: mutates any visual property on an element including masks, opacity, transform, blendMode, hidden, volume, muted, and name Masks are handled as a nested property with add/update/remove actions, aligned with the data model where masks are properties of elements rather than standalone timeline entities. --- apps/web/src/agent/context.ts | 365 ++++++++++++++++++- apps/web/src/agent/tools/get-element.tool.ts | 22 ++ apps/web/src/agent/tools/index.ts | 2 + apps/web/src/agent/tools/schemas.ts | 30 ++ apps/web/src/agent/tools/update-clip.tool.ts | 88 +++++ 5 files changed, 505 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/agent/tools/get-element.tool.ts create mode 100644 apps/web/src/agent/tools/update-clip.tool.ts diff --git a/apps/web/src/agent/context.ts b/apps/web/src/agent/context.ts index 2cbf8a91..745b425f 100644 --- a/apps/web/src/agent/context.ts +++ b/apps/web/src/agent/context.ts @@ -24,6 +24,11 @@ import { canPlaceTimeSpansOnTrack } from "@/lib/timeline/placement/overlap"; import { validateElementTrackCompatibility } from "@/lib/timeline/placement"; import { findTrackInSceneTracks } from "@/lib/timeline/track-element-update"; import { effectsRegistry } from "@/lib/effects"; +import { masksRegistry, buildDefaultMaskInstance } from "@/lib/masks"; +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"; /** * Thin adapter: the ONLY file in agent/ that imports from core/. @@ -648,7 +653,8 @@ export const EditorContextAdapter = { return { error: "Element is not an effect" }; } - const effectElement = resolved.element as import("@/lib/timeline/types").EffectElement; + const effectElement = + resolved.element as import("@/lib/timeline/types").EffectElement; const definition = effectsRegistry.get(effectElement.effectType); if (!definition) { return { error: `Effect not found: ${effectElement.effectType}` }; @@ -666,7 +672,9 @@ export const EditorContextAdapter = { { trackId: resolved.track.id, elementId, - patch: { params: mergedParams } as Partial, + patch: { params: mergedParams } as Partial< + import("@/lib/timeline/types").TimelineElement + >, }, ], }); @@ -677,12 +685,264 @@ export const EditorContextAdapter = { appliedParams: mergedParams, }; }, + + getElement({ + elementId, + }: { + elementId: string; + }): 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}` }; + } + + return serializeElement(resolved.element, resolved.track.id); + }, + + updateClip({ + elementId, + name, + mask, + opacity, + positionX, + positionY, + rotation, + scaleX, + scaleY, + blendMode, + hidden, + volume, + muted, + }: { + elementId: string; + name?: string; + mask?: { + action: "add" | "update" | "remove"; + maskType?: string; + params?: Record; + }; + opacity?: number; + positionX?: number; + positionY?: number; + rotation?: number; + scaleX?: number; + scaleY?: number; + blendMode?: string; + hidden?: boolean; + volume?: number; + muted?: boolean; + }): + | { success: boolean; elementId: 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: `Timeline element not found: ${elementId}` }; + } + + const { element } = resolved; + const patch: Record = {}; + const applied: Record = {}; + + if (name !== undefined) { + if (typeof name !== "string" || !name.trim()) { + return { error: "name must be a non-empty string" }; + } + patch.name = name; + applied.name = name; + } + + if (muted !== undefined) { + if (typeof muted !== "boolean") { + return { error: "muted must be a boolean" }; + } + if (element.type !== "video" && element.type !== "audio") { + return { + error: `Element type '${element.type}' does not support muted`, + }; + } + patch.muted = muted; + applied.muted = muted; + } + + if (opacity !== undefined) { + if (typeof opacity !== "number" || opacity < 0 || opacity > 100) { + return { error: "opacity must be a number between 0 and 100" }; + } + if (!hasProperty(element, "opacity")) { + return { + error: `Element type '${element.type}' does not support opacity`, + }; + } + patch.opacity = opacity; + applied.opacity = opacity; + } + + if (hidden !== undefined) { + if (typeof hidden !== "boolean") { + return { error: "hidden must be a boolean" }; + } + patch.hidden = hidden; + applied.hidden = hidden; + } + + if (blendMode !== undefined) { + if (typeof blendMode !== "string") { + return { error: "blendMode must be a string" }; + } + if (!hasProperty(element, "blendMode")) { + return { + error: `Element type '${element.type}' does not support blendMode`, + }; + } + patch.blendMode = blendMode as BlendMode; + applied.blendMode = blendMode; + } + + if (volume !== undefined) { + if (typeof volume !== "number" || volume < 0 || volume > 100) { + return { error: "volume must be a number between 0 and 100" }; + } + if (element.type !== "video" && element.type !== "audio") { + return { + error: `Element type '${element.type}' does not support volume`, + }; + } + patch.volume = volume; + applied.volume = volume; + } + + if ( + positionX !== undefined || + positionY !== undefined || + rotation !== undefined || + scaleX !== undefined || + scaleY !== undefined + ) { + if (!hasProperty(element, "transform")) { + return { + error: `Element type '${element.type}' does not support transform properties`, + }; + } + const currentTransform = (element as { transform: Transform }).transform; + const nextTransform: Transform = { + scaleX: scaleX ?? currentTransform.scaleX, + scaleY: scaleY ?? currentTransform.scaleY, + position: { + x: positionX ?? currentTransform.position.x, + y: positionY ?? currentTransform.position.y, + }, + rotate: rotation ?? currentTransform.rotate, + }; + patch.transform = nextTransform; + applied.transform = nextTransform; + } + + if (mask !== undefined) { + if (!isMaskableElement(element)) { + return { + error: `Element type '${element.type}' does not support masks. Only video, image, and graphic elements support masks.`, + }; + } + + const maskable = element as MaskableElement; + const currentMasks = maskable.masks ?? []; + + if (mask.action === "add") { + if (!mask.maskType || typeof mask.maskType !== "string") { + return { error: "mask.maskType is required when action is 'add'" }; + } + if (!masksRegistry.has(mask.maskType as MaskType)) { + return { + error: `Unknown mask type: ${mask.maskType}. Available: split, cinematic-bars, rectangle, ellipse, heart, diamond, star`, + }; + } + + const newMask = buildDefaultMaskInstance({ + maskType: mask.maskType as MaskType, + }); + if (mask.params) { + newMask.params = { ...newMask.params, ...mask.params }; + } + patch.masks = [...currentMasks, newMask]; + applied.mask = { + id: newMask.id, + type: newMask.type, + params: newMask.params, + }; + } else if (mask.action === "update") { + if (currentMasks.length === 0) { + return { + error: `Element has no mask to update. Use action 'add' first.`, + }; + } + if (!mask.params || typeof mask.params !== "object") { + return { error: "mask.params is required when action is 'update'" }; + } + + const existingMask = currentMasks[0]; + const updatedMasks = currentMasks.map((m, i) => + i === 0 ? { ...m, params: { ...m.params, ...mask.params } } : m, + ); + patch.masks = updatedMasks; + applied.mask = { + type: existingMask.type, + params: updatedMasks[0].params, + }; + } else if (mask.action === "remove") { + if (currentMasks.length === 0) { + return { error: "Element has no mask to remove" }; + } + patch.masks = []; + applied.mask = null; + } + } + + if (Object.keys(patch).length === 0) { + return { + error: "No properties to update. Provide at least one property.", + }; + } + + core.timeline.updateElements({ + updates: [ + { + trackId: resolved.track.id, + elementId, + patch: patch as Partial, + }, + ], + }); + + return { success: true, elementId, applied }; + }, }; function secondsToTicks(seconds: number): number { return Math.round(seconds * TICKS_PER_SECOND); } +function hasProperty(obj: unknown, prop: string): boolean { + return typeof obj === "object" && obj !== null && prop in obj; +} + function ticksToSeconds(ticks: number): number { return ticks / TICKS_PER_SECOND; } @@ -1070,3 +1330,104 @@ function validateEffectParams( } export { buildSystemPrompt } from "@/agent/system-prompt"; + +function serializeElement( + element: TimelineElement, + trackId: string, +): Record { + const base = { + elementId: element.id, + trackId, + type: element.type, + name: element.name, + start: ticksToSeconds(element.startTime), + end: ticksToSeconds(element.startTime + element.duration), + duration: ticksToSeconds(element.duration), + trimStart: ticksToSeconds(element.trimStart), + trimEnd: ticksToSeconds(element.trimEnd), + }; + + switch (element.type) { + case "video": + return { + ...base, + assetId: element.mediaId, + transform: element.transform, + opacity: element.opacity, + blendMode: element.blendMode ?? null, + hidden: element.hidden ?? false, + volume: element.volume ?? 100, + muted: element.muted ?? false, + masks: element.masks ?? [], + effects: element.effects ?? [], + }; + case "image": + return { + ...base, + assetId: element.mediaId, + transform: element.transform, + opacity: element.opacity, + blendMode: element.blendMode ?? null, + hidden: element.hidden ?? false, + masks: element.masks ?? [], + effects: element.effects ?? [], + }; + case "text": + return { + ...base, + content: element.content, + fontSize: element.fontSize, + fontFamily: element.fontFamily, + color: element.color, + fontWeight: element.fontWeight, + fontStyle: element.fontStyle, + textAlign: element.textAlign, + letterSpacing: element.letterSpacing ?? null, + lineHeight: element.lineHeight ?? null, + background: element.background, + transform: element.transform, + opacity: element.opacity, + blendMode: element.blendMode ?? null, + hidden: element.hidden ?? false, + effects: element.effects ?? [], + }; + case "sticker": + return { + ...base, + stickerId: element.stickerId, + transform: element.transform, + opacity: element.opacity, + blendMode: element.blendMode ?? null, + hidden: element.hidden ?? false, + effects: element.effects ?? [], + }; + case "graphic": + return { + ...base, + definitionId: element.definitionId, + params: element.params, + transform: element.transform, + opacity: element.opacity, + blendMode: element.blendMode ?? null, + hidden: element.hidden ?? false, + masks: element.masks ?? [], + effects: element.effects ?? [], + }; + case "audio": + return { + ...base, + assetId: element.sourceType === "upload" ? element.mediaId : null, + sourceType: element.sourceType, + volume: element.volume, + muted: element.muted ?? false, + }; + case "effect": + return { + ...base, + effectType: element.effectType, + params: element.params, + }; + default: + return base; + } +} diff --git a/apps/web/src/agent/tools/get-element.tool.ts b/apps/web/src/agent/tools/get-element.tool.ts new file mode 100644 index 00000000..4978b62d --- /dev/null +++ b/apps/web/src/agent/tools/get-element.tool.ts @@ -0,0 +1,22 @@ +import type { AgentContext, ToolDefinition } from "@/agent/types"; +import { toolRegistry } from "@/agent/tools/registry"; +import { getElementSchema } from "@/agent/tools/schemas"; +import { EditorContextAdapter } from "@/agent/context"; + +const getElementTool: ToolDefinition = { + ...getElementSchema, + execute: async ( + args: Record, + _context: AgentContext, + ): Promise | { error: string }> => { + const elementId = args.elementId; + + if (typeof elementId !== "string" || !elementId.trim()) { + return { error: "Invalid elementId" }; + } + + return EditorContextAdapter.getElement({ elementId }); + }, +}; + +toolRegistry.register(getElementSchema.name, getElementTool); diff --git a/apps/web/src/agent/tools/index.ts b/apps/web/src/agent/tools/index.ts index c21904fe..94d9c0f6 100644 --- a/apps/web/src/agent/tools/index.ts +++ b/apps/web/src/agent/tools/index.ts @@ -13,6 +13,7 @@ import "@/agent/tools/load-context.tool"; import "@/agent/tools/list-project-assets.tool"; import "@/agent/tools/list-timeline.tool"; +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"; @@ -24,3 +25,4 @@ import "@/agent/tools/list-effects.tool"; import "@/agent/tools/get-effect.tool"; import "@/agent/tools/apply-effect.tool"; import "@/agent/tools/update-effect.tool"; +import "@/agent/tools/update-clip.tool"; diff --git a/apps/web/src/agent/tools/schemas.ts b/apps/web/src/agent/tools/schemas.ts index cb0bc7c1..1fff15fb 100644 --- a/apps/web/src/agent/tools/schemas.ts +++ b/apps/web/src/agent/tools/schemas.ts @@ -171,6 +171,34 @@ export const updateEffectSchema: ToolSchema = { ], }; +export const getElementSchema: ToolSchema = { + name: "get_element", + description: + "Returns full metadata for a single timeline element. Use list_timeline to discover elementIds, then get_element for deep inspection. Returns type-specific properties: video/image/graphic elements include transform, opacity, blendMode, masks, hidden, and applied effects. Text elements include content, font styles, background, transform. Audio elements include volume, muted. Effect elements include effectType and all parameter values.", + parameters: [{ key: "elementId", type: "string", required: true }], +}; + +export const updateClipSchema: ToolSchema = { + name: "update_clip", + description: + "Updates properties of any timeline element (video, image, graphic, text, sticker, audio, effect). Use list_timeline to discover elementId, then get_element to inspect current values. Only provide the properties you want to change. mask: { action: 'add', maskType } to add, { action: 'update', params: {...} } to modify, { action: 'remove' } to delete. Mask types: rectangle, ellipse, heart, diamond, star, split, cinematic-bars. Only video/image/graphic support masks. name: rename the element. opacity: 0-100. positionX/positionY: position offset. rotation: degrees. scaleX/scaleY: scale factor. blendMode: normal, darken, multiply, screen, etc. hidden: boolean. volume: 0-100 (video/audio only). muted: boolean (video/audio only).", + parameters: [ + { key: "elementId", type: "string", required: true }, + { key: "name", type: "string", required: false }, + { key: "mask", type: "object", required: false }, + { key: "opacity", type: "number", required: false }, + { key: "positionX", type: "number", required: false }, + { key: "positionY", type: "number", required: false }, + { key: "rotation", type: "number", required: false }, + { key: "scaleX", type: "number", required: false }, + { key: "scaleY", type: "number", required: false }, + { key: "blendMode", type: "string", required: false }, + { key: "hidden", type: "boolean", required: false }, + { key: "volume", type: "number", required: false }, + { key: "muted", type: "boolean", required: false }, + ], +}; + /** * The exact list of schemas exposed to the LLM. * Excludes internal-only tools (transcribe_video, mock). @@ -179,6 +207,7 @@ export const providerToolSchemas: ToolSchema[] = [ loadContextSchema, listProjectAssetsSchema, listTimelineSchema, + getElementSchema, splitSchema, deleteTimelineElementsSchema, moveTimelineElementsSchema, @@ -190,4 +219,5 @@ export const providerToolSchemas: ToolSchema[] = [ getEffectSchema, applyEffectSchema, updateEffectSchema, + updateClipSchema, ]; diff --git a/apps/web/src/agent/tools/update-clip.tool.ts b/apps/web/src/agent/tools/update-clip.tool.ts new file mode 100644 index 00000000..e87682a4 --- /dev/null +++ b/apps/web/src/agent/tools/update-clip.tool.ts @@ -0,0 +1,88 @@ +import type { AgentContext, ToolDefinition } from "@/agent/types"; +import { toolRegistry } from "@/agent/tools/registry"; +import { updateClipSchema } from "@/agent/tools/schemas"; +import { EditorContextAdapter } from "@/agent/context"; + +const updateClipTool: ToolDefinition = { + ...updateClipSchema, + execute: async ( + args: Record, + _context: AgentContext, + ): Promise< + | { success: boolean; elementId: string; applied: Record } + | { error: string } + > => { + const elementId = args.elementId; + const name = args.name as string | undefined; + const mask = args.mask as + | { + action: "add" | "update" | "remove"; + maskType?: string; + params?: Record; + } + | undefined; + const opacity = args.opacity as number | undefined; + const positionX = args.positionX as number | undefined; + const positionY = args.positionY as number | undefined; + const rotation = args.rotation as number | undefined; + const scaleX = args.scaleX as number | undefined; + const scaleY = args.scaleY as number | undefined; + const blendMode = args.blendMode as string | undefined; + const hidden = args.hidden as boolean | undefined; + const volume = args.volume as number | undefined; + const muted = args.muted as boolean | undefined; + + if (typeof elementId !== "string" || !elementId.trim()) { + return { error: "Invalid elementId" }; + } + + const hasUpdate = + name !== undefined || + mask !== undefined || + opacity !== undefined || + positionX !== undefined || + positionY !== undefined || + rotation !== undefined || + scaleX !== undefined || + scaleY !== undefined || + blendMode !== undefined || + hidden !== undefined || + volume !== undefined || + muted !== undefined; + + if (!hasUpdate) { + return { + error: "No properties to update. Provide at least one property.", + }; + } + + if ( + mask !== undefined && + (typeof mask !== "object" || Array.isArray(mask)) + ) { + return { error: "mask must be an object with an 'action' property" }; + } + + if (mask && !["add", "update", "remove"].includes(mask.action)) { + return { error: "mask.action must be 'add', 'update', or 'remove'" }; + } + + return EditorContextAdapter.updateClip({ + elementId, + name, + mask, + opacity, + positionX, + positionY, + rotation, + scaleX, + scaleY, + blendMode, + hidden, + volume, + muted, + }); + }, +}; + +toolRegistry.register(updateClipSchema.name, updateClipTool); From 28b825267f7ee5a7084787e335047881d7836ec4 Mon Sep 17 00:00:00 2001 From: Luis Esteban Acevedo Ladino Date: Sun, 26 Apr 2026 18:54:36 -0500 Subject: [PATCH 5/6] 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 --- apps/web/src/agent/context-mapper.ts | 12 +++++ apps/web/src/agent/context.ts | 49 +++++++++++++++++++ .../agent/tools/duplicate-elements.tool.ts | 32 ++++++++++++ apps/web/src/agent/tools/index.ts | 2 + apps/web/src/agent/tools/schemas.ts | 16 ++++++ apps/web/src/agent/tools/undo.tool.ts | 16 ++++++ apps/web/src/agent/types.ts | 3 ++ 7 files changed, 130 insertions(+) create mode 100644 apps/web/src/agent/tools/duplicate-elements.tool.ts create mode 100644 apps/web/src/agent/tools/undo.tool.ts 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. */ From f474a87267e2e1100b454c4e16f6c29ac2f382fc Mon Sep 17 00:00:00 2001 From: Luis Esteban Acevedo Ladino Date: Sun, 26 Apr 2026 19:02:39 -0500 Subject: [PATCH 6/6] feat: add redo, toggle track mute/visibility, and trim support - redo: re-applies the last undone action via core.command.redo() - toggle_track_mute: toggles mute on video/audio tracks via ToggleTrackMuteCommand - toggle_track_visibility: toggles visibility on overlay tracks via ToggleTrackVisibilityCommand - update_clip now supports trimStart/trimEnd for slip-trimming clips without moving them on the timeline --- apps/web/src/agent/context.ts | 87 +++++++++++++++++++ apps/web/src/agent/tools/index.ts | 3 + apps/web/src/agent/tools/redo.tool.ts | 16 ++++ apps/web/src/agent/tools/schemas.ts | 28 +++++- .../src/agent/tools/toggle-track-mute.tool.ts | 22 +++++ .../tools/toggle-track-visibility.tool.ts | 25 ++++++ apps/web/src/agent/tools/update-clip.tool.ts | 6 ++ 7 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/agent/tools/redo.tool.ts create mode 100644 apps/web/src/agent/tools/toggle-track-mute.tool.ts create mode 100644 apps/web/src/agent/tools/toggle-track-visibility.tool.ts diff --git a/apps/web/src/agent/context.ts b/apps/web/src/agent/context.ts index 84506129..c7bac4d2 100644 --- a/apps/web/src/agent/context.ts +++ b/apps/web/src/agent/context.ts @@ -3,6 +3,8 @@ import { TICKS_PER_SECOND } from "@/lib/wasm"; import { buildContextFromEditorState } from "@/agent/context-mapper"; import { BatchCommand } from "@/lib/commands"; import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline"; +import { ToggleTrackMuteCommand } from "@/lib/commands/timeline/track/toggle-track-mute"; +import { ToggleTrackVisibilityCommand } from "@/lib/commands/timeline/track/toggle-track-visibility"; import { DEFAULT_NEW_ELEMENT_DURATION } from "@/lib/timeline/creation"; import { buildElementFromMedia, @@ -89,6 +91,64 @@ export const EditorContextAdapter = { return { remainingUndoDepth }; }, + redo(): { remainingRedoDepth: number } | { error: string } { + const core = EditorCore.getInstance(); + if (!core.command.canRedo()) { + return { error: "Nothing to redo" }; + } + core.command.redo(); + const remainingRedoDepth = core.command.canRedo() ? 1 : 0; + return { remainingRedoDepth }; + }, + + toggleTrackMute({ + trackId, + }: { + trackId: string; + }): { trackId: string } | { error: string } { + const core = EditorCore.getInstance(); + const activeScene = core.scenes.getActiveSceneOrNull(); + if (!activeScene) { + return { error: "No active timeline" }; + } + + const track = findTrackInSceneTracks({ + tracks: activeScene.tracks, + trackId, + }); + if (!track) { + return { error: `Track not found: ${trackId}` }; + } + + core.command.execute({ command: new ToggleTrackMuteCommand(trackId) }); + return { trackId }; + }, + + toggleTrackVisibility({ + trackId, + }: { + trackId: string; + }): { trackId: string } | { error: string } { + const core = EditorCore.getInstance(); + const activeScene = core.scenes.getActiveSceneOrNull(); + if (!activeScene) { + return { error: "No active timeline" }; + } + + const track = findTrackInSceneTracks({ + tracks: activeScene.tracks, + trackId, + }); + if (!track) { + return { error: `Track not found: ${trackId}` }; + } + + core.command.execute({ + command: new ToggleTrackVisibilityCommand(trackId), + }); + return { trackId }; + }, + duplicateElements({ elementIds }: { elementIds: string[] }): | { success: boolean; @@ -761,6 +821,8 @@ export const EditorContextAdapter = { elementId, name, mask, + trimStart, + trimEnd, opacity, positionX, positionY, @@ -779,6 +841,8 @@ export const EditorContextAdapter = { maskType?: string; params?: Record; }; + trimStart?: number; + trimEnd?: number; opacity?: number; positionX?: number; positionY?: number; @@ -831,6 +895,29 @@ export const EditorContextAdapter = { applied.muted = muted; } + if (trimStart !== undefined || trimEnd !== undefined) { + if ( + (trimStart !== undefined && typeof trimStart !== "number") || + (trimEnd !== undefined && typeof trimEnd !== "number") + ) { + return { error: "trimStart and trimEnd must be numbers (seconds)" }; + } + if (trimStart !== undefined && trimStart < 0) { + return { error: "trimStart must be >= 0" }; + } + if (trimEnd !== undefined && trimEnd < 0) { + return { error: "trimEnd must be >= 0" }; + } + if (trimStart !== undefined) { + patch.trimStart = secondsToTicks(trimStart); + applied.trimStart = trimStart; + } + if (trimEnd !== undefined) { + patch.trimEnd = secondsToTicks(trimEnd); + applied.trimEnd = trimEnd; + } + } + if (opacity !== undefined) { if (typeof opacity !== "number" || opacity < 0 || opacity > 100) { return { error: "opacity must be a number between 0 and 100" }; diff --git a/apps/web/src/agent/tools/index.ts b/apps/web/src/agent/tools/index.ts index 0ef6b470..9f73e068 100644 --- a/apps/web/src/agent/tools/index.ts +++ b/apps/web/src/agent/tools/index.ts @@ -28,3 +28,6 @@ import "@/agent/tools/apply-effect.tool"; import "@/agent/tools/update-effect.tool"; import "@/agent/tools/update-clip.tool"; import "@/agent/tools/undo.tool"; +import "@/agent/tools/redo.tool"; +import "@/agent/tools/toggle-track-mute.tool"; +import "@/agent/tools/toggle-track-visibility.tool"; diff --git a/apps/web/src/agent/tools/redo.tool.ts b/apps/web/src/agent/tools/redo.tool.ts new file mode 100644 index 00000000..0f364460 --- /dev/null +++ b/apps/web/src/agent/tools/redo.tool.ts @@ -0,0 +1,16 @@ +import type { AgentContext, ToolDefinition } from "@/agent/types"; +import { toolRegistry } from "@/agent/tools/registry"; +import { redoSchema } from "@/agent/tools/schemas"; +import { EditorContextAdapter } from "@/agent/context"; + +const redoTool: ToolDefinition = { + ...redoSchema, + execute: async ( + _args: Record, + _context: AgentContext, + ): Promise<{ remainingRedoDepth: number } | { error: string }> => { + return EditorContextAdapter.redo(); + }, +}; + +toolRegistry.register(redoSchema.name, redoTool); diff --git a/apps/web/src/agent/tools/schemas.ts b/apps/web/src/agent/tools/schemas.ts index 12861f11..4fb67640 100644 --- a/apps/web/src/agent/tools/schemas.ts +++ b/apps/web/src/agent/tools/schemas.ts @@ -171,6 +171,27 @@ export const updateEffectSchema: ToolSchema = { ], }; +export const redoSchema: ToolSchema = { + name: "redo", + description: + "Redoes the last undone action. Only works after an undo. Returns whether there are more actions to redo.", + parameters: [], +}; + +export const toggleTrackMuteSchema: ToolSchema = { + name: "toggle_track_mute", + description: + "Toggles mute on a timeline track. Use list_timeline to discover trackIds. Only works on tracks that support audio (video and audio tracks). Returns the new muted state.", + parameters: [{ key: "trackId", type: "string", required: true }], +}; + +export const toggleTrackVisibilitySchema: ToolSchema = { + name: "toggle_track_visibility", + description: + "Toggles visibility on a timeline track. Hidden tracks are not rendered in the preview. Use list_timeline to discover trackIds. Returns the new hidden state.", + parameters: [{ key: "trackId", type: "string", required: true }], +}; + export const undoSchema: ToolSchema = { name: "undo", description: @@ -195,11 +216,13 @@ export const getElementSchema: ToolSchema = { export const updateClipSchema: ToolSchema = { name: "update_clip", description: - "Updates properties of any timeline element (video, image, graphic, text, sticker, audio, effect). Use list_timeline to discover elementId, then get_element to inspect current values. Only provide the properties you want to change. mask: { action: 'add', maskType } to add, { action: 'update', params: {...} } to modify, { action: 'remove' } to delete. Mask types: rectangle, ellipse, heart, diamond, star, split, cinematic-bars. Only video/image/graphic support masks. name: rename the element. opacity: 0-100. positionX/positionY: position offset. rotation: degrees. scaleX/scaleY: scale factor. blendMode: normal, darken, multiply, screen, etc. hidden: boolean. volume: 0-100 (video/audio only). muted: boolean (video/audio only).", + "Updates properties of any timeline element (video, image, graphic, text, sticker, audio, effect). Use list_timeline to discover elementId, then get_element to inspect current values. Only provide the properties you want to change. mask: { action: 'add', maskType } to add, { action: 'update', params: {...} } to modify, { action: 'remove' } to delete. Mask types: rectangle, ellipse, heart, diamond, star, split, cinematic-bars. Only video/image/graphic support masks. name: rename the element. trimStart/trimEnd: seconds to trim from the source start/end (slip trim without moving the clip). opacity: 0-100. positionX/positionY: position offset. rotation: degrees. scaleX/scaleY: scale factor. blendMode: normal, darken, multiply, screen, etc. hidden: boolean. volume: 0-100 (video/audio only). muted: boolean (video/audio only).", parameters: [ { key: "elementId", type: "string", required: true }, { key: "name", type: "string", required: false }, { key: "mask", type: "object", required: false }, + { key: "trimStart", type: "number", required: false }, + { key: "trimEnd", type: "number", required: false }, { key: "opacity", type: "number", required: false }, { key: "positionX", type: "number", required: false }, { key: "positionY", type: "number", required: false }, @@ -236,4 +259,7 @@ export const providerToolSchemas: ToolSchema[] = [ updateEffectSchema, updateClipSchema, undoSchema, + redoSchema, + toggleTrackMuteSchema, + toggleTrackVisibilitySchema, ]; diff --git a/apps/web/src/agent/tools/toggle-track-mute.tool.ts b/apps/web/src/agent/tools/toggle-track-mute.tool.ts new file mode 100644 index 00000000..bcc1ecd7 --- /dev/null +++ b/apps/web/src/agent/tools/toggle-track-mute.tool.ts @@ -0,0 +1,22 @@ +import type { AgentContext, ToolDefinition } from "@/agent/types"; +import { toolRegistry } from "@/agent/tools/registry"; +import { toggleTrackMuteSchema } from "@/agent/tools/schemas"; +import { EditorContextAdapter } from "@/agent/context"; + +const toggleTrackMuteTool: ToolDefinition = { + ...toggleTrackMuteSchema, + execute: async ( + args: Record, + _context: AgentContext, + ): Promise<{ trackId: string } | { error: string }> => { + const trackId = args.trackId; + + if (typeof trackId !== "string" || !trackId.trim()) { + return { error: "Invalid trackId" }; + } + + return EditorContextAdapter.toggleTrackMute({ trackId }); + }, +}; + +toolRegistry.register(toggleTrackMuteSchema.name, toggleTrackMuteTool); diff --git a/apps/web/src/agent/tools/toggle-track-visibility.tool.ts b/apps/web/src/agent/tools/toggle-track-visibility.tool.ts new file mode 100644 index 00000000..a0932372 --- /dev/null +++ b/apps/web/src/agent/tools/toggle-track-visibility.tool.ts @@ -0,0 +1,25 @@ +import type { AgentContext, ToolDefinition } from "@/agent/types"; +import { toolRegistry } from "@/agent/tools/registry"; +import { toggleTrackVisibilitySchema } from "@/agent/tools/schemas"; +import { EditorContextAdapter } from "@/agent/context"; + +const toggleTrackVisibilityTool: ToolDefinition = { + ...toggleTrackVisibilitySchema, + execute: async ( + args: Record, + _context: AgentContext, + ): Promise<{ trackId: string } | { error: string }> => { + const trackId = args.trackId; + + if (typeof trackId !== "string" || !trackId.trim()) { + return { error: "Invalid trackId" }; + } + + return EditorContextAdapter.toggleTrackVisibility({ trackId }); + }, +}; + +toolRegistry.register( + toggleTrackVisibilitySchema.name, + toggleTrackVisibilityTool, +); diff --git a/apps/web/src/agent/tools/update-clip.tool.ts b/apps/web/src/agent/tools/update-clip.tool.ts index e87682a4..7af177b9 100644 --- a/apps/web/src/agent/tools/update-clip.tool.ts +++ b/apps/web/src/agent/tools/update-clip.tool.ts @@ -21,6 +21,8 @@ const updateClipTool: ToolDefinition = { params?: Record; } | undefined; + const trimStart = args.trimStart as number | undefined; + const trimEnd = args.trimEnd as number | undefined; const opacity = args.opacity as number | undefined; const positionX = args.positionX as number | undefined; const positionY = args.positionY as number | undefined; @@ -39,6 +41,8 @@ const updateClipTool: ToolDefinition = { const hasUpdate = name !== undefined || mask !== undefined || + trimStart !== undefined || + trimEnd !== undefined || opacity !== undefined || positionX !== undefined || positionY !== undefined || @@ -71,6 +75,8 @@ const updateClipTool: ToolDefinition = { elementId, name, mask, + trimStart, + trimEnd, opacity, positionX, positionY,