feat: add agent effect tools (list, get, apply)

This commit is contained in:
Luis Esteban Acevedo Ladino 2026-04-26 16:19:32 -05:00
parent 4c94c4d7da
commit 2e32740698
7 changed files with 446 additions and 11 deletions

View File

@ -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<string, number | string | boolean>;
}):
| { 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<TimelineElement>;
}
function validateEffectParams(
paramDefs: import("@/lib/params").ParamDefinition[],
params: Record<string, number | string | boolean>,
): 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";

View File

@ -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<string, unknown>,
_context: AgentContext,
): Promise<
| {
effectId: string;
elementId: string;
appliedParams: Record<string, number | string | boolean>;
}
| { error: string }
> => {
const trackId = args.trackId;
const elementId = args.elementId;
const effectType = args.effectType;
const params = args.params as
| Record<string, number | string | boolean>
| 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);

View File

@ -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<string, unknown>,
_context: AgentContext,
): Promise<GetEffectResult | { error: string }> => {
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);

View File

@ -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";

View File

@ -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<string, unknown>,
_context: AgentContext,
): Promise<ListEffectsResult> => {
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);

View File

@ -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,
];

View File

@ -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: <type>" }`.
---
## 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<string, number | string | boolean>;
}
```
@ -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<string, number | string | boolean>;
}
```
### 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: <type>" }`.
- Missing element: `{ error: "Timeline element not found: <id>" }`.
- Missing track: `{ error: "Track not found: <id>" }`.
- Unsupported element: `{ error: "Element does not support effects" }`.
- Unknown parameter: `{ error: "Unknown parameter: <key>" }`.
- Out of range: `{ error: "Parameter '<key>' must be >= <min>" }`.
---