Merge pull request #7 from luisacevelad/feat/agent-effect-tools
Feat/agent effect tools
This commit is contained in:
commit
eea0b5d866
|
|
@ -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<string, unknown>)[key];
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ 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,
|
||||
buildEffectElement,
|
||||
buildTextElement,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
|
|
@ -22,6 +25,12 @@ 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 { 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/.
|
||||
|
|
@ -72,6 +81,113 @@ 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 };
|
||||
},
|
||||
|
||||
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;
|
||||
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,
|
||||
}: {
|
||||
|
|
@ -545,12 +661,424 @@ export const EditorContextAdapter = {
|
|||
skipped,
|
||||
};
|
||||
},
|
||||
|
||||
addEffectElement({
|
||||
effectType,
|
||||
start,
|
||||
end,
|
||||
params,
|
||||
}: {
|
||||
effectType: string;
|
||||
start: number;
|
||||
end: number;
|
||||
params?: Record<string, number | string | boolean>;
|
||||
}):
|
||||
| {
|
||||
elementId: string;
|
||||
trackId: string;
|
||||
appliedParams: Record<string, number | string | boolean>;
|
||||
}
|
||||
| { 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 definition = effectsRegistry.get(effectType);
|
||||
|
||||
if (params) {
|
||||
const validationError = validateEffectParams(definition.params, params);
|
||||
if (validationError) {
|
||||
return { error: validationError };
|
||||
}
|
||||
}
|
||||
|
||||
const startTimeTicks = secondsToTicks(start);
|
||||
const durationTicks = secondsToTicks(end) - startTimeTicks;
|
||||
if (durationTicks <= 0) {
|
||||
return { error: "Invalid time range" };
|
||||
}
|
||||
|
||||
const element = buildEffectElement({
|
||||
effectType,
|
||||
startTime: startTimeTicks,
|
||||
duration: durationTicks,
|
||||
});
|
||||
|
||||
if (params && Object.keys(params).length > 0) {
|
||||
element.params = { ...element.params, ...params };
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
},
|
||||
|
||||
updateEffectElement({
|
||||
elementId,
|
||||
params,
|
||||
}: {
|
||||
elementId: string;
|
||||
params: Record<string, number | string | boolean>;
|
||||
}):
|
||||
| {
|
||||
success: boolean;
|
||||
elementId: string;
|
||||
appliedParams: Record<string, number | string | boolean>;
|
||||
}
|
||||
| { 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<
|
||||
import("@/lib/timeline/types").TimelineElement
|
||||
>,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
elementId,
|
||||
appliedParams: mergedParams,
|
||||
};
|
||||
},
|
||||
|
||||
getElement({
|
||||
elementId,
|
||||
}: {
|
||||
elementId: string;
|
||||
}): Record<string, unknown> | { error: string } {
|
||||
const core = EditorCore.getInstance();
|
||||
const activeScene = core.scenes.getActiveSceneOrNull();
|
||||
if (!activeScene) {
|
||||
return { error: "No active timeline" };
|
||||
}
|
||||
|
||||
const [resolved] = findTimelineElementsWithTracksByIds({
|
||||
tracks: activeScene.tracks,
|
||||
elementIds: [elementId],
|
||||
});
|
||||
if (!resolved) {
|
||||
return { error: `Element not found: ${elementId}` };
|
||||
}
|
||||
|
||||
return serializeElement(resolved.element, resolved.track.id);
|
||||
},
|
||||
|
||||
updateClip({
|
||||
elementId,
|
||||
name,
|
||||
mask,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
opacity,
|
||||
positionX,
|
||||
positionY,
|
||||
rotation,
|
||||
scaleX,
|
||||
scaleY,
|
||||
blendMode,
|
||||
hidden,
|
||||
volume,
|
||||
muted,
|
||||
}: {
|
||||
elementId: string;
|
||||
name?: string;
|
||||
mask?: {
|
||||
action: "add" | "update" | "remove";
|
||||
maskType?: string;
|
||||
params?: Record<string, number | string | boolean>;
|
||||
};
|
||||
trimStart?: number;
|
||||
trimEnd?: number;
|
||||
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<string, unknown> }
|
||||
| { error: string } {
|
||||
const core = EditorCore.getInstance();
|
||||
const activeScene = core.scenes.getActiveSceneOrNull();
|
||||
if (!activeScene) {
|
||||
return { error: "No active timeline" };
|
||||
}
|
||||
|
||||
const [resolved] = findTimelineElementsWithTracksByIds({
|
||||
tracks: activeScene.tracks,
|
||||
elementIds: [elementId],
|
||||
});
|
||||
if (!resolved) {
|
||||
return { error: `Timeline element not found: ${elementId}` };
|
||||
}
|
||||
|
||||
const { element } = resolved;
|
||||
const patch: Record<string, unknown> = {};
|
||||
const applied: Record<string, unknown> = {};
|
||||
|
||||
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 (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" };
|
||||
}
|
||||
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<TimelineElement>,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -894,4 +1422,148 @@ 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";
|
||||
|
||||
function serializeElement(
|
||||
element: TimelineElement,
|
||||
trackId: string,
|
||||
): Record<string, unknown> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
| {
|
||||
elementId: string;
|
||||
trackId: string;
|
||||
appliedParams: Record<string, number | string | boolean>;
|
||||
}
|
||||
| { error: string }
|
||||
> => {
|
||||
const effectType = args.effectType;
|
||||
const start = args.start;
|
||||
const end = args.end;
|
||||
const params = args.params as
|
||||
| Record<string, number | string | boolean>
|
||||
| undefined;
|
||||
|
||||
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.addEffectElement({
|
||||
effectType,
|
||||
start,
|
||||
end,
|
||||
params,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
toolRegistry.register(applyEffectSchema.name, applyEffectTool);
|
||||
|
|
@ -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<string, unknown>,
|
||||
_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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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<string, unknown>,
|
||||
_context: AgentContext,
|
||||
): Promise<Record<string, unknown> | { 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);
|
||||
|
|
@ -13,10 +13,21 @@
|
|||
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";
|
||||
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";
|
||||
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";
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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<string, unknown>,
|
||||
_context: AgentContext,
|
||||
): Promise<{ remainingRedoDepth: number } | { error: string }> => {
|
||||
return EditorContextAdapter.redo();
|
||||
},
|
||||
};
|
||||
|
||||
toolRegistry.register(redoSchema.name, redoTool);
|
||||
|
|
@ -135,6 +135,107 @@ 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:
|
||||
"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: "effectType", type: "string", required: true },
|
||||
{ key: "start", type: "number", required: true },
|
||||
{ key: "end", type: "number", required: true },
|
||||
{ key: "params", type: "object", required: false },
|
||||
],
|
||||
};
|
||||
|
||||
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 },
|
||||
],
|
||||
};
|
||||
|
||||
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:
|
||||
"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:
|
||||
"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. 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 },
|
||||
{ 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).
|
||||
|
|
@ -143,11 +244,22 @@ export const providerToolSchemas: ToolSchema[] = [
|
|||
loadContextSchema,
|
||||
listProjectAssetsSchema,
|
||||
listTimelineSchema,
|
||||
getElementSchema,
|
||||
splitSchema,
|
||||
deleteTimelineElementsSchema,
|
||||
moveTimelineElementsSchema,
|
||||
duplicateElementsSchema,
|
||||
addMediaToTimelineSchema,
|
||||
updateTimelineElementTimingSchema,
|
||||
addTextSchema,
|
||||
updateTextSchema,
|
||||
listEffectsSchema,
|
||||
getEffectSchema,
|
||||
applyEffectSchema,
|
||||
updateEffectSchema,
|
||||
updateClipSchema,
|
||||
undoSchema,
|
||||
redoSchema,
|
||||
toggleTrackMuteSchema,
|
||||
toggleTrackVisibilitySchema,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>,
|
||||
_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);
|
||||
|
|
@ -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<string, unknown>,
|
||||
_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,
|
||||
);
|
||||
|
|
@ -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<string, unknown>,
|
||||
_context: AgentContext,
|
||||
): Promise<{ remainingUndoDepth: number } | { error: string }> => {
|
||||
return EditorContextAdapter.undo();
|
||||
},
|
||||
};
|
||||
|
||||
toolRegistry.register(undoSchema.name, undoTool);
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
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<string, unknown>,
|
||||
_context: AgentContext,
|
||||
): Promise<
|
||||
| { success: boolean; elementId: string; applied: Record<string, unknown> }
|
||||
| { 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<string, number | string | boolean>;
|
||||
}
|
||||
| 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;
|
||||
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 ||
|
||||
trimStart !== undefined ||
|
||||
trimEnd !== 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,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
opacity,
|
||||
positionX,
|
||||
positionY,
|
||||
rotation,
|
||||
scaleX,
|
||||
scaleY,
|
||||
blendMode,
|
||||
hidden,
|
||||
volume,
|
||||
muted,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
toolRegistry.register(updateClipSchema.name, updateClipTool);
|
||||
|
|
@ -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<string, unknown>,
|
||||
_context: AgentContext,
|
||||
): Promise<
|
||||
| {
|
||||
success: boolean;
|
||||
elementId: string;
|
||||
appliedParams: Record<string, number | string | boolean>;
|
||||
}
|
||||
| { error: string }
|
||||
> => {
|
||||
const elementId = args.elementId;
|
||||
const params = args.params as
|
||||
| Record<string, number | string | boolean>
|
||||
| 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);
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -527,42 +527,173 @@ 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
|
||||
{
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
effectType: "blur";
|
||||
params?: {
|
||||
intensity?: number;
|
||||
};
|
||||
query?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Output
|
||||
```ts
|
||||
{
|
||||
effectId: string;
|
||||
elementId: string;
|
||||
effects: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
### Requirements
|
||||
- MUST validate the element is visual and supports effects.
|
||||
- MUST validate `effectType` exists in the effects registry.
|
||||
- MUST apply default params when `params` are omitted.
|
||||
- MUST validate `intensity` if provided.
|
||||
- MUST preserve undo/redo behavior if supported.
|
||||
- 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
|
||||
- Effect not found: `{ error: "Effect not found" }`.
|
||||
- Unsupported element: `{ error: "Element does not support effects" }`.
|
||||
- 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
|
||||
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
|
||||
{
|
||||
effectType: string;
|
||||
start: number;
|
||||
end: number;
|
||||
params?: Record<string, number | string | boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
### Output
|
||||
```ts
|
||||
{
|
||||
elementId: string;
|
||||
trackId: string;
|
||||
appliedParams: Record<string, number | string | boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
### Requirements
|
||||
- MUST validate `effectType` exists in the effects registry.
|
||||
- MUST validate `start` and `end` as valid timeline seconds with `start < end`.
|
||||
- MUST validate `params` against the effect's parameter definitions (types, ranges).
|
||||
- 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 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: <type>" }`.
|
||||
- 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: <key>" }`.
|
||||
- Out of range: `{ error: "Parameter '<key>' must be >= <min>" }`.
|
||||
|
||||
---
|
||||
|
||||
## 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<string, number | string | boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
### Output
|
||||
```ts
|
||||
{
|
||||
success: boolean;
|
||||
elementId: string;
|
||||
appliedParams: Record<string, number | string | boolean>;
|
||||
}
|
||||
```
|
||||
|
||||
### 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: <id>" }`.
|
||||
- Wrong type: `{ error: "Element is not an effect" }`.
|
||||
- Effect not found: `{ error: "Effect not found: <type>" }`.
|
||||
- Unknown parameter: `{ error: "Unknown parameter: <key>" }`.
|
||||
- Out of range: `{ error: "Parameter '<key>' must be >= <min>" }`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue