@nodetool-ai/agents 0.8.1 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.nodetool-build-stamp +1 -1
- package/dist/analysis/audio-dsp.d.ts +3 -2
- package/dist/analysis/audio-dsp.d.ts.map +1 -1
- package/dist/analysis/audio-dsp.js +11 -8
- package/dist/analysis/audio-dsp.js.map +1 -1
- package/dist/analysis/media-decode.d.ts +13 -0
- package/dist/analysis/media-decode.d.ts.map +1 -1
- package/dist/analysis/media-decode.js +33 -6
- package/dist/analysis/media-decode.js.map +1 -1
- package/dist/author-graph.d.ts +2 -3
- package/dist/author-graph.d.ts.map +1 -1
- package/dist/author-graph.js +3 -5
- package/dist/author-graph.js.map +1 -1
- package/dist/browser-gate.d.ts +2 -2
- package/dist/browser-gate.js +2 -2
- package/dist/capabilities/GamePublishRequest.d.ts +14 -0
- package/dist/capabilities/GamePublishRequest.d.ts.map +1 -0
- package/dist/capabilities/GamePublishRequest.js +2 -0
- package/dist/capabilities/GamePublishRequest.js.map +1 -0
- package/dist/capabilities/adapters.d.ts +4 -13
- package/dist/capabilities/adapters.d.ts.map +1 -1
- package/dist/capabilities/adapters.js +9 -14
- package/dist/capabilities/adapters.js.map +1 -1
- package/dist/capabilities/agent-activity.d.ts +27 -0
- package/dist/capabilities/agent-activity.d.ts.map +1 -0
- package/dist/capabilities/agent-activity.js +103 -0
- package/dist/capabilities/agent-activity.js.map +1 -0
- package/dist/capabilities/agentic-storyboard-finish.d.ts +67 -0
- package/dist/capabilities/agentic-storyboard-finish.d.ts.map +1 -0
- package/dist/capabilities/agentic-storyboard-finish.js +1321 -0
- package/dist/capabilities/agentic-storyboard-finish.js.map +1 -0
- package/dist/capabilities/agents.d.ts +5 -2
- package/dist/capabilities/agents.d.ts.map +1 -1
- package/dist/capabilities/agents.js +14 -4
- package/dist/capabilities/agents.js.map +1 -1
- package/dist/capabilities/agents.specs.d.ts +3 -0
- package/dist/capabilities/agents.specs.d.ts.map +1 -1
- package/dist/capabilities/agents.specs.js +71 -1
- package/dist/capabilities/agents.specs.js.map +1 -1
- package/dist/capabilities/analysis.d.ts.map +1 -1
- package/dist/capabilities/analysis.js +33 -22
- package/dist/capabilities/analysis.js.map +1 -1
- package/dist/capabilities/analysis.specs.js +2 -2
- package/dist/capabilities/analysis.specs.js.map +1 -1
- package/dist/capabilities/apps.d.ts.map +1 -1
- package/dist/capabilities/apps.js +5 -3
- package/dist/capabilities/apps.js.map +1 -1
- package/dist/capabilities/args.d.ts +2 -5
- package/dist/capabilities/args.d.ts.map +1 -1
- package/dist/capabilities/args.js +2 -5
- package/dist/capabilities/args.js.map +1 -1
- package/dist/capabilities/browser.d.ts +1 -1
- package/dist/capabilities/browser.js +1 -1
- package/dist/capabilities/browser.specs.d.ts +1 -1
- package/dist/capabilities/browser.specs.js +1 -1
- package/dist/capabilities/code.d.ts +5 -0
- package/dist/capabilities/code.d.ts.map +1 -1
- package/dist/capabilities/code.js +128 -89
- package/dist/capabilities/code.js.map +1 -1
- package/dist/capabilities/errors.d.ts +19 -0
- package/dist/capabilities/errors.d.ts.map +1 -0
- package/dist/capabilities/errors.js +109 -0
- package/dist/capabilities/errors.js.map +1 -0
- package/dist/capabilities/errors.specs.d.ts +11 -0
- package/dist/capabilities/errors.specs.d.ts.map +1 -0
- package/dist/capabilities/errors.specs.js +100 -0
- package/dist/capabilities/errors.specs.js.map +1 -0
- package/dist/capabilities/example-timelines.d.ts.map +1 -1
- package/dist/capabilities/example-timelines.js +52 -10
- package/dist/capabilities/example-timelines.js.map +1 -1
- package/dist/capabilities/example-timelines.specs.d.ts +1 -0
- package/dist/capabilities/example-timelines.specs.d.ts.map +1 -1
- package/dist/capabilities/example-timelines.specs.js +21 -3
- package/dist/capabilities/example-timelines.specs.js.map +1 -1
- package/dist/capabilities/finish-storyboard.d.ts +5 -0
- package/dist/capabilities/finish-storyboard.d.ts.map +1 -0
- package/dist/capabilities/finish-storyboard.js +278 -0
- package/dist/capabilities/finish-storyboard.js.map +1 -0
- package/dist/capabilities/game-asset-source.js +1 -1
- package/dist/capabilities/game-asset-source.js.map +1 -1
- package/dist/capabilities/game.d.ts +14 -2
- package/dist/capabilities/game.d.ts.map +1 -1
- package/dist/capabilities/game.js +209 -39
- package/dist/capabilities/game.js.map +1 -1
- package/dist/capabilities/game.specs.d.ts +2 -0
- package/dist/capabilities/game.specs.d.ts.map +1 -1
- package/dist/capabilities/game.specs.js +25 -11
- package/dist/capabilities/game.specs.js.map +1 -1
- package/dist/capabilities/game3d.d.ts +17 -0
- package/dist/capabilities/game3d.d.ts.map +1 -0
- package/dist/capabilities/game3d.js +433 -0
- package/dist/capabilities/game3d.js.map +1 -0
- package/dist/capabilities/gate-from-context.d.ts +1 -1
- package/dist/capabilities/gate-from-context.js +1 -1
- package/dist/capabilities/generations.d.ts +2 -0
- package/dist/capabilities/generations.d.ts.map +1 -1
- package/dist/capabilities/generations.js +7 -2
- package/dist/capabilities/generations.js.map +1 -1
- package/dist/capabilities/index.d.ts +2 -2
- package/dist/capabilities/index.d.ts.map +1 -1
- package/dist/capabilities/index.js +2 -2
- package/dist/capabilities/index.js.map +1 -1
- package/dist/capabilities/invoke.d.ts +14 -25
- package/dist/capabilities/invoke.d.ts.map +1 -1
- package/dist/capabilities/invoke.js +72 -35
- package/dist/capabilities/invoke.js.map +1 -1
- package/dist/capabilities/jobs.d.ts.map +1 -1
- package/dist/capabilities/jobs.js +47 -3
- package/dist/capabilities/jobs.js.map +1 -1
- package/dist/capabilities/jobs.specs.d.ts.map +1 -1
- package/dist/capabilities/jobs.specs.js +3 -1
- package/dist/capabilities/jobs.specs.js.map +1 -1
- package/dist/capabilities/js-scripts.d.ts +19 -1
- package/dist/capabilities/js-scripts.d.ts.map +1 -1
- package/dist/capabilities/js-scripts.js +109 -2
- package/dist/capabilities/js-scripts.js.map +1 -1
- package/dist/capabilities/js-scripts.specs.d.ts +2 -0
- package/dist/capabilities/js-scripts.specs.d.ts.map +1 -1
- package/dist/capabilities/js-scripts.specs.js +50 -0
- package/dist/capabilities/js-scripts.specs.js.map +1 -1
- package/dist/capabilities/lazy-tool.d.ts +23 -24
- package/dist/capabilities/lazy-tool.d.ts.map +1 -1
- package/dist/capabilities/lazy-tool.js +29 -35
- package/dist/capabilities/lazy-tool.js.map +1 -1
- package/dist/capabilities/legacy-tools.d.ts +15 -0
- package/dist/capabilities/legacy-tools.d.ts.map +1 -0
- package/dist/capabilities/legacy-tools.js +98 -0
- package/dist/capabilities/legacy-tools.js.map +1 -0
- package/dist/capabilities/media.d.ts +0 -9
- package/dist/capabilities/media.d.ts.map +1 -1
- package/dist/capabilities/media.js +301 -260
- package/dist/capabilities/media.js.map +1 -1
- package/dist/capabilities/models.d.ts +2 -2
- package/dist/capabilities/models.d.ts.map +1 -1
- package/dist/capabilities/models.js +5 -3
- package/dist/capabilities/models.js.map +1 -1
- package/dist/capabilities/registry.d.ts +2 -0
- package/dist/capabilities/registry.d.ts.map +1 -1
- package/dist/capabilities/registry.js +28 -0
- package/dist/capabilities/registry.js.map +1 -1
- package/dist/capabilities/run-agent.d.ts +19 -0
- package/dist/capabilities/run-agent.d.ts.map +1 -0
- package/dist/capabilities/run-agent.js +183 -0
- package/dist/capabilities/run-agent.js.map +1 -0
- package/dist/capabilities/runs.d.ts +9 -0
- package/dist/capabilities/runs.d.ts.map +1 -0
- package/dist/capabilities/runs.js +46 -0
- package/dist/capabilities/runs.js.map +1 -0
- package/dist/capabilities/runs.specs.d.ts +39 -0
- package/dist/capabilities/runs.specs.d.ts.map +1 -0
- package/dist/capabilities/runs.specs.js +52 -0
- package/dist/capabilities/runs.specs.js.map +1 -0
- package/dist/capabilities/scripts.d.ts.map +1 -1
- package/dist/capabilities/scripts.js +11 -5
- package/dist/capabilities/scripts.js.map +1 -1
- package/dist/capabilities/sketches.d.ts.map +1 -1
- package/dist/capabilities/sketches.js +4 -2
- package/dist/capabilities/sketches.js.map +1 -1
- package/dist/capabilities/skills.d.ts.map +1 -1
- package/dist/capabilities/skills.js +22 -2
- package/dist/capabilities/skills.js.map +1 -1
- package/dist/capabilities/storyboard-layout-check.d.ts +33 -0
- package/dist/capabilities/storyboard-layout-check.d.ts.map +1 -0
- package/dist/capabilities/storyboard-layout-check.js +229 -0
- package/dist/capabilities/storyboard-layout-check.js.map +1 -0
- package/dist/capabilities/storyboard-measured-sources.d.ts +10 -0
- package/dist/capabilities/storyboard-measured-sources.d.ts.map +1 -0
- package/dist/capabilities/storyboard-measured-sources.js +67 -0
- package/dist/capabilities/storyboard-measured-sources.js.map +1 -0
- package/dist/capabilities/storyboard-review-times.d.ts +4 -0
- package/dist/capabilities/storyboard-review-times.d.ts.map +1 -0
- package/dist/capabilities/storyboard-review-times.js +39 -0
- package/dist/capabilities/storyboard-review-times.js.map +1 -0
- package/dist/capabilities/storyboards.d.ts +2 -18
- package/dist/capabilities/storyboards.d.ts.map +1 -1
- package/dist/capabilities/storyboards.js +315 -94
- package/dist/capabilities/storyboards.js.map +1 -1
- package/dist/capabilities/storyboards.specs.d.ts +3 -0
- package/dist/capabilities/storyboards.specs.d.ts.map +1 -1
- package/dist/capabilities/storyboards.specs.js +42 -3
- package/dist/capabilities/storyboards.specs.js.map +1 -1
- package/dist/capabilities/timeline-bridge.d.ts +359 -0
- package/dist/capabilities/timeline-bridge.d.ts.map +1 -0
- package/dist/capabilities/timeline-bridge.js +1016 -0
- package/dist/capabilities/timeline-bridge.js.map +1 -0
- package/dist/capabilities/timeline-clip-frames.d.ts +17 -0
- package/dist/capabilities/timeline-clip-frames.d.ts.map +1 -0
- package/dist/capabilities/timeline-clip-frames.js +84 -0
- package/dist/capabilities/timeline-clip-frames.js.map +1 -0
- package/dist/capabilities/timeline-media-edit.d.ts +8 -0
- package/dist/capabilities/timeline-media-edit.d.ts.map +1 -0
- package/dist/capabilities/timeline-media-edit.js +94 -0
- package/dist/capabilities/timeline-media-edit.js.map +1 -0
- package/dist/capabilities/timeline-native-media.d.ts.map +1 -1
- package/dist/capabilities/timeline-native-media.js +40 -0
- package/dist/capabilities/timeline-native-media.js.map +1 -1
- package/dist/capabilities/timelines.d.ts +64 -4
- package/dist/capabilities/timelines.d.ts.map +1 -1
- package/dist/capabilities/timelines.js +566 -67
- package/dist/capabilities/timelines.js.map +1 -1
- package/dist/capabilities/timelines.specs.d.ts +8 -0
- package/dist/capabilities/timelines.specs.d.ts.map +1 -1
- package/dist/capabilities/timelines.specs.js +173 -8
- package/dist/capabilities/timelines.specs.js.map +1 -1
- package/dist/capabilities/types.d.ts +3 -1
- package/dist/capabilities/types.d.ts.map +1 -1
- package/dist/capabilities/types.js.map +1 -1
- package/dist/capabilities/ui.d.ts.map +1 -1
- package/dist/capabilities/ui.js +24 -2
- package/dist/capabilities/ui.js.map +1 -1
- package/dist/capabilities/workflows.d.ts.map +1 -1
- package/dist/capabilities/workflows.js +53 -26
- package/dist/capabilities/workflows.js.map +1 -1
- package/dist/capabilities/workflows.specs.d.ts.map +1 -1
- package/dist/capabilities/workflows.specs.js +7 -1
- package/dist/capabilities/workflows.specs.js.map +1 -1
- package/dist/codeact/action-diagnostics.d.ts.map +1 -1
- package/dist/codeact/action-diagnostics.js +51 -1
- package/dist/codeact/action-diagnostics.js.map +1 -1
- package/dist/codeact/codeact-executor.d.ts.map +1 -1
- package/dist/codeact/codeact-executor.js +16 -8
- package/dist/codeact/codeact-executor.js.map +1 -1
- package/dist/codeact/compact-ids.d.ts.map +1 -1
- package/dist/codeact/compact-ids.js +5 -1
- package/dist/codeact/compact-ids.js.map +1 -1
- package/dist/codeact/graph-model.d.ts +1 -1
- package/dist/codeact/graph-model.d.ts.map +1 -1
- package/dist/codeact/graph-model.js +2 -3
- package/dist/codeact/graph-model.js.map +1 -1
- package/dist/codeact/nodetool-api.d.ts +1 -1
- package/dist/codeact/nodetool-api.d.ts.map +1 -1
- package/dist/codeact/nodetool-api.js +202 -284
- package/dist/codeact/nodetool-api.js.map +1 -1
- package/dist/codeact/tool-api.d.ts +6 -3
- package/dist/codeact/tool-api.d.ts.map +1 -1
- package/dist/codeact/tool-api.js +22 -15
- package/dist/codeact/tool-api.js.map +1 -1
- package/dist/evals/codeact-api-cases.d.ts.map +1 -1
- package/dist/evals/codeact-api-cases.js +2 -0
- package/dist/evals/codeact-api-cases.js.map +1 -1
- package/dist/evals/codeact-api-runs.d.ts +3 -0
- package/dist/evals/codeact-api-runs.d.ts.map +1 -0
- package/dist/evals/codeact-api-runs.js +33 -0
- package/dist/evals/codeact-api-runs.js.map +1 -0
- package/dist/evals/surfaces/sketch.d.ts +2 -0
- package/dist/evals/surfaces/sketch.d.ts.map +1 -1
- package/dist/evals/surfaces/sketch.js +11 -3
- package/dist/evals/surfaces/sketch.js.map +1 -1
- package/dist/evals/surfaces/timeline.d.ts +4 -357
- package/dist/evals/surfaces/timeline.d.ts.map +1 -1
- package/dist/evals/surfaces/timeline.js +9 -2621
- package/dist/evals/surfaces/timeline.js.map +1 -1
- package/dist/evals/tool-loop-bridge.d.ts +4 -0
- package/dist/evals/tool-loop-bridge.d.ts.map +1 -1
- package/dist/evals/tool-loop-bridge.js.map +1 -1
- package/dist/evals/tool-loop-eval.d.ts +2 -0
- package/dist/evals/tool-loop-eval.d.ts.map +1 -1
- package/dist/evals/tool-loop-eval.js +6 -1
- package/dist/evals/tool-loop-eval.js.map +1 -1
- package/dist/evals/tool-loop-permission.d.ts +1 -15
- package/dist/evals/tool-loop-permission.d.ts.map +1 -1
- package/dist/evals/tool-loop-permission.js +23 -41
- package/dist/evals/tool-loop-permission.js.map +1 -1
- package/dist/game-authoring.d.ts +4737 -0
- package/dist/game-authoring.d.ts.map +1 -0
- package/dist/game-authoring.js +146 -0
- package/dist/game-authoring.js.map +1 -0
- package/dist/game-code-bake.d.ts +26 -0
- package/dist/game-code-bake.d.ts.map +1 -0
- package/dist/game-code-bake.js +98 -0
- package/dist/game-code-bake.js.map +1 -0
- package/dist/game-examples.d.ts +872 -16
- package/dist/game-examples.d.ts.map +1 -1
- package/dist/game-examples.js +5 -4
- package/dist/game-examples.js.map +1 -1
- package/dist/index.d.ts +15 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -3
- package/dist/index.js.map +1 -1
- package/dist/js-sandbox-worker/engine.d.ts +4 -0
- package/dist/js-sandbox-worker/engine.d.ts.map +1 -0
- package/dist/js-sandbox-worker/engine.js +45 -0
- package/dist/js-sandbox-worker/engine.js.map +1 -0
- package/dist/js-sandbox-worker/host.js +1 -1
- package/dist/js-sandbox-worker/host.js.map +1 -1
- package/dist/js-sandbox-worker/interpreter.d.ts +1 -1
- package/dist/js-sandbox-worker/interpreter.d.ts.map +1 -1
- package/dist/js-sandbox-worker/interpreter.js +94 -2
- package/dist/js-sandbox-worker/interpreter.js.map +1 -1
- package/dist/js-sandbox-worker/worker-entry.d.ts.map +1 -1
- package/dist/js-sandbox-worker/worker-entry.js +3 -2
- package/dist/js-sandbox-worker/worker-entry.js.map +1 -1
- package/dist/js-sandbox.d.ts +2 -0
- package/dist/js-sandbox.d.ts.map +1 -1
- package/dist/js-sandbox.js +56 -11
- package/dist/js-sandbox.js.map +1 -1
- package/dist/js-script-app-runner.d.ts +2 -0
- package/dist/js-script-app-runner.d.ts.map +1 -1
- package/dist/js-script-app-runner.js +8 -3
- package/dist/js-script-app-runner.js.map +1 -1
- package/dist/js-script-sandbox.d.ts +12 -18
- package/dist/js-script-sandbox.d.ts.map +1 -1
- package/dist/js-script-sandbox.js +3 -49
- package/dist/js-script-sandbox.js.map +1 -1
- package/dist/prompts/product-knowledge.d.ts +1 -1
- package/dist/prompts/product-knowledge.d.ts.map +1 -1
- package/dist/prompts/product-knowledge.js +82 -41
- package/dist/prompts/product-knowledge.js.map +1 -1
- package/dist/sandbox-pack-resolution.d.ts +21 -0
- package/dist/sandbox-pack-resolution.d.ts.map +1 -0
- package/dist/sandbox-pack-resolution.js +42 -0
- package/dist/sandbox-pack-resolution.js.map +1 -0
- package/dist/sandbox-toolbelt.d.ts +3 -1
- package/dist/sandbox-toolbelt.d.ts.map +1 -1
- package/dist/sandbox-toolbelt.js +8 -2
- package/dist/sandbox-toolbelt.js.map +1 -1
- package/dist/skill-prompt.d.ts +11 -1
- package/dist/skill-prompt.d.ts.map +1 -1
- package/dist/skill-prompt.js +17 -6
- package/dist/skill-prompt.js.map +1 -1
- package/dist/system-skills.d.ts +7 -1
- package/dist/system-skills.d.ts.map +1 -1
- package/dist/system-skills.js +2 -2
- package/dist/system-skills.js.map +1 -1
- package/dist/timeline-capture.d.ts +21 -0
- package/dist/timeline-capture.d.ts.map +1 -0
- package/dist/timeline-capture.js +564 -0
- package/dist/timeline-capture.js.map +1 -0
- package/dist/timeline-code-bake.d.ts +75 -0
- package/dist/timeline-code-bake.d.ts.map +1 -0
- package/dist/timeline-code-bake.js +327 -0
- package/dist/timeline-code-bake.js.map +1 -0
- package/dist/timeline-preview/frames.d.ts +26 -0
- package/dist/timeline-preview/frames.d.ts.map +1 -1
- package/dist/timeline-preview/frames.js +155 -70
- package/dist/timeline-preview/frames.js.map +1 -1
- package/dist/tools/base-tool.d.ts +1 -0
- package/dist/tools/base-tool.d.ts.map +1 -1
- package/dist/tools/base-tool.js +1 -1
- package/dist/tools/base-tool.js.map +1 -1
- package/dist/tools/builtin-tools.d.ts.map +1 -1
- package/dist/tools/builtin-tools.js +13 -0
- package/dist/tools/builtin-tools.js.map +1 -1
- package/dist/tools/external-capability-tools.d.ts +1 -1
- package/dist/tools/external-capability-tools.js +1 -1
- package/dist/tools/mcp-tool-support.d.ts +10 -9
- package/dist/tools/mcp-tool-support.d.ts.map +1 -1
- package/dist/tools/mcp-tool-support.js +14 -13
- package/dist/tools/mcp-tool-support.js.map +1 -1
- package/dist/tools/mcp-tools.d.ts +6 -4
- package/dist/tools/mcp-tools.d.ts.map +1 -1
- package/dist/tools/mcp-tools.js +65 -62
- package/dist/tools/mcp-tools.js.map +1 -1
- package/dist/tools/tool-permissions.d.ts +1 -1
- package/dist/tools/tool-permissions.js +1 -1
- package/package.json +24 -22
- package/dist/capabilities/gate-tools.d.ts +0 -18
- package/dist/capabilities/gate-tools.d.ts.map +0 -1
- package/dist/capabilities/gate-tools.js +0 -66
- package/dist/capabilities/gate-tools.js.map +0 -1
- package/dist/tsconfig.tests.tmp.tsbuildinfo +0 -1
|
@@ -1,68 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Headless bridge for the timeline / video editor tool-loop eval.
|
|
3
|
-
*
|
|
4
|
-
* The real frontend tools (`web/src/lib/tools/builtin/timeline.ts`) delegate to
|
|
5
|
-
* a `TimelineAgentHandler` the live `TimelineEditor` registers per open
|
|
6
|
-
* sequence on `timelineAgentBridge` — it mutates Zustand document/UI/playback
|
|
7
|
-
* stores and (for `ui_timeline_get_clip_frames`) samples rendered video
|
|
8
|
-
* frames. None of that can run under Node. This bridge reimplements the
|
|
9
|
-
* *effects* of the non-rendering tools against a plain in-memory sequence
|
|
10
|
-
* (tracks + clips from `@nodetool-ai/timeline`), so a model can drive the same
|
|
11
|
-
* `ui_timeline_*` tool surface headlessly.
|
|
12
|
-
*
|
|
13
|
-
* What it does NOT fork is the tool *contract*: names, descriptions, and Zod
|
|
14
|
-
* parameter shapes are copied verbatim from the builtin file (minus the
|
|
15
|
-
* `timeline_id` parameter — this bridge holds a single implicit sequence, so
|
|
16
|
-
* there is nothing to disambiguate). `ui_timeline_get_clip_frames` is
|
|
17
|
-
* intentionally excluded: it requires real rendered video frames and has no
|
|
18
|
-
* meaningful headless equivalent.
|
|
19
|
-
*
|
|
20
|
-
* Real cut/preset logic is reused from the pure `@nodetool-ai/timeline`
|
|
21
|
-
* package (`splitClip`, `ANIMATION_PRESETS`, `makeClip`/`makeTrack`) rather
|
|
22
|
-
* than reimplemented, so this bridge exercises the same behavior the live
|
|
23
|
-
* editor does.
|
|
24
|
-
*/
|
|
25
|
-
import { z } from "zod";
|
|
26
|
-
import { isShortResourceId } from "@nodetool-ai/protocol";
|
|
27
|
-
import { parseWithTypeCoercion } from "@nodetool-ai/runtime";
|
|
28
|
-
import { splitClip, trimClip, ANIMATION_PRESETS, ANIMATED_PROPERTIES, buildBakedAnimation, CUSTOM_ANIMATION_CONTRACT, CUSTOM_ANIMATION_PRESET_ID, normalizeCustomCurves, resolveCustomMask, computeModel3DBakeHash, makeClip, makeClipVersion, makeTrack, DEFAULT_TEXT_CLIP_DURATION_MS, DEFAULT_MEDIA_CLIP_DURATION_MS, DEFAULT_MODEL3D_CLIP_DURATION_MS, DEFAULT_MODEL3D_CLIP_NAME, model3dStyleWithPatch, shapeStyleWithDefaults, assertAuthorableFontFamily, textStyleWithDefaults, moveTrackOrder, mediaTypeForContentType, trackTypeForMediaType, STAGGER_UNITS, parseStaggerUnit, typewriterTiming, parseEasing, DEFAULT_BEAT_TOLERANCE_MS, buildBeatGrid, beatCountToCover, snapClipsToGrid, staggerClipAnimations, isGroupClip, moveGroup, ungroup, trimGroup, resliceTracksForSplitClip, resliceTracksForTrimmedClip, applyTransitionAtCutCandidate, planTransitionAtCut, instantiateComposition, DEFAULT_MIDI_INSTRUMENT, DEFAULT_TEMPO, MIDI_INSTRUMENT_PRESETS, createMidiNote, findInstrumentPreset, formatBarsBeats, presetIdForInstrument, quantizeNotes, scaleVelocity, sortNotes, transposeNotes, validateNotes, visibleNotes, rescaleClipsForTempo, resolveTempo, captureMediaEditSourceContext, composeGenerativeTakePatch, createMediaEditRequest, ensureBaselineTake, applyTakeToClip, selectGeneratedMatteVersion, activeTakeIdOf, selectTake, renameTake as renameTakeOnClip, deleteTake as deleteTakeOnClip, adaptSequenceFormat, addReframeKeyframe, clearReframe, isMediaTrackStale, mediaTrackCanDriveReframe } from "@nodetool-ai/timeline";
|
|
29
|
-
import { computeActiveLayers, countTextStaggerUnits, parseSvgPath } from "@nodetool-ai/timeline/scene";
|
|
30
|
-
import { APPLY_TRANSITION_AT_CUT_DESCRIPTION, applyTransitionAtCutParams, buildEffect, buildMask, buildTimeRemap, buildTransition, resolveDeleteTrackArgs, resolveMoveTrackArgs, resolveShapeArg, setBakedAnimationParams, SET_BAKED_ANIMATION_DESCRIPTION, setGeneratedMatteParams, SET_GENERATED_MATTE_DESCRIPTION, textStyleParams, textStylePatchParams } from "@nodetool-ai/protocol/api-schemas/timeline-tool-params.js";
|
|
31
|
-
import { buildTimelineToolContracts, liftCustomAnimation, rejectUnknownClipParams } from "@nodetool-ai/protocol/api-schemas/timeline-tool-contracts.js";
|
|
32
|
-
import { uiToolParams } from "@nodetool-ai/protocol/api-schemas/ui-tool-contract.js";
|
|
33
1
|
import { findSystemSkill } from "../../system-skills.js";
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
*
|
|
38
|
-
*/
|
|
39
|
-
const CONTRACTS = buildTimelineToolContracts({
|
|
40
|
-
staggerUnits: STAGGER_UNITS,
|
|
41
|
-
animatedProperties: ANIMATED_PROPERTIES,
|
|
42
|
-
beatToleranceMs: DEFAULT_BEAT_TOLERANCE_MS
|
|
43
|
-
});
|
|
44
|
-
const generatedTransitionCandidateParams = applyTransitionAtCutParams;
|
|
45
|
-
const transitionCandidateParams = z.object({
|
|
46
|
-
candidate_id: z.string().trim().min(1)
|
|
47
|
-
});
|
|
48
|
-
const transitionCandidateLifecycleParams = applyTransitionAtCutParams
|
|
49
|
-
.partial()
|
|
50
|
-
.extend({ candidate_id: z.string().trim().min(1).optional() })
|
|
51
|
-
.superRefine((value, context) => {
|
|
52
|
-
if (value.candidate_id === undefined &&
|
|
53
|
-
(value.outgoingClipId === undefined || value.incomingClipId === undefined)) {
|
|
54
|
-
context.addIssue({
|
|
55
|
-
code: "custom",
|
|
56
|
-
message: "Provide outgoingClipId and incomingClipId to create a candidate, or candidate_id to apply one."
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
});
|
|
60
|
-
/** Units a failed lookup names before it stops and points at get_state. */
|
|
61
|
-
const MAX_LISTED_UNITS = 12;
|
|
62
|
-
/**
|
|
63
|
-
* Tools that read the sequence without changing it. Everything else is an
|
|
64
|
-
* edit, which is what {@link previewedAfterLastEdit} measures "last" against.
|
|
65
|
-
*/
|
|
2
|
+
import { ANIMATION_PRESETS, parseEasing, parseStaggerUnit } from "@nodetool-ai/timeline";
|
|
3
|
+
import { countTextStaggerUnits } from "@nodetool-ai/timeline/scene";
|
|
4
|
+
import { createTimelineToolBridge } from "../../capabilities/timeline-bridge.js";
|
|
5
|
+
export * from "../../capabilities/timeline-bridge.js";
|
|
66
6
|
export const TIMELINE_READ_ONLY_TOOLS = [
|
|
67
7
|
"ui_timeline_get_state",
|
|
68
8
|
"ui_timeline_list_animation_presets",
|
|
@@ -212,2560 +152,6 @@ export function easingDecelerates(easing) {
|
|
|
212
152
|
const landing = (curve(1) - curve(1 - EASING_SLOPE_STEP)) / EASING_SLOPE_STEP;
|
|
213
153
|
return landing < entry;
|
|
214
154
|
}
|
|
215
|
-
function tool(name, description, parameters, impl) {
|
|
216
|
-
return {
|
|
217
|
-
name,
|
|
218
|
-
description,
|
|
219
|
-
parameters,
|
|
220
|
-
execute: (args) => {
|
|
221
|
-
const parsed = parseWithTypeCoercion(parameters, args ?? {});
|
|
222
|
-
return impl(parsed);
|
|
223
|
-
}
|
|
224
|
-
};
|
|
225
|
-
}
|
|
226
|
-
/**
|
|
227
|
-
* `set_clip_params` takes the style as a patch merged over the clip's own, so
|
|
228
|
-
* every field is optional. The shared contract still declares the whole-bag
|
|
229
|
-
* `textStyleParams`, which refuses `{textStyle: {fontFamily}}` on a finished
|
|
230
|
-
* title. Both hosts override that one field; when protocol carries
|
|
231
|
-
* `textStylePatchParams` the two overrides go together.
|
|
232
|
-
*/
|
|
233
|
-
const CONTRACT_SHAPE_OVERRIDES = {
|
|
234
|
-
ui_timeline_set_clip_params: { textStyle: textStylePatchParams.optional() }
|
|
235
|
-
};
|
|
236
|
-
/** A tool whose name, description and argument shape both hosts share. */
|
|
237
|
-
function sharedTool(name, impl) {
|
|
238
|
-
const contract = CONTRACTS[name];
|
|
239
|
-
const shape = { ...contract.shape, ...CONTRACT_SHAPE_OVERRIDES[name] };
|
|
240
|
-
// No host fields: this bridge drives one implicit sequence, so there is no
|
|
241
|
-
// `timeline_id` to disambiguate.
|
|
242
|
-
return tool(name, contract.description, uiToolParams({ ...contract, shape }), impl);
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* Keys a caller wraps the whole patch in.
|
|
246
|
-
*
|
|
247
|
-
* `set_clip_params` reads its fields off the op itself — `{op, target,
|
|
248
|
-
* textStyle}` — but a REST-shaped guess sends `{op, target, params: {…}}`, and
|
|
249
|
-
* that used to be refused as an unknown key with the real fields hidden one
|
|
250
|
-
* level down inside it. The wrapper says nothing the op does not already know,
|
|
251
|
-
* so it is unwrapped rather than argued with.
|
|
252
|
-
*/
|
|
253
|
-
const CLIP_PARAM_WRAPPERS = ["params", "patch", "props", "properties"];
|
|
254
|
-
/** Lift a patch a caller nested under a wrapper key onto the op itself. */
|
|
255
|
-
export function unwrapClipParams(patch) {
|
|
256
|
-
const wrapper = CLIP_PARAM_WRAPPERS.find((key) => isRecordValue(patch[key]));
|
|
257
|
-
if (!wrapper)
|
|
258
|
-
return patch;
|
|
259
|
-
const { [wrapper]: nested, ...rest } = patch;
|
|
260
|
-
// The op's own keys win: a caller that sent both meant the one it spelled
|
|
261
|
-
// out, and silently preferring the wrapper would drop it.
|
|
262
|
-
return { ...nested, ...rest };
|
|
263
|
-
}
|
|
264
|
-
const isRecordValue = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
265
|
-
function mergeClipTransform(current, patch) {
|
|
266
|
-
return {
|
|
267
|
-
position: { x: 0, y: 0, ...current?.position, ...patch.position },
|
|
268
|
-
scale: { x: 1, y: 1, ...current?.scale, ...patch.scale },
|
|
269
|
-
rotation: patch.rotation ?? current?.rotation ?? 0,
|
|
270
|
-
rotationX: patch.rotationX ?? current?.rotationX,
|
|
271
|
-
rotationY: patch.rotationY ?? current?.rotationY,
|
|
272
|
-
perspective: patch.perspective ?? current?.perspective,
|
|
273
|
-
anchor: { x: 0.5, y: 0.5, ...current?.anchor, ...patch.anchor }
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
/**
|
|
277
|
-
* A text style patch, merged over the clip's own and checked for a family that
|
|
278
|
-
* names no face.
|
|
279
|
-
*
|
|
280
|
-
* A clip that carries no text style yet still needs the three fields that make
|
|
281
|
-
* one drawable, so the merged result goes through the same schema the whole
|
|
282
|
-
* bag does rather than being stored half-built.
|
|
283
|
-
*/
|
|
284
|
-
function mergeTextStyle(clip, patch) {
|
|
285
|
-
const merged = { ...(clip.textStyle ?? {}), ...patch };
|
|
286
|
-
const parsed = textStyleParams.safeParse(merged);
|
|
287
|
-
if (!parsed.success) {
|
|
288
|
-
const missing = parsed.error.issues
|
|
289
|
-
.filter((issue) => issue.code === "invalid_type")
|
|
290
|
-
.map((issue) => issue.path.join("."));
|
|
291
|
-
throw new Error(missing.length > 0
|
|
292
|
-
? `Clip "${clip.name}" has no text style yet, so this patch cannot ` +
|
|
293
|
-
`stand on its own — it still needs ${missing.join(", ")}.`
|
|
294
|
-
: `textStyle: ${parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`);
|
|
295
|
-
}
|
|
296
|
-
assertAuthorableFontFamily(parsed.data.fontFamily);
|
|
297
|
-
return parsed.data;
|
|
298
|
-
}
|
|
299
|
-
function capitalize(s) {
|
|
300
|
-
return s.length > 0 ? s[0].toUpperCase() + s.slice(1) : s;
|
|
301
|
-
}
|
|
302
|
-
/**
|
|
303
|
-
* Units a staggered animation splits into — a text clip's word count, which is
|
|
304
|
-
* what a custom body reads off `inputs.staggerCount`. Zero when the clip does
|
|
305
|
-
* not stagger, matching the un-staggered block case.
|
|
306
|
-
*/
|
|
307
|
-
function staggerUnitCount(clip, stagger) {
|
|
308
|
-
if (!stagger || clip.mediaType !== "text")
|
|
309
|
-
return 0;
|
|
310
|
-
const text = clip.textStyle?.text ?? "";
|
|
311
|
-
const words = text.trim().split(/\s+/).filter((word) => word.length > 0);
|
|
312
|
-
return words.length;
|
|
313
|
-
}
|
|
314
|
-
/**
|
|
315
|
-
* Build an in-memory timeline bridge whose tools share the `ui_timeline_*`
|
|
316
|
-
* contract but run headlessly against a plain sequence of tracks/clips (no
|
|
317
|
-
* Zustand stores, no rendering).
|
|
318
|
-
*/
|
|
319
|
-
export function createTimelineToolBridge(initial = {}) {
|
|
320
|
-
const seed = initial.sequence;
|
|
321
|
-
const resolveAsset = initial.resolveAsset;
|
|
322
|
-
const bakeAnimation = initial.bakeAnimation;
|
|
323
|
-
const bakeModel3DClip = initial.bakeModel3DClip;
|
|
324
|
-
const loadComposition = initial.loadComposition;
|
|
325
|
-
const retargetFormat = initial.retargetFormat;
|
|
326
|
-
const generateTransition = initial.generateTransition;
|
|
327
|
-
const sequenceId = initial.sequenceId ?? "seq_eval";
|
|
328
|
-
const sequenceName = initial.sequenceName ?? "Sequence";
|
|
329
|
-
const projectId = initial.projectId ?? "";
|
|
330
|
-
const fps = seed?.fps ?? initial.fps ?? 30;
|
|
331
|
-
const width = seed?.width ?? initial.width ?? 1920;
|
|
332
|
-
const height = seed?.height ?? initial.height ?? 1080;
|
|
333
|
-
let playheadMs = 0;
|
|
334
|
-
let selectedClipIds = [];
|
|
335
|
-
// Absent until a midi track or `set_tempo` puts one there — a document with
|
|
336
|
-
// no midi in it should not grow a tempo field it never asked for.
|
|
337
|
-
let tempo = seed?.tempo;
|
|
338
|
-
let trackSeq = 0;
|
|
339
|
-
let clipSeq = 0;
|
|
340
|
-
let animSeq = 0;
|
|
341
|
-
let markerSeq = 0;
|
|
342
|
-
let versionSeq = 0;
|
|
343
|
-
let transitionSeq = 0;
|
|
344
|
-
const tracks = [];
|
|
345
|
-
let clips = [];
|
|
346
|
-
let markers = [];
|
|
347
|
-
// Document-level, like markers: a track is named by `clipId`, and a clip
|
|
348
|
-
// follows one through its own `trackBinding` (P0 AI Video, Phase 2).
|
|
349
|
-
let mediaTracks = [];
|
|
350
|
-
let transitionCandidates = [];
|
|
351
|
-
let auditionedTransitionCandidateId;
|
|
352
|
-
let appliedTransitionCandidates = [];
|
|
353
|
-
const derivedSequences = [];
|
|
354
|
-
let setup = seed?.setup
|
|
355
|
-
? structuredClone(seed.setup)
|
|
356
|
-
: null;
|
|
357
|
-
let beatSeq = 0;
|
|
358
|
-
const toolLog = [];
|
|
359
|
-
const previewTimesMs = [];
|
|
360
|
-
const previewedLayerKinds = new Set();
|
|
361
|
-
// Ids the sequence already uses. A seeded document brings its own, which the
|
|
362
|
-
// `track_1`/`clip_1` counters would otherwise collide with on the first edit.
|
|
363
|
-
const usedIds = new Set();
|
|
364
|
-
const mint = (prefix, next) => {
|
|
365
|
-
let id = `${prefix}_${next()}`;
|
|
366
|
-
while (usedIds.has(id))
|
|
367
|
-
id = `${prefix}_${next()}`;
|
|
368
|
-
usedIds.add(id);
|
|
369
|
-
return id;
|
|
370
|
-
};
|
|
371
|
-
const nextTrackId = () => mint("track", () => ++trackSeq);
|
|
372
|
-
const nextClipId = () => mint("clip", () => ++clipSeq);
|
|
373
|
-
const nextAnimId = () => mint("anim", () => ++animSeq);
|
|
374
|
-
const nextMarkerId = () => mint("marker", () => ++markerSeq);
|
|
375
|
-
const nextVersionId = () => mint("version", () => ++versionSeq);
|
|
376
|
-
const nextTransitionId = () => mint("transition", () => ++transitionSeq);
|
|
377
|
-
const clipEnd = (clip) => clip.startMs + clip.durationMs;
|
|
378
|
-
function isAmbiguousCut(outgoing, incoming) {
|
|
379
|
-
const cutTimeMs = incoming.startMs;
|
|
380
|
-
return clips.some((candidate) => {
|
|
381
|
-
if (candidate.id === outgoing.id ||
|
|
382
|
-
candidate.id === incoming.id ||
|
|
383
|
-
candidate.trackId !== outgoing.trackId) {
|
|
384
|
-
return false;
|
|
385
|
-
}
|
|
386
|
-
return ((candidate.startMs <= cutTimeMs && clipEnd(candidate) > cutTimeMs) ||
|
|
387
|
-
candidate.startMs === cutTimeMs);
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
|
-
function transitionSource(outgoing, incoming, durationMs) {
|
|
391
|
-
return {
|
|
392
|
-
sequenceId,
|
|
393
|
-
outgoingClipId: outgoing.id,
|
|
394
|
-
incomingClipId: incoming.id,
|
|
395
|
-
outgoingTakeId: activeTakeIdOf(outgoing),
|
|
396
|
-
incomingTakeId: activeTakeIdOf(incoming),
|
|
397
|
-
outgoingAssetId: outgoing.currentAssetId,
|
|
398
|
-
incomingAssetId: incoming.currentAssetId,
|
|
399
|
-
outgoingTransition: outgoing.transitionIn
|
|
400
|
-
? structuredClone(outgoing.transitionIn)
|
|
401
|
-
: undefined,
|
|
402
|
-
incomingTransition: incoming.transitionIn
|
|
403
|
-
? structuredClone(incoming.transitionIn)
|
|
404
|
-
: undefined,
|
|
405
|
-
outgoingStartMs: outgoing.startMs,
|
|
406
|
-
outgoingDurationMs: outgoing.durationMs,
|
|
407
|
-
incomingStartMs: incoming.startMs,
|
|
408
|
-
incomingDurationMs: incoming.durationMs,
|
|
409
|
-
cutTimeMs: incoming.startMs,
|
|
410
|
-
durationMs
|
|
411
|
-
};
|
|
412
|
-
}
|
|
413
|
-
function assertFreshTransitionCandidate(candidate) {
|
|
414
|
-
const outgoing = clips.find((clip) => clip.id === candidate.source.outgoingClipId);
|
|
415
|
-
const incoming = clips.find((clip) => clip.id === candidate.source.incomingClipId);
|
|
416
|
-
if (!outgoing || !incoming) {
|
|
417
|
-
throw new Error("The generated transition candidate is stale because its selected clip no longer exists.");
|
|
418
|
-
}
|
|
419
|
-
if (isAmbiguousCut(outgoing, incoming)) {
|
|
420
|
-
throw new Error("The generated transition candidate is stale because the selected cut is ambiguous.");
|
|
421
|
-
}
|
|
422
|
-
const source = transitionSource(outgoing, incoming, candidate.source.durationMs);
|
|
423
|
-
if (JSON.stringify(source) !== JSON.stringify(candidate.source)) {
|
|
424
|
-
throw new Error("The generated transition candidate is stale because the selected clips or cut changed.");
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
async function createTransitionCandidate(input) {
|
|
428
|
-
const outgoingClipId = input.outgoingClipId;
|
|
429
|
-
const incomingClipId = input.incomingClipId;
|
|
430
|
-
const outgoing = resolveClip(outgoingClipId);
|
|
431
|
-
const incoming = resolveClip(incomingClipId);
|
|
432
|
-
if (isAmbiguousCut(outgoing, incoming)) {
|
|
433
|
-
throw new Error("The selected cut is ambiguous. Choose two adjacent clips with no other clip crossing the cut.");
|
|
434
|
-
}
|
|
435
|
-
const planned = planTransitionAtCut(clips, {
|
|
436
|
-
outgoingClipId: outgoing.id,
|
|
437
|
-
incomingClipId: incoming.id,
|
|
438
|
-
durationMs: input.durationMs,
|
|
439
|
-
overlapMs: input.overlapMs,
|
|
440
|
-
type: input.type,
|
|
441
|
-
easing: input.easing,
|
|
442
|
-
color: input.color,
|
|
443
|
-
direction: input.direction,
|
|
444
|
-
softness: input.softness
|
|
445
|
-
});
|
|
446
|
-
if (!planned.ok)
|
|
447
|
-
throw new Error(planned.error);
|
|
448
|
-
if (!generateTransition) {
|
|
449
|
-
throw new Error("Selected-cut transition generation is unavailable because this surface has no provider generator.");
|
|
450
|
-
}
|
|
451
|
-
const source = transitionSource(outgoing, incoming, planned.candidate.durationMs);
|
|
452
|
-
const request = {
|
|
453
|
-
durationMs: planned.candidate.durationMs,
|
|
454
|
-
overlapMs: planned.candidate.overlapMs,
|
|
455
|
-
type: planned.candidate.type,
|
|
456
|
-
easing: input.easing,
|
|
457
|
-
color: input.color,
|
|
458
|
-
direction: input.direction,
|
|
459
|
-
softness: input.softness
|
|
460
|
-
};
|
|
461
|
-
const generated = await generateTransition({
|
|
462
|
-
sequenceId,
|
|
463
|
-
source,
|
|
464
|
-
request
|
|
465
|
-
});
|
|
466
|
-
if (generated.generationId.trim().length === 0 ||
|
|
467
|
-
generated.assetId.trim().length === 0) {
|
|
468
|
-
throw new Error("Selected-cut transition generation returned no generation id or asset.");
|
|
469
|
-
}
|
|
470
|
-
const id = nextTransitionId();
|
|
471
|
-
const candidate = {
|
|
472
|
-
id,
|
|
473
|
-
generationId: generated.generationId,
|
|
474
|
-
kind: "generated_transition_at_cut",
|
|
475
|
-
status: "success",
|
|
476
|
-
asset: {
|
|
477
|
-
id: generated.assetId,
|
|
478
|
-
contentType: generated.contentType ?? "video/mp4"
|
|
479
|
-
},
|
|
480
|
-
source,
|
|
481
|
-
request,
|
|
482
|
-
operation: {
|
|
483
|
-
op: "apply_generated_transition_at_cut",
|
|
484
|
-
candidateId: id,
|
|
485
|
-
outgoingClipId: outgoing.id,
|
|
486
|
-
incomingClipId: incoming.id,
|
|
487
|
-
cutTimeMs: incoming.startMs,
|
|
488
|
-
durationMs: planned.candidate.durationMs,
|
|
489
|
-
undoable: true
|
|
490
|
-
}
|
|
491
|
-
};
|
|
492
|
-
transitionCandidates = [...transitionCandidates, candidate];
|
|
493
|
-
return candidate;
|
|
494
|
-
}
|
|
495
|
-
function applyTransitionCandidate(candidateId) {
|
|
496
|
-
const candidate = transitionCandidates.find((entry) => entry.id === candidateId);
|
|
497
|
-
if (!candidate) {
|
|
498
|
-
throw new Error(`No generated transition candidate named "${candidateId}" exists.`);
|
|
499
|
-
}
|
|
500
|
-
assertFreshTransitionCandidate(candidate);
|
|
501
|
-
if (appliedTransitionCandidates.some((entry) => entry.id === candidate.id)) {
|
|
502
|
-
throw new Error(`Generated transition candidate "${candidate.id}" was already applied.`);
|
|
503
|
-
}
|
|
504
|
-
const planned = planTransitionAtCut(clips, {
|
|
505
|
-
outgoingClipId: candidate.source.outgoingClipId,
|
|
506
|
-
incomingClipId: candidate.source.incomingClipId,
|
|
507
|
-
...candidate.request
|
|
508
|
-
});
|
|
509
|
-
if (!planned.ok)
|
|
510
|
-
throw new Error(planned.error);
|
|
511
|
-
const applied = applyTransitionAtCutCandidate(clips, planned.candidate);
|
|
512
|
-
if (!applied.ok)
|
|
513
|
-
throw new Error(applied.error);
|
|
514
|
-
clips = applied.clips;
|
|
515
|
-
appliedTransitionCandidates = [
|
|
516
|
-
...appliedTransitionCandidates,
|
|
517
|
-
structuredClone(candidate)
|
|
518
|
-
];
|
|
519
|
-
if (auditionedTransitionCandidateId === candidate.id) {
|
|
520
|
-
auditionedTransitionCandidateId = undefined;
|
|
521
|
-
}
|
|
522
|
-
return {
|
|
523
|
-
candidate,
|
|
524
|
-
operation: candidate.operation,
|
|
525
|
-
description: `Apply generated transition candidate "${candidate.id}" to the selected cut as one undoable operation.`
|
|
526
|
-
};
|
|
527
|
-
}
|
|
528
|
-
function addTrackInternal(type, name) {
|
|
529
|
-
const index = tracks.length;
|
|
530
|
-
const track = makeTrack({
|
|
531
|
-
id: nextTrackId(),
|
|
532
|
-
type,
|
|
533
|
-
name: name ?? `${capitalize(type)} ${index + 1}`,
|
|
534
|
-
index
|
|
535
|
-
});
|
|
536
|
-
if (type === "midi") {
|
|
537
|
-
// The track owns the voice, so a midi track without one plays nothing.
|
|
538
|
-
track.instrument = DEFAULT_MIDI_INSTRUMENT;
|
|
539
|
-
// Ticks are read against the tempo, so the first midi track is where a
|
|
540
|
-
// document that never had one gets it — written down rather than left
|
|
541
|
-
// implicit, so the next reader does not have to know the default.
|
|
542
|
-
tempo ??= DEFAULT_TEMPO;
|
|
543
|
-
}
|
|
544
|
-
tracks.push(track);
|
|
545
|
-
return track;
|
|
546
|
-
}
|
|
547
|
-
/** The midi track a clip or instrument op names, refusing any other type. */
|
|
548
|
-
function resolveMidiTrack(idOrName) {
|
|
549
|
-
const track = resolveTrack(idOrName);
|
|
550
|
-
if (track.type !== "midi") {
|
|
551
|
-
throw new Error(`Track "${track.name}" is a ${track.type} track — notes are played by a midi track's instrument. ${validUnits(tracks.filter((t) => t.type === "midi"), "midi track")}`);
|
|
552
|
-
}
|
|
553
|
-
return track;
|
|
554
|
-
}
|
|
555
|
-
/** The midi clip a note op names, refusing any other kind. */
|
|
556
|
-
function resolveMidiClip(target) {
|
|
557
|
-
const clip = resolveClip(target);
|
|
558
|
-
if (clip.mediaType !== "midi") {
|
|
559
|
-
throw new Error(`Clip "${clip.name}" is a ${clip.mediaType} clip — only a midi clip carries notes. Place one with add_midi_clip.`);
|
|
560
|
-
}
|
|
561
|
-
return clip;
|
|
562
|
-
}
|
|
563
|
-
/**
|
|
564
|
-
* The voice an instrument argument names: `{preset}` resolved to the sound
|
|
565
|
-
* it stands for, or a spelled-out instrument taken as it came.
|
|
566
|
-
*
|
|
567
|
-
* The preset's object is copied onto the track rather than referenced, so
|
|
568
|
-
* the document keeps the sound it was given even if the shipped table
|
|
569
|
-
* changes under it.
|
|
570
|
-
*/
|
|
571
|
-
function resolveInstrumentArg(raw) {
|
|
572
|
-
const value = (raw ?? {});
|
|
573
|
-
const presetId = value["preset"];
|
|
574
|
-
if (typeof presetId !== "string") {
|
|
575
|
-
return value;
|
|
576
|
-
}
|
|
577
|
-
const preset = findInstrumentPreset(presetId);
|
|
578
|
-
if (!preset) {
|
|
579
|
-
throw new Error(`No instrument preset named "${presetId}". Valid presets: ${MIDI_INSTRUMENT_PRESETS.map((p) => `${p.id} ("${p.name}")`).join(", ")}.`);
|
|
580
|
-
}
|
|
581
|
-
return structuredClone(preset.instrument);
|
|
582
|
-
}
|
|
583
|
-
/**
|
|
584
|
-
* The notes a caller sent, minted and checked as one list.
|
|
585
|
-
*
|
|
586
|
-
* Every problem is reported at once: an agent that fixes one note only to be
|
|
587
|
-
* told about the next spends a call per note.
|
|
588
|
-
*/
|
|
589
|
-
function buildNotes(raw, clipName) {
|
|
590
|
-
const notes = (raw ?? []).map((note) => createMidiNote({
|
|
591
|
-
id: note["id"],
|
|
592
|
-
pitch: note["pitch"],
|
|
593
|
-
velocity: note["velocity"],
|
|
594
|
-
startTick: note["start_tick"],
|
|
595
|
-
durationTick: note["duration_tick"]
|
|
596
|
-
}));
|
|
597
|
-
const problems = validateNotes(notes);
|
|
598
|
-
if (problems.length > 0) {
|
|
599
|
-
throw new Error(`Clip "${clipName}" was not given its notes — ${problems.length} problem(s): ` +
|
|
600
|
-
problems
|
|
601
|
-
.map((p) => (p.noteId ? `${p.noteId}: ${p.message}` : p.message))
|
|
602
|
-
.join(" "));
|
|
603
|
-
}
|
|
604
|
-
return sortNotes(notes);
|
|
605
|
-
}
|
|
606
|
-
function findOrCreateTrack(type) {
|
|
607
|
-
const existing = tracks.find((t) => t.type === type);
|
|
608
|
-
if (existing)
|
|
609
|
-
return existing;
|
|
610
|
-
return addTrackInternal(type);
|
|
611
|
-
}
|
|
612
|
-
/**
|
|
613
|
-
* The ids and names a caller could have used, for the message a failed
|
|
614
|
-
* lookup throws. Capped, because a long cut has hundreds of clips and an
|
|
615
|
-
* error listing all of them is one an agent stops reading.
|
|
616
|
-
*/
|
|
617
|
-
function validUnits(units, kind) {
|
|
618
|
-
if (units.length === 0)
|
|
619
|
-
return `The timeline has no ${kind}s yet.`;
|
|
620
|
-
const shown = units
|
|
621
|
-
.slice(0, MAX_LISTED_UNITS)
|
|
622
|
-
.map((u) => `${u.id} ("${u.name}")`)
|
|
623
|
-
.join(", ");
|
|
624
|
-
const rest = units.length - MAX_LISTED_UNITS;
|
|
625
|
-
return rest > 0
|
|
626
|
-
? `Valid ${kind}s: ${shown}, and ${rest} more — call get_state for the full list.`
|
|
627
|
-
: `Valid ${kind}s: ${shown}.`;
|
|
628
|
-
}
|
|
629
|
-
function resolveTrack(idOrName) {
|
|
630
|
-
const byId = tracks.find((t) => t.id === idOrName);
|
|
631
|
-
if (byId)
|
|
632
|
-
return byId;
|
|
633
|
-
if (isShortResourceId(idOrName)) {
|
|
634
|
-
const matches = tracks.filter((track) => track.id.startsWith(idOrName));
|
|
635
|
-
if (matches.length === 1) {
|
|
636
|
-
return matches[0];
|
|
637
|
-
}
|
|
638
|
-
if (matches.length > 1) {
|
|
639
|
-
throw new Error(`Short track id "${idOrName}" matches more than one track. Use the full id or name.`);
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
const lower = idOrName.toLowerCase();
|
|
643
|
-
const byName = tracks.find((t) => t.name.toLowerCase() === lower);
|
|
644
|
-
if (byName)
|
|
645
|
-
return byName;
|
|
646
|
-
throw new Error(`No track found matching "${idOrName}". ${validUnits(tracks, "track")}`);
|
|
647
|
-
}
|
|
648
|
-
function trackEndMs(trackId) {
|
|
649
|
-
return clips
|
|
650
|
-
.filter((c) => c.trackId === trackId)
|
|
651
|
-
.reduce((m, c) => Math.max(m, c.startMs + c.durationMs), 0);
|
|
652
|
-
}
|
|
653
|
-
function resolveClip(target) {
|
|
654
|
-
if (target.toLowerCase() === "selected") {
|
|
655
|
-
if (selectedClipIds.length !== 1) {
|
|
656
|
-
throw new Error(`"selected" requires exactly one selected clip (currently ${selectedClipIds.length}).`);
|
|
657
|
-
}
|
|
658
|
-
const clip = clips.find((c) => c.id === selectedClipIds[0]);
|
|
659
|
-
if (!clip)
|
|
660
|
-
throw new Error("Selected clip no longer exists.");
|
|
661
|
-
return clip;
|
|
662
|
-
}
|
|
663
|
-
const byId = clips.find((c) => c.id === target);
|
|
664
|
-
if (byId)
|
|
665
|
-
return byId;
|
|
666
|
-
if (isShortResourceId(target)) {
|
|
667
|
-
const matches = clips.filter((clip) => clip.id.startsWith(target));
|
|
668
|
-
if (matches.length === 1) {
|
|
669
|
-
return matches[0];
|
|
670
|
-
}
|
|
671
|
-
if (matches.length > 1) {
|
|
672
|
-
throw new Error(`Short clip id "${target}" matches more than one clip. Use the full id or name.`);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
const lower = target.toLowerCase();
|
|
676
|
-
const byName = clips.find((c) => c.name.toLowerCase() === lower);
|
|
677
|
-
if (byName)
|
|
678
|
-
return byName;
|
|
679
|
-
throw new Error(`No clip found matching "${target}". ${validUnits(clips, "clip")}`);
|
|
680
|
-
}
|
|
681
|
-
/**
|
|
682
|
-
* The body of `trim_clip`, shared with `set_clip_params`: a caller that sends
|
|
683
|
-
* `durationMs` alongside a style change means the same edit either way, and
|
|
684
|
-
* two copies of the group-trim rule would drift.
|
|
685
|
-
*/
|
|
686
|
-
function applyTrim(clip, patch) {
|
|
687
|
-
// A group carries what it holds (D4): shortening one pulls its children
|
|
688
|
-
// inside the window that leaves, rather than leaving them hanging past an
|
|
689
|
-
// edge nothing draws.
|
|
690
|
-
if (isGroupClip(clip) && patch.durationMs !== undefined) {
|
|
691
|
-
clips = trimGroup(clips, clip.id, "end", patch.durationMs - clip.durationMs);
|
|
692
|
-
return clips.find((c) => c.id === clip.id);
|
|
693
|
-
}
|
|
694
|
-
let next = clip;
|
|
695
|
-
if (patch.durationMs !== undefined) {
|
|
696
|
-
if (patch.durationMs <= 0) {
|
|
697
|
-
throw new Error(`durationMs must be greater than 0 (got ${patch.durationMs}); delete the clip instead of trimming it to nothing`);
|
|
698
|
-
}
|
|
699
|
-
next = trimClip(clip, "end", patch.durationMs - clip.durationMs);
|
|
700
|
-
clips = clips.map((entry) => entry.id === clip.id ? next : entry);
|
|
701
|
-
}
|
|
702
|
-
if (patch.inPointMs !== undefined)
|
|
703
|
-
next.inPointMs = patch.inPointMs;
|
|
704
|
-
if (patch.outPointMs !== undefined)
|
|
705
|
-
next.outPointMs = patch.outPointMs;
|
|
706
|
-
return next;
|
|
707
|
-
}
|
|
708
|
-
function applyTrimStart(clip, startMs) {
|
|
709
|
-
const deltaMs = clip.startMs - Math.max(0, startMs);
|
|
710
|
-
if (deltaMs === 0)
|
|
711
|
-
return clip;
|
|
712
|
-
if (isGroupClip(clip)) {
|
|
713
|
-
clips = trimGroup(clips, clip.id, "start", deltaMs);
|
|
714
|
-
return clips.find((entry) => entry.id === clip.id);
|
|
715
|
-
}
|
|
716
|
-
const next = trimClip(clip, "start", deltaMs);
|
|
717
|
-
clips = clips.map((entry) => entry.id === clip.id ? next : entry);
|
|
718
|
-
return next;
|
|
719
|
-
}
|
|
720
|
-
/** The body of `move_clip`, shared with `set_clip_params`. */
|
|
721
|
-
function applyMove(clip, patch) {
|
|
722
|
-
// Moving a group moves what it holds by the same delta (D4). Children keep
|
|
723
|
-
// their own tracks, so their z-order is untouched (I9) — only the group
|
|
724
|
-
// itself takes a new `trackId`.
|
|
725
|
-
let moved = clip;
|
|
726
|
-
if (isGroupClip(clip) && patch.startMs !== undefined) {
|
|
727
|
-
const nextStartMs = Math.max(0, patch.startMs);
|
|
728
|
-
clips = moveGroup(clips, clip.id, nextStartMs - clip.startMs);
|
|
729
|
-
moved = clips.find((c) => c.id === clip.id);
|
|
730
|
-
}
|
|
731
|
-
else if (patch.startMs !== undefined) {
|
|
732
|
-
clip.startMs = Math.max(0, patch.startMs);
|
|
733
|
-
}
|
|
734
|
-
if (patch.trackId !== undefined) {
|
|
735
|
-
moved.trackId = resolveTrack(patch.trackId).id;
|
|
736
|
-
}
|
|
737
|
-
return moved;
|
|
738
|
-
}
|
|
739
|
-
/** Resolve a marker by id, or by case-insensitive label. */
|
|
740
|
-
function resolveMarker(target) {
|
|
741
|
-
const byId = markers.find((m) => m.id === target);
|
|
742
|
-
if (byId)
|
|
743
|
-
return byId;
|
|
744
|
-
const lower = target.toLowerCase();
|
|
745
|
-
const byLabel = markers.find((m) => m.label.toLowerCase() === lower);
|
|
746
|
-
if (byLabel)
|
|
747
|
-
return byLabel;
|
|
748
|
-
const known = markers
|
|
749
|
-
.map((m) => `${m.id} ("${m.label}") at ${m.timeMs}ms`)
|
|
750
|
-
.join(", ");
|
|
751
|
-
throw new Error(`No marker matches "${target}". Use a marker id or its label. ` +
|
|
752
|
-
(known.length > 0
|
|
753
|
-
? `Markers: ${known}.`
|
|
754
|
-
: "This sequence has no markers yet."));
|
|
755
|
-
}
|
|
756
|
-
/**
|
|
757
|
-
* Resolve the clips one beat op addresses: the named ones, or every clip.
|
|
758
|
-
*
|
|
759
|
-
* A target that matches nothing comes back as a miss rather than throwing —
|
|
760
|
-
* a batch op that dies on one bad name hides what the other targets did.
|
|
761
|
-
*/
|
|
762
|
-
function resolveSnapTargets(targets) {
|
|
763
|
-
if (!targets || targets.length === 0) {
|
|
764
|
-
return { clips: [...clips], missing: [] };
|
|
765
|
-
}
|
|
766
|
-
const resolved = [];
|
|
767
|
-
const missing = [];
|
|
768
|
-
for (const target of targets) {
|
|
769
|
-
try {
|
|
770
|
-
const clip = resolveClip(target);
|
|
771
|
-
if (!resolved.includes(clip))
|
|
772
|
-
resolved.push(clip);
|
|
773
|
-
}
|
|
774
|
-
catch {
|
|
775
|
-
// Recorded as a skip in the op's own report, with the reason.
|
|
776
|
-
missing.push(target);
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
return { clips: resolved, missing };
|
|
780
|
-
}
|
|
781
|
-
function serializeTrack(t) {
|
|
782
|
-
return {
|
|
783
|
-
id: t.id,
|
|
784
|
-
name: t.name,
|
|
785
|
-
type: t.type,
|
|
786
|
-
index: t.index,
|
|
787
|
-
visible: t.visible,
|
|
788
|
-
locked: t.locked,
|
|
789
|
-
muted: t.muted ?? false,
|
|
790
|
-
solo: t.solo ?? false,
|
|
791
|
-
clipCount: clips.filter((c) => c.trackId === t.id).length,
|
|
792
|
-
// The voice every clip on this track plays. Absent on every other type.
|
|
793
|
-
instrument: t.instrument,
|
|
794
|
-
// Which shipped voice that is, when it is one. A track stores the
|
|
795
|
-
// instrument rather than the id it was picked from, so this is read
|
|
796
|
-
// back by sound — an instrument edited away from a preset reports none.
|
|
797
|
-
presetId: t.instrument ? presetIdForInstrument(t.instrument) : undefined
|
|
798
|
-
};
|
|
799
|
-
}
|
|
800
|
-
/**
|
|
801
|
-
* Build one `preset: "custom"` animation. `curves` are checked and stored;
|
|
802
|
-
* `code` is baked into curves first, by the host that supplied a baker. Both
|
|
803
|
-
* paths end at `normalizeCustomCurves`, the single gate the compiler and the
|
|
804
|
-
* validator also run, so what is stored is what will render.
|
|
805
|
-
*/
|
|
806
|
-
async function buildCustomAnimation(clip, input) {
|
|
807
|
-
const code = typeof input.code === "string" ? input.code.trim() : "";
|
|
808
|
-
const hasCurves = input.curves !== undefined;
|
|
809
|
-
const hasCode = code !== "";
|
|
810
|
-
if (hasCurves && hasCode) {
|
|
811
|
-
throw new Error('A "custom" animation takes exactly one of `curves` and `code`; both were given. Pass the keyframes, or the body that produces them.');
|
|
812
|
-
}
|
|
813
|
-
if (!hasCurves && !hasCode) {
|
|
814
|
-
throw new Error('A "custom" animation needs `curves` or `code`. Accepted shape: ' +
|
|
815
|
-
'{role: "in", preset: "custom", curves: [{property: "offsetY", keyframes: [{t: 0, value: 160}, {t: 1, value: 0}]}]}' +
|
|
816
|
-
' — `t` runs 0..1 over the window. `code` is a JS body baked into curves instead. Either may also be nested under `custom`.');
|
|
817
|
-
}
|
|
818
|
-
// Curves are normalized to 0..1 over the window, so a custom animation
|
|
819
|
-
// with no duration of its own spans the clip and nothing is cropped.
|
|
820
|
-
const durationMs = input.durationMs ?? clip.durationMs;
|
|
821
|
-
let curves;
|
|
822
|
-
let maskInput;
|
|
823
|
-
if (hasCurves) {
|
|
824
|
-
const normalized = normalizeCustomCurves(input.curves);
|
|
825
|
-
if (!normalized.ok)
|
|
826
|
-
throw new Error(normalized.error);
|
|
827
|
-
curves = normalized.curves;
|
|
828
|
-
maskInput = input.mask;
|
|
829
|
-
}
|
|
830
|
-
else {
|
|
831
|
-
if (!bakeAnimation) {
|
|
832
|
-
throw new Error('This surface cannot run `code`: no animation baker is wired to it. Pass `curves` instead, or bake the body through POST /api/timelines/animations/bake.');
|
|
833
|
-
}
|
|
834
|
-
const baked = await bakeAnimation({
|
|
835
|
-
code,
|
|
836
|
-
role: input.role,
|
|
837
|
-
durationMs,
|
|
838
|
-
clipDurationMs: clip.durationMs,
|
|
839
|
-
canvas: { width, height },
|
|
840
|
-
params: input.params,
|
|
841
|
-
staggerCount: staggerUnitCount(clip, input.stagger)
|
|
842
|
-
});
|
|
843
|
-
if (!baked.ok || !baked.curves) {
|
|
844
|
-
throw new Error(baked.error ?? "The animation body returned no curves.");
|
|
845
|
-
}
|
|
846
|
-
const normalized = normalizeCustomCurves(baked.curves);
|
|
847
|
-
if (!normalized.ok)
|
|
848
|
-
throw new Error(normalized.error);
|
|
849
|
-
curves = normalized.curves;
|
|
850
|
-
maskInput = baked.mask ?? input.mask;
|
|
851
|
-
}
|
|
852
|
-
const mask = resolveCustomMask(curves, maskInput);
|
|
853
|
-
if (!mask.ok)
|
|
854
|
-
throw new Error(mask.error);
|
|
855
|
-
const custom = {
|
|
856
|
-
curves,
|
|
857
|
-
bakedAt: new Date().toISOString()
|
|
858
|
-
};
|
|
859
|
-
if (hasCode)
|
|
860
|
-
custom.code = code;
|
|
861
|
-
if (mask.mask)
|
|
862
|
-
custom.mask = mask.mask;
|
|
863
|
-
return {
|
|
864
|
-
id: nextAnimId(),
|
|
865
|
-
role: input.role,
|
|
866
|
-
preset: CUSTOM_ANIMATION_PRESET_ID,
|
|
867
|
-
durationMs,
|
|
868
|
-
delayMs: input.delayMs,
|
|
869
|
-
easing: input.easing,
|
|
870
|
-
params: input.params,
|
|
871
|
-
stagger: input.stagger,
|
|
872
|
-
custom
|
|
873
|
-
};
|
|
874
|
-
}
|
|
875
|
-
function serializeClip(c) {
|
|
876
|
-
const track = tracks.find((t) => t.id === c.trackId);
|
|
877
|
-
return {
|
|
878
|
-
id: c.id,
|
|
879
|
-
name: c.name,
|
|
880
|
-
trackId: c.trackId,
|
|
881
|
-
trackName: track?.name ?? null,
|
|
882
|
-
mediaType: c.mediaType,
|
|
883
|
-
sourceType: c.sourceType,
|
|
884
|
-
startMs: c.startMs,
|
|
885
|
-
durationMs: c.durationMs,
|
|
886
|
-
endMs: c.startMs + c.durationMs,
|
|
887
|
-
inPointMs: c.inPointMs,
|
|
888
|
-
outPointMs: c.outPointMs,
|
|
889
|
-
status: c.status,
|
|
890
|
-
currentAssetId: c.currentAssetId,
|
|
891
|
-
activeTakeId: activeTakeIdOf(c),
|
|
892
|
-
takeCount: c.versions?.length ?? 0,
|
|
893
|
-
prompt: c.prompt,
|
|
894
|
-
provider: c.provider,
|
|
895
|
-
model: c.model,
|
|
896
|
-
voice: c.voice,
|
|
897
|
-
animations: (c.animations ?? []).map((a) => ({
|
|
898
|
-
role: a.role,
|
|
899
|
-
preset: a.preset
|
|
900
|
-
})),
|
|
901
|
-
hidden: c.hidden ?? false,
|
|
902
|
-
muted: c.muted ?? false,
|
|
903
|
-
locked: c.locked,
|
|
904
|
-
opacity: c.opacity,
|
|
905
|
-
transform: c.transform,
|
|
906
|
-
textStyle: c.textStyle,
|
|
907
|
-
shapeStyle: c.shapeStyle,
|
|
908
|
-
captionStyle: c.caption?.style,
|
|
909
|
-
transitionIn: c.transitionIn,
|
|
910
|
-
mask: c.mask,
|
|
911
|
-
matte: c.matte,
|
|
912
|
-
timeRemap: c.timeRemap,
|
|
913
|
-
effects: c.effects,
|
|
914
|
-
parentId: c.parentId,
|
|
915
|
-
reframe: c.reframe
|
|
916
|
-
? {
|
|
917
|
-
mode: c.reframe.mode,
|
|
918
|
-
trackId: c.reframe.trackId,
|
|
919
|
-
safeMargin: c.reframe.safeMargin,
|
|
920
|
-
smoothing: c.reframe.smoothing,
|
|
921
|
-
sampleCount: c.reframe.samples?.length ?? 0,
|
|
922
|
-
keyframeCount: c.reframe.keyframes?.length ?? 0
|
|
923
|
-
}
|
|
924
|
-
: undefined,
|
|
925
|
-
// The notes themselves are the clip's bulk — a phrase is hundreds of
|
|
926
|
-
// them — so state reports how many there are and how many the window
|
|
927
|
-
// actually plays. `set_notes` sends the list; nothing reads it back.
|
|
928
|
-
noteCount: c.notes?.length,
|
|
929
|
-
audibleNoteCount: c.mediaType === "midi"
|
|
930
|
-
? visibleNotes(c, resolveTempo({ tempo }).bpm).length
|
|
931
|
-
: undefined,
|
|
932
|
-
// Where the phrase starts in musical time, so placing the next one is
|
|
933
|
-
// reading a bar number rather than dividing milliseconds by the tempo.
|
|
934
|
-
startBarsBeats: c.mediaType === "midi"
|
|
935
|
-
? formatBarsBeats(c.startMs, resolveTempo({ tempo }))
|
|
936
|
-
: undefined
|
|
937
|
-
};
|
|
938
|
-
}
|
|
939
|
-
// Seed from a real sequence when one was handed over, otherwise from the
|
|
940
|
-
// shorthand. Cloned, so the bridge never writes through to the caller's state.
|
|
941
|
-
if (seed) {
|
|
942
|
-
for (const track of seed.tracks) {
|
|
943
|
-
const copy = structuredClone(track);
|
|
944
|
-
usedIds.add(copy.id);
|
|
945
|
-
tracks.push(copy);
|
|
946
|
-
}
|
|
947
|
-
for (const clip of seed.clips) {
|
|
948
|
-
const copy = structuredClone(clip);
|
|
949
|
-
usedIds.add(copy.id);
|
|
950
|
-
for (const animation of copy.animations ?? [])
|
|
951
|
-
usedIds.add(animation.id);
|
|
952
|
-
clips.push(copy);
|
|
953
|
-
}
|
|
954
|
-
for (const marker of seed.markers ?? []) {
|
|
955
|
-
const copy = structuredClone(marker);
|
|
956
|
-
usedIds.add(copy.id);
|
|
957
|
-
markers.push(copy);
|
|
958
|
-
}
|
|
959
|
-
for (const mediaTrack of seed.mediaTracks ?? []) {
|
|
960
|
-
const copy = structuredClone(mediaTrack);
|
|
961
|
-
usedIds.add(copy.id);
|
|
962
|
-
mediaTracks.push(copy);
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
// Seed initial tracks and clips.
|
|
966
|
-
for (const t of seed ? [] : (initial.tracks ?? [])) {
|
|
967
|
-
addTrackInternal(t.type, t.name);
|
|
968
|
-
}
|
|
969
|
-
for (const c of seed ? [] : (initial.clips ?? [])) {
|
|
970
|
-
const track = tracks[c.trackIndex];
|
|
971
|
-
if (!track) {
|
|
972
|
-
throw new Error(`Initial clip "${c.name}" references trackIndex ${c.trackIndex}, but only ${tracks.length} track(s) exist.`);
|
|
973
|
-
}
|
|
974
|
-
clips.push(makeClip({
|
|
975
|
-
id: nextClipId(),
|
|
976
|
-
trackId: track.id,
|
|
977
|
-
name: c.name,
|
|
978
|
-
startMs: c.startMs,
|
|
979
|
-
durationMs: c.durationMs,
|
|
980
|
-
mediaType: c.mediaType ?? "video",
|
|
981
|
-
sourceType: "imported",
|
|
982
|
-
status: "generated"
|
|
983
|
-
}));
|
|
984
|
-
}
|
|
985
|
-
/** Resolve a beat by id, or by its 1-based position in the plan. */
|
|
986
|
-
function resolveBeat(target) {
|
|
987
|
-
const beats = setup?.beats ?? [];
|
|
988
|
-
const byId = beats.find((beat) => beat.id === target);
|
|
989
|
-
if (byId)
|
|
990
|
-
return byId;
|
|
991
|
-
const position = Number.parseInt(target, 10);
|
|
992
|
-
const byPosition = beats[position - 1];
|
|
993
|
-
if (Number.isFinite(position) && byPosition)
|
|
994
|
-
return byPosition;
|
|
995
|
-
throw new Error(`No beat matches "${target}". Use a beat id or its 1-based position. ` +
|
|
996
|
-
(beats.length > 0
|
|
997
|
-
? `This plan has ${beats.length} beats.`
|
|
998
|
-
: "This sequence has no beat plan yet; run ui_timeline_plan_beats first."));
|
|
999
|
-
}
|
|
1000
|
-
const tools = [
|
|
1001
|
-
sharedTool("ui_timeline_get_state", async () => {
|
|
1002
|
-
const durationMs = clips.reduce((m, c) => Math.max(m, c.startMs + c.durationMs), 0);
|
|
1003
|
-
return {
|
|
1004
|
-
ok: true,
|
|
1005
|
-
sequenceId,
|
|
1006
|
-
fps,
|
|
1007
|
-
width,
|
|
1008
|
-
height,
|
|
1009
|
-
durationMs,
|
|
1010
|
-
playheadMs,
|
|
1011
|
-
selectedClipIds: [...selectedClipIds],
|
|
1012
|
-
// Absent when the document carries none: a reader that needs a
|
|
1013
|
-
// number reads DEFAULT_TEMPO, and saying so beats printing a tempo
|
|
1014
|
-
// the document does not store.
|
|
1015
|
-
tempo,
|
|
1016
|
-
tracks: tracks.map(serializeTrack),
|
|
1017
|
-
clips: clips.map(serializeClip),
|
|
1018
|
-
mediaTracks: mediaTracks.map((track) => ({
|
|
1019
|
-
id: track.id,
|
|
1020
|
-
clipId: track.clipId,
|
|
1021
|
-
sourceAssetId: track.sourceAssetId,
|
|
1022
|
-
name: track.name,
|
|
1023
|
-
kind: track.kind,
|
|
1024
|
-
sourceStartMs: track.sourceStartMs,
|
|
1025
|
-
sourceEndMs: track.sourceEndMs,
|
|
1026
|
-
sampleCount: track.samples.length,
|
|
1027
|
-
confidence: track.confidence,
|
|
1028
|
-
status: track.status,
|
|
1029
|
-
provenance: track.provenance
|
|
1030
|
-
})),
|
|
1031
|
-
markers: markers.map((m) => ({ ...m }))
|
|
1032
|
-
};
|
|
1033
|
-
}),
|
|
1034
|
-
sharedTool("ui_timeline_add_track", async ({ type, name }) => {
|
|
1035
|
-
const track = addTrackInternal(type, name);
|
|
1036
|
-
return { ok: true, track: serializeTrack(track) };
|
|
1037
|
-
}),
|
|
1038
|
-
sharedTool("ui_timeline_move_track", async (args) => {
|
|
1039
|
-
const { target, toIndex, before, after } = resolveMoveTrackArgs(args);
|
|
1040
|
-
const track = resolveTrack(target);
|
|
1041
|
-
const destination = {};
|
|
1042
|
-
if (toIndex !== undefined)
|
|
1043
|
-
destination.toIndex = toIndex;
|
|
1044
|
-
if (before !== undefined) {
|
|
1045
|
-
destination.beforeId = resolveTrack(before).id;
|
|
1046
|
-
}
|
|
1047
|
-
if (after !== undefined) {
|
|
1048
|
-
destination.afterId = resolveTrack(after).id;
|
|
1049
|
-
}
|
|
1050
|
-
const orderedIds = moveTrackOrder(tracks, track.id, destination);
|
|
1051
|
-
const byId = new Map(tracks.map((t) => [t.id, t]));
|
|
1052
|
-
// The array order is what `get_state` prints, so keep it and the
|
|
1053
|
-
// indices saying the same thing.
|
|
1054
|
-
tracks.length = 0;
|
|
1055
|
-
orderedIds.forEach((id, i) => {
|
|
1056
|
-
const moved = byId.get(id);
|
|
1057
|
-
moved.index = i;
|
|
1058
|
-
tracks.push(moved);
|
|
1059
|
-
});
|
|
1060
|
-
return {
|
|
1061
|
-
ok: true,
|
|
1062
|
-
track: serializeTrack(track),
|
|
1063
|
-
tracks: tracks.map(serializeTrack)
|
|
1064
|
-
};
|
|
1065
|
-
}),
|
|
1066
|
-
sharedTool("ui_timeline_add_text_clip", async ({ text, name, transform, trackId, startMs, durationMs, opacity, style, ...loose }) => {
|
|
1067
|
-
const track = trackId
|
|
1068
|
-
? resolveTrack(trackId)
|
|
1069
|
-
: findOrCreateTrack("overlay");
|
|
1070
|
-
// `style` wins over a top-level twin: a caller that sent both meant the
|
|
1071
|
-
// bag it named.
|
|
1072
|
-
const s = {
|
|
1073
|
-
...loose,
|
|
1074
|
-
...(style ??
|
|
1075
|
-
{})
|
|
1076
|
-
};
|
|
1077
|
-
const clip = makeClip({
|
|
1078
|
-
id: nextClipId(),
|
|
1079
|
-
trackId: track.id,
|
|
1080
|
-
name: name ?? text,
|
|
1081
|
-
startMs: startMs ?? trackEndMs(track.id),
|
|
1082
|
-
durationMs: durationMs ?? DEFAULT_TEXT_CLIP_DURATION_MS,
|
|
1083
|
-
mediaType: "text",
|
|
1084
|
-
sourceType: "imported",
|
|
1085
|
-
status: "generated",
|
|
1086
|
-
textStyle: textStyleWithDefaults(text, s)
|
|
1087
|
-
});
|
|
1088
|
-
if (opacity !== undefined)
|
|
1089
|
-
clip.opacity = opacity;
|
|
1090
|
-
if (transform)
|
|
1091
|
-
clip.transform = mergeClipTransform(clip.transform, transform);
|
|
1092
|
-
clips.push(clip);
|
|
1093
|
-
selectedClipIds = [clip.id];
|
|
1094
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
1095
|
-
}),
|
|
1096
|
-
sharedTool("ui_timeline_delete_track", async (args) => {
|
|
1097
|
-
const { target, deleteClips } = resolveDeleteTrackArgs(args);
|
|
1098
|
-
const track = resolveTrack(target);
|
|
1099
|
-
const onIt = clips.filter((c) => c.trackId === track.id);
|
|
1100
|
-
if (onIt.length > 0 && !deleteClips) {
|
|
1101
|
-
throw new Error(`Track "${track.name}" still holds ${onIt.length} clip(s): ` +
|
|
1102
|
-
`${onIt.map((c) => c.id).join(", ")}. Move them first, or pass ` +
|
|
1103
|
-
"deleteClips: true to delete them with the track.");
|
|
1104
|
-
}
|
|
1105
|
-
const removedClipIds = onIt.map((c) => c.id);
|
|
1106
|
-
const kept = clips.filter((c) => c.trackId !== track.id);
|
|
1107
|
-
clips.length = 0;
|
|
1108
|
-
clips.push(...kept);
|
|
1109
|
-
// A parent that went with the track would leave its children pointing
|
|
1110
|
-
// at a clip that no longer exists, which the validator reads as a
|
|
1111
|
-
// broken document rather than a deletion.
|
|
1112
|
-
for (const clip of clips) {
|
|
1113
|
-
if (clip.parentId && removedClipIds.includes(clip.parentId)) {
|
|
1114
|
-
delete clip.parentId;
|
|
1115
|
-
}
|
|
1116
|
-
}
|
|
1117
|
-
selectedClipIds = selectedClipIds.filter((id) => !removedClipIds.includes(id));
|
|
1118
|
-
const remaining = tracks.filter((t) => t.id !== track.id);
|
|
1119
|
-
tracks.length = 0;
|
|
1120
|
-
// Index is z-order, so the stack has to close over the gap.
|
|
1121
|
-
remaining.forEach((t, i) => {
|
|
1122
|
-
t.index = i;
|
|
1123
|
-
tracks.push(t);
|
|
1124
|
-
});
|
|
1125
|
-
return {
|
|
1126
|
-
ok: true,
|
|
1127
|
-
deleted: { id: track.id, name: track.name, type: track.type },
|
|
1128
|
-
deletedClipIds: removedClipIds,
|
|
1129
|
-
tracks: tracks.map(serializeTrack)
|
|
1130
|
-
};
|
|
1131
|
-
}),
|
|
1132
|
-
sharedTool("ui_timeline_add_media_clip", async ({ asset, trackId, startMs, durationMs, name, transform }) => {
|
|
1133
|
-
if (!resolveAsset) {
|
|
1134
|
-
throw new Error("This timeline surface cannot look up assets, so an existing asset cannot be placed here.");
|
|
1135
|
-
}
|
|
1136
|
-
const ref = asset;
|
|
1137
|
-
const found = await resolveAsset(ref);
|
|
1138
|
-
if (!found) {
|
|
1139
|
-
throw new Error(`No asset found for "${ref}". Pass an asset id or an asset:// URI from list_assets.`);
|
|
1140
|
-
}
|
|
1141
|
-
const mediaType = mediaTypeForContentType(found.contentType);
|
|
1142
|
-
if (!mediaType) {
|
|
1143
|
-
throw new Error(`Asset "${found.name}" is ${found.contentType}, which is not video, image, or audio and cannot go on a timeline.`);
|
|
1144
|
-
}
|
|
1145
|
-
if (mediaType !== "image" && durationMs === undefined && !found.durationMs) {
|
|
1146
|
-
throw new Error(`Asset "${found.name}" has no known duration. Supply durationMs or import media that can be probed.`);
|
|
1147
|
-
}
|
|
1148
|
-
const track = trackId
|
|
1149
|
-
? resolveTrack(trackId)
|
|
1150
|
-
: findOrCreateTrack(trackTypeForMediaType(mediaType));
|
|
1151
|
-
const init = {
|
|
1152
|
-
id: nextClipId(),
|
|
1153
|
-
trackId: track.id,
|
|
1154
|
-
name: name ?? found.name,
|
|
1155
|
-
startMs: startMs ?? trackEndMs(track.id),
|
|
1156
|
-
durationMs: durationMs ??
|
|
1157
|
-
found.durationMs ??
|
|
1158
|
-
DEFAULT_MEDIA_CLIP_DURATION_MS,
|
|
1159
|
-
mediaType,
|
|
1160
|
-
sourceType: "imported",
|
|
1161
|
-
status: "generated",
|
|
1162
|
-
currentAssetId: found.id
|
|
1163
|
-
};
|
|
1164
|
-
if (found.thumbnailAssetId) {
|
|
1165
|
-
init.thumbnailAssetId = found.thumbnailAssetId;
|
|
1166
|
-
}
|
|
1167
|
-
const clip = makeClip(init);
|
|
1168
|
-
if (transform)
|
|
1169
|
-
clip.transform = mergeClipTransform(clip.transform, transform);
|
|
1170
|
-
clips.push(clip);
|
|
1171
|
-
selectedClipIds = [clip.id];
|
|
1172
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
1173
|
-
}),
|
|
1174
|
-
sharedTool("ui_timeline_add_shape_clip", async ({ shape, shapeStyle, name, transform, trackId, startMs, durationMs, opacity, ...loose }) => {
|
|
1175
|
-
const track = trackId
|
|
1176
|
-
? resolveTrack(trackId)
|
|
1177
|
-
: findOrCreateTrack("overlay");
|
|
1178
|
-
const shapeArg = resolveShapeArg(shape, shapeStyle, loose);
|
|
1179
|
-
const clip = makeClip({
|
|
1180
|
-
id: nextClipId(),
|
|
1181
|
-
trackId: track.id,
|
|
1182
|
-
name: name ?? capitalize(shapeArg.kind),
|
|
1183
|
-
startMs: startMs ?? trackEndMs(track.id),
|
|
1184
|
-
durationMs: durationMs ?? DEFAULT_TEXT_CLIP_DURATION_MS,
|
|
1185
|
-
mediaType: "shape",
|
|
1186
|
-
sourceType: "imported",
|
|
1187
|
-
status: "generated",
|
|
1188
|
-
shapeStyle: shapeStyleWithDefaults(shapeArg)
|
|
1189
|
-
});
|
|
1190
|
-
if (opacity !== undefined)
|
|
1191
|
-
clip.opacity = opacity;
|
|
1192
|
-
if (transform)
|
|
1193
|
-
clip.transform = mergeClipTransform(clip.transform, transform);
|
|
1194
|
-
clips.push(clip);
|
|
1195
|
-
selectedClipIds = [clip.id];
|
|
1196
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
1197
|
-
}),
|
|
1198
|
-
sharedTool("ui_timeline_add_model3d_clip", async ({ assetId, trackId, startMs, durationMs, style, transform }) => {
|
|
1199
|
-
// 3D is picture (D1), so it lands where a title lands.
|
|
1200
|
-
const track = trackId
|
|
1201
|
-
? resolveTrack(trackId)
|
|
1202
|
-
: findOrCreateTrack("overlay");
|
|
1203
|
-
const clip = makeClip({
|
|
1204
|
-
id: nextClipId(),
|
|
1205
|
-
trackId: track.id,
|
|
1206
|
-
name: DEFAULT_MODEL3D_CLIP_NAME,
|
|
1207
|
-
startMs: startMs ?? trackEndMs(track.id),
|
|
1208
|
-
durationMs: durationMs ??
|
|
1209
|
-
DEFAULT_MODEL3D_CLIP_DURATION_MS,
|
|
1210
|
-
mediaType: "model3d",
|
|
1211
|
-
sourceType: "imported",
|
|
1212
|
-
status: "generated",
|
|
1213
|
-
currentAssetId: assetId.trim(),
|
|
1214
|
-
model3dStyle: model3dStyleWithPatch(undefined, style)
|
|
1215
|
-
});
|
|
1216
|
-
if (transform)
|
|
1217
|
-
clip.transform = mergeClipTransform(clip.transform, transform);
|
|
1218
|
-
clips.push(clip);
|
|
1219
|
-
selectedClipIds = [clip.id];
|
|
1220
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
1221
|
-
}),
|
|
1222
|
-
sharedTool("ui_timeline_set_model3d_style", async ({ target, patch }) => {
|
|
1223
|
-
const clip = resolveClip(target);
|
|
1224
|
-
if (clip.mediaType !== "model3d") {
|
|
1225
|
-
throw new Error(`Clip "${clip.name}" is a ${clip.mediaType} clip, not a 3D clip — ` +
|
|
1226
|
-
"model3dStyle names a camera, an animation and lighting for a " +
|
|
1227
|
-
"glTF, and nothing else reads it.");
|
|
1228
|
-
}
|
|
1229
|
-
clip.model3dStyle = model3dStyleWithPatch(clip.model3dStyle, patch);
|
|
1230
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
1231
|
-
}),
|
|
1232
|
-
sharedTool("ui_timeline_bake_model3d_clip", async ({ target }) => {
|
|
1233
|
-
const clip = resolveClip(target);
|
|
1234
|
-
if (clip.mediaType !== "model3d") {
|
|
1235
|
-
throw new Error(`Clip "${clip.name}" is a ${clip.mediaType} clip, not a 3D clip — ` +
|
|
1236
|
-
"only a 3D clip has a camera, lighting and a glTF to render.");
|
|
1237
|
-
}
|
|
1238
|
-
const style = clip.model3dStyle;
|
|
1239
|
-
if (!style) {
|
|
1240
|
-
throw new Error(`Clip "${clip.name}" has no model3dStyle, so nothing names the ` +
|
|
1241
|
-
"camera, lighting or animation a bake would render.");
|
|
1242
|
-
}
|
|
1243
|
-
if (!clip.currentAssetId) {
|
|
1244
|
-
throw new Error(`Clip "${clip.name}" has no glTF asset to bake — a 3D clip draws ` +
|
|
1245
|
-
"its asset the way an image clip draws its image.");
|
|
1246
|
-
}
|
|
1247
|
-
const sequence = { fps, width, height };
|
|
1248
|
-
const dependencyHash = computeModel3DBakeHash(clip, sequence);
|
|
1249
|
-
if (!bakeModel3DClip) {
|
|
1250
|
-
return {
|
|
1251
|
-
ok: true,
|
|
1252
|
-
clip: serializeClip(clip),
|
|
1253
|
-
bakeStarted: false,
|
|
1254
|
-
note: "This surface has no Blender renderer, so nothing was baked."
|
|
1255
|
-
};
|
|
1256
|
-
}
|
|
1257
|
-
const baked = await bakeModel3DClip({ clip, sequence, dependencyHash });
|
|
1258
|
-
clip.model3dStyle = {
|
|
1259
|
-
...style,
|
|
1260
|
-
bake: { assetId: baked.assetId, dependencyHash }
|
|
1261
|
-
};
|
|
1262
|
-
const now = new Date().toISOString();
|
|
1263
|
-
clip.versions = [
|
|
1264
|
-
...(clip.versions ?? []),
|
|
1265
|
-
makeClipVersion({
|
|
1266
|
-
id: nextVersionId(),
|
|
1267
|
-
createdAt: now,
|
|
1268
|
-
workflowUpdatedAt: now,
|
|
1269
|
-
jobId: baked.jobId ?? "",
|
|
1270
|
-
assetId: baked.assetId,
|
|
1271
|
-
dependencyHash
|
|
1272
|
-
})
|
|
1273
|
-
];
|
|
1274
|
-
return { ok: true, clip: serializeClip(clip), bakeStarted: true };
|
|
1275
|
-
}),
|
|
1276
|
-
sharedTool("ui_timeline_generate_clip", async ({ kind, prompt, trackId, startMs, durationMs, provider, model, voice, width: clipWidth, height: clipHeight, aspectRatio, resolution, autoGenerate }) => {
|
|
1277
|
-
const mediaType = kind === "text-to-video"
|
|
1278
|
-
? "video"
|
|
1279
|
-
: kind === "text-to-image"
|
|
1280
|
-
? "image"
|
|
1281
|
-
: "audio";
|
|
1282
|
-
const track = trackId
|
|
1283
|
-
? resolveTrack(trackId)
|
|
1284
|
-
: kind === "text-to-audio"
|
|
1285
|
-
? findOrCreateTrack("audio")
|
|
1286
|
-
: kind === "text-to-video"
|
|
1287
|
-
? findOrCreateTrack("video")
|
|
1288
|
-
: (tracks.find((t) => t.type === "video" || t.type === "overlay") ??
|
|
1289
|
-
findOrCreateTrack("video"));
|
|
1290
|
-
const generationStarted = autoGenerate !== false;
|
|
1291
|
-
const clip = makeClip({
|
|
1292
|
-
id: nextClipId(),
|
|
1293
|
-
trackId: track.id,
|
|
1294
|
-
name: prompt,
|
|
1295
|
-
startMs: startMs ?? trackEndMs(track.id),
|
|
1296
|
-
durationMs: durationMs ??
|
|
1297
|
-
(kind === "text-to-audio" ? 3000 : 5000),
|
|
1298
|
-
mediaType,
|
|
1299
|
-
sourceType: "generated",
|
|
1300
|
-
bindingKind: kind,
|
|
1301
|
-
status: generationStarted ? "generating" : "draft",
|
|
1302
|
-
prompt: prompt,
|
|
1303
|
-
provider: provider,
|
|
1304
|
-
model: model,
|
|
1305
|
-
voice: voice,
|
|
1306
|
-
width: clipWidth,
|
|
1307
|
-
height: clipHeight,
|
|
1308
|
-
aspectRatio: aspectRatio,
|
|
1309
|
-
resolution: resolution
|
|
1310
|
-
});
|
|
1311
|
-
clips.push(clip);
|
|
1312
|
-
selectedClipIds = [clip.id];
|
|
1313
|
-
const result = { ok: true, clip: serializeClip(clip), generationStarted };
|
|
1314
|
-
if (!generationStarted) {
|
|
1315
|
-
result.note = "Generation not started (autoGenerate=false).";
|
|
1316
|
-
}
|
|
1317
|
-
return result;
|
|
1318
|
-
}),
|
|
1319
|
-
sharedTool("ui_timeline_generatively_edit_clip", async ({ clip_id, instruction, provider, model }) => {
|
|
1320
|
-
// The P0 request captures a durable destination. Unlike general
|
|
1321
|
-
// timeline actions, this intentionally refuses names and selection.
|
|
1322
|
-
const clip = clips.find((candidate) => candidate.id === clip_id);
|
|
1323
|
-
if (!clip) {
|
|
1324
|
-
throw new Error(`No clip with id "${clip_id}" exists on this timeline. Call ui_timeline_get_state and pass the clip id.`);
|
|
1325
|
-
}
|
|
1326
|
-
const baseline = ensureBaselineTake(clip);
|
|
1327
|
-
if (baseline !== clip) {
|
|
1328
|
-
clips = clips.map((candidate) => candidate.id === clip.id ? baseline : candidate);
|
|
1329
|
-
}
|
|
1330
|
-
const source = captureMediaEditSourceContext(sequenceId, baseline);
|
|
1331
|
-
if (!source.ok)
|
|
1332
|
-
throw new Error(source.error);
|
|
1333
|
-
// Eval hosts have no selected model catalog. These deterministic
|
|
1334
|
-
// placeholders only stand in for a real provider's returned asset;
|
|
1335
|
-
// browser and server execution keep their normal model validation.
|
|
1336
|
-
const resolvedProvider = provider ?? "headless";
|
|
1337
|
-
const resolvedModel = model ?? "headless-video-to-video";
|
|
1338
|
-
const request = createMediaEditRequest({
|
|
1339
|
-
sourceContext: source.context,
|
|
1340
|
-
instruction: instruction,
|
|
1341
|
-
provider: resolvedProvider,
|
|
1342
|
-
model: resolvedModel
|
|
1343
|
-
});
|
|
1344
|
-
const generationId = nextVersionId();
|
|
1345
|
-
const now = new Date().toISOString();
|
|
1346
|
-
const next = composeGenerativeTakePatch(baseline, "restyle", {
|
|
1347
|
-
assetId: `generative://${baseline.id}/${generationId}`,
|
|
1348
|
-
jobId: generationId,
|
|
1349
|
-
createdAt: now,
|
|
1350
|
-
workflowUpdatedAt: now,
|
|
1351
|
-
provider: request.provider,
|
|
1352
|
-
model: request.model,
|
|
1353
|
-
prompt: request.instruction,
|
|
1354
|
-
durationMs: request.sourceContext.timelineDurationMs,
|
|
1355
|
-
mediaEdit: request
|
|
1356
|
-
});
|
|
1357
|
-
clips = clips.map((candidate) => candidate.id === baseline.id ? next : candidate);
|
|
1358
|
-
const take = next.versions?.find((candidate) => candidate.id === generationId);
|
|
1359
|
-
if (!take) {
|
|
1360
|
-
throw new Error("The AI edit candidate could not be recorded.");
|
|
1361
|
-
}
|
|
1362
|
-
return {
|
|
1363
|
-
ok: true,
|
|
1364
|
-
requestId: generationId,
|
|
1365
|
-
generationId,
|
|
1366
|
-
activeTakeId: activeTakeIdOf(next) ?? null,
|
|
1367
|
-
candidate: {
|
|
1368
|
-
id: take.id,
|
|
1369
|
-
status: take.status,
|
|
1370
|
-
source: take.source
|
|
1371
|
-
},
|
|
1372
|
-
clip: serializeClip(next)
|
|
1373
|
-
};
|
|
1374
|
-
}),
|
|
1375
|
-
sharedTool("ui_timeline_apply_take", async ({ clip_id, take_id }) => {
|
|
1376
|
-
const clip = clips.find((candidate) => candidate.id === clip_id);
|
|
1377
|
-
if (!clip) {
|
|
1378
|
-
throw new Error(`No clip with id "${clip_id}" exists on this timeline. Call ui_timeline_get_state and pass the clip id.`);
|
|
1379
|
-
}
|
|
1380
|
-
const take = (clip.versions ?? []).find((candidate) => candidate.id === take_id);
|
|
1381
|
-
if (!take?.mediaEdit) {
|
|
1382
|
-
throw new Error(`Take "${take_id}" is not an AI edit candidate for "${clip.name}". Apply only the candidate returned by ui_timeline_generatively_edit_clip.`);
|
|
1383
|
-
}
|
|
1384
|
-
const result = applyTakeToClip(clip, take_id);
|
|
1385
|
-
if (result.error)
|
|
1386
|
-
throw new Error(result.error);
|
|
1387
|
-
clips = clips.map((candidate) => candidate.id === clip.id ? result.clip : candidate);
|
|
1388
|
-
return { ok: true, clip: serializeClip(result.clip) };
|
|
1389
|
-
}),
|
|
1390
|
-
sharedTool("ui_timeline_split_clip", async ({ target, atMs }) => {
|
|
1391
|
-
const clip = resolveClip(target);
|
|
1392
|
-
const at = atMs ?? playheadMs;
|
|
1393
|
-
const [left, right] = splitClip(clip, at);
|
|
1394
|
-
left.id = nextClipId();
|
|
1395
|
-
right.id = nextClipId();
|
|
1396
|
-
const idx = clips.findIndex((c) => c.id === clip.id);
|
|
1397
|
-
clips.splice(idx, 1, left, right);
|
|
1398
|
-
selectedClipIds = selectedClipIds.filter((id) => id !== clip.id);
|
|
1399
|
-
mediaTracks = resliceTracksForSplitClip(mediaTracks, clip.id, left, right, nextTrackId);
|
|
1400
|
-
return { ok: true, clips: [serializeClip(left), serializeClip(right)] };
|
|
1401
|
-
}),
|
|
1402
|
-
sharedTool("ui_timeline_trim_clip", async ({ target, durationMs, inPointMs, outPointMs }) => {
|
|
1403
|
-
const trimmed = applyTrim(resolveClip(target), {
|
|
1404
|
-
durationMs: durationMs,
|
|
1405
|
-
inPointMs: inPointMs,
|
|
1406
|
-
outPointMs: outPointMs
|
|
1407
|
-
});
|
|
1408
|
-
mediaTracks = resliceTracksForTrimmedClip(mediaTracks, trimmed);
|
|
1409
|
-
return { ok: true, clip: serializeClip(trimmed) };
|
|
1410
|
-
}),
|
|
1411
|
-
sharedTool("ui_timeline_move_clip", async ({ target, startMs, trackId }) => {
|
|
1412
|
-
const moved = applyMove(resolveClip(target), {
|
|
1413
|
-
startMs: startMs,
|
|
1414
|
-
trackId: trackId
|
|
1415
|
-
});
|
|
1416
|
-
return { ok: true, clip: serializeClip(moved) };
|
|
1417
|
-
}),
|
|
1418
|
-
sharedTool("ui_timeline_delete_clip", async ({ target }) => {
|
|
1419
|
-
const clip = resolveClip(target);
|
|
1420
|
-
// Deleting a group deletes the parent, not the picture: its children
|
|
1421
|
-
// stay where they are and stop inheriting (D4). Leaving them with a
|
|
1422
|
-
// `parentId` nothing answers is what the validator calls a dangling
|
|
1423
|
-
// parent.
|
|
1424
|
-
const remaining = isGroupClip(clip) ? ungroup(clips, clip.id) : clips;
|
|
1425
|
-
clips = remaining.filter((c) => c.id !== clip.id);
|
|
1426
|
-
selectedClipIds = selectedClipIds.filter((id) => id !== clip.id);
|
|
1427
|
-
return { ok: true, deleted: serializeClip(clip) };
|
|
1428
|
-
}),
|
|
1429
|
-
sharedTool("ui_timeline_duplicate_clip", async ({ target, gapMs }) => {
|
|
1430
|
-
const src = resolveClip(target);
|
|
1431
|
-
const copy = {
|
|
1432
|
-
...src,
|
|
1433
|
-
id: nextClipId(),
|
|
1434
|
-
startMs: src.startMs + src.durationMs + (gapMs ?? 0),
|
|
1435
|
-
versions: [],
|
|
1436
|
-
animations: src.animations?.map((a) => ({ ...a, id: nextAnimId() }))
|
|
1437
|
-
};
|
|
1438
|
-
clips.push(copy);
|
|
1439
|
-
selectedClipIds = [copy.id];
|
|
1440
|
-
return { ok: true, clip: serializeClip(copy) };
|
|
1441
|
-
}),
|
|
1442
|
-
sharedTool("ui_timeline_set_clip_params", async ({ target, ...wrapped }) => {
|
|
1443
|
-
const patch = unwrapClipParams(wrapped);
|
|
1444
|
-
let clip = resolveClip(target);
|
|
1445
|
-
rejectUnknownClipParams(patch);
|
|
1446
|
-
// Timing belongs to move_clip and trim_clip, but a caller sending it
|
|
1447
|
-
// here means one edit either way — so apply it through the same code
|
|
1448
|
-
// rather than dropping it or making them call twice.
|
|
1449
|
-
clip = applyTrim(clip, {
|
|
1450
|
-
durationMs: patch.durationMs,
|
|
1451
|
-
inPointMs: patch.inPointMs,
|
|
1452
|
-
outPointMs: patch.outPointMs
|
|
1453
|
-
});
|
|
1454
|
-
clip = applyMove(clip, {
|
|
1455
|
-
startMs: patch.startMs,
|
|
1456
|
-
trackId: patch.trackId
|
|
1457
|
-
});
|
|
1458
|
-
// The style is a patch over what the clip already carries: changing one
|
|
1459
|
-
// field used to mean re-sending the whole bag, and re-sending it is how
|
|
1460
|
-
// the four fields the caller did not mean to touch get overwritten.
|
|
1461
|
-
if (patch.textStyle !== undefined) {
|
|
1462
|
-
patch.textStyle = mergeTextStyle(clip, patch.textStyle);
|
|
1463
|
-
}
|
|
1464
|
-
if (patch.fontSizePx !== undefined) {
|
|
1465
|
-
// Shorthand for the one text field callers reach for by name.
|
|
1466
|
-
const size = patch.fontSizePx;
|
|
1467
|
-
const style = (patch.textStyle ?? clip.textStyle);
|
|
1468
|
-
if (!style) {
|
|
1469
|
-
throw new Error(`Clip "${clip.name}" carries no text to size; fontSizePx applies to a text clip's textStyle.`);
|
|
1470
|
-
}
|
|
1471
|
-
patch.textStyle = { ...style, fontSizePx: size };
|
|
1472
|
-
}
|
|
1473
|
-
if (patch.name !== undefined)
|
|
1474
|
-
clip.name = patch.name;
|
|
1475
|
-
if (patch.transform !== undefined)
|
|
1476
|
-
clip.transform = mergeClipTransform(clip.transform, patch.transform);
|
|
1477
|
-
if (patch.opacity !== undefined)
|
|
1478
|
-
clip.opacity = patch.opacity;
|
|
1479
|
-
if (patch.speedMultiplier !== undefined)
|
|
1480
|
-
clip.speedMultiplier = patch.speedMultiplier;
|
|
1481
|
-
if (patch.volumeDb !== undefined)
|
|
1482
|
-
clip.volumeDb = patch.volumeDb;
|
|
1483
|
-
if (patch.fadeInMs !== undefined)
|
|
1484
|
-
clip.fadeInMs = patch.fadeInMs;
|
|
1485
|
-
if (patch.fadeOutMs !== undefined)
|
|
1486
|
-
clip.fadeOutMs = patch.fadeOutMs;
|
|
1487
|
-
if (patch.fadeInShape !== undefined)
|
|
1488
|
-
clip.fadeInShape = patch.fadeInShape;
|
|
1489
|
-
if (patch.fadeOutShape !== undefined)
|
|
1490
|
-
clip.fadeOutShape = patch.fadeOutShape;
|
|
1491
|
-
if (patch.blendMode !== undefined)
|
|
1492
|
-
clip.blendMode = patch.blendMode;
|
|
1493
|
-
if (patch.borderRadius !== undefined)
|
|
1494
|
-
clip.borderRadius = patch.borderRadius;
|
|
1495
|
-
if (patch.hidden !== undefined)
|
|
1496
|
-
clip.hidden = patch.hidden;
|
|
1497
|
-
if (patch.muted !== undefined)
|
|
1498
|
-
clip.muted = patch.muted;
|
|
1499
|
-
if (patch.locked !== undefined)
|
|
1500
|
-
clip.locked = patch.locked;
|
|
1501
|
-
if (patch.textStyle !== undefined)
|
|
1502
|
-
clip.textStyle = patch.textStyle;
|
|
1503
|
-
if (patch.shapeStyle !== undefined)
|
|
1504
|
-
clip.shapeStyle = patch.shapeStyle;
|
|
1505
|
-
if (patch.captionStyle !== undefined) {
|
|
1506
|
-
// The style rides on the clip's caption, so a clip with no words to
|
|
1507
|
-
// draw has nowhere to put it. Say so rather than storing a look
|
|
1508
|
-
// nothing renders.
|
|
1509
|
-
if (!clip.caption) {
|
|
1510
|
-
throw new Error(`Clip "${clip.name}" carries no caption to style.`);
|
|
1511
|
-
}
|
|
1512
|
-
clip.caption = {
|
|
1513
|
-
...clip.caption,
|
|
1514
|
-
style: patch.captionStyle
|
|
1515
|
-
};
|
|
1516
|
-
}
|
|
1517
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
1518
|
-
}),
|
|
1519
|
-
sharedTool("ui_timeline_add_group", async ({ name, startMs, durationMs, trackId, children, transform }) => {
|
|
1520
|
-
// Resolve every child before anything is written: a half-applied group
|
|
1521
|
-
// leaves the caller with an empty group and no idea which of its clips
|
|
1522
|
-
// moved.
|
|
1523
|
-
const targets = (children ?? []).map((ref) => resolveClip(ref));
|
|
1524
|
-
const track = trackId
|
|
1525
|
-
? resolveTrack(trackId)
|
|
1526
|
-
: findOrCreateTrack("overlay");
|
|
1527
|
-
const group = makeClip({
|
|
1528
|
-
id: nextClipId(),
|
|
1529
|
-
trackId: track.id,
|
|
1530
|
-
name: name,
|
|
1531
|
-
startMs: startMs,
|
|
1532
|
-
durationMs: durationMs,
|
|
1533
|
-
mediaType: "group",
|
|
1534
|
-
sourceType: "imported",
|
|
1535
|
-
status: "generated"
|
|
1536
|
-
});
|
|
1537
|
-
if (transform)
|
|
1538
|
-
group.transform = mergeClipTransform(group.transform, transform);
|
|
1539
|
-
clips.push(group);
|
|
1540
|
-
for (const child of targets) {
|
|
1541
|
-
child.parentId = group.id;
|
|
1542
|
-
}
|
|
1543
|
-
selectedClipIds = [group.id];
|
|
1544
|
-
return {
|
|
1545
|
-
ok: true,
|
|
1546
|
-
clip: serializeClip(group),
|
|
1547
|
-
children: targets.map((c) => c.id)
|
|
1548
|
-
};
|
|
1549
|
-
}),
|
|
1550
|
-
sharedTool("ui_timeline_set_parent", async ({ target, parentId }) => {
|
|
1551
|
-
const clip = resolveClip(target);
|
|
1552
|
-
if (parentId === null) {
|
|
1553
|
-
delete clip.parentId;
|
|
1554
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
1555
|
-
}
|
|
1556
|
-
const parent = resolveClip(parentId);
|
|
1557
|
-
if (parent.mediaType !== "group") {
|
|
1558
|
-
throw new Error(`"${parent.name}" is a ${parent.mediaType} clip, not a group — parent to a clip created with add_group. ${validUnits(clips.filter((c) => c.mediaType === "group"), "group")}`);
|
|
1559
|
-
}
|
|
1560
|
-
// A cycle renders unparented and warns, so refusing it here is the
|
|
1561
|
-
// only place it can still be fixed.
|
|
1562
|
-
let cursor = parent;
|
|
1563
|
-
while (cursor) {
|
|
1564
|
-
if (cursor.id === clip.id) {
|
|
1565
|
-
throw new Error(`"${parent.name}" is inside "${clip.name}" — parenting them would make a cycle.`);
|
|
1566
|
-
}
|
|
1567
|
-
const next = cursor.parentId;
|
|
1568
|
-
cursor = next ? clips.find((c) => c.id === next) : undefined;
|
|
1569
|
-
}
|
|
1570
|
-
clip.parentId = parent.id;
|
|
1571
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
1572
|
-
}),
|
|
1573
|
-
sharedTool("ui_timeline_set_transition", async ({ target, transition }) => {
|
|
1574
|
-
const clip = resolveClip(target);
|
|
1575
|
-
if (transition === null) {
|
|
1576
|
-
delete clip.transitionIn;
|
|
1577
|
-
}
|
|
1578
|
-
else {
|
|
1579
|
-
clip.transitionIn = buildTransition(transition);
|
|
1580
|
-
}
|
|
1581
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
1582
|
-
}),
|
|
1583
|
-
tool("ui_timeline_generate_transition_at_cut", "Generate an inactive cut-level transition candidate for two explicitly adjacent clips. This does not change either clip or create a clip take. Preview the returned candidate before applying it.", generatedTransitionCandidateParams, async (args) => {
|
|
1584
|
-
const candidate = await createTransitionCandidate(args);
|
|
1585
|
-
return {
|
|
1586
|
-
ok: true,
|
|
1587
|
-
requestId: candidate.generationId,
|
|
1588
|
-
generationId: candidate.generationId,
|
|
1589
|
-
candidate,
|
|
1590
|
-
source: candidate.source
|
|
1591
|
-
};
|
|
1592
|
-
}),
|
|
1593
|
-
tool("ui_timeline_preview_transition_candidate", "Audition one generated cut-level transition candidate without changing the timeline document. Refuses candidates whose selected clips, source takes, or cut are stale.", transitionCandidateParams, async ({ candidate_id }) => {
|
|
1594
|
-
const candidate = transitionCandidates.find((entry) => entry.id === candidate_id);
|
|
1595
|
-
if (!candidate) {
|
|
1596
|
-
throw new Error(`No generated transition candidate named "${candidate_id}" exists.`);
|
|
1597
|
-
}
|
|
1598
|
-
assertFreshTransitionCandidate(candidate);
|
|
1599
|
-
auditionedTransitionCandidateId = candidate.id;
|
|
1600
|
-
return { ok: true, previewOnly: true, candidate };
|
|
1601
|
-
}),
|
|
1602
|
-
tool("ui_timeline_apply_transition_at_cut", `${APPLY_TRANSITION_AT_CUT_DESCRIPTION} With clip ids and timing, this creates an inactive cut-level candidate. Apply a returned candidate_id explicitly to commit it as one undoable operation.`, transitionCandidateLifecycleParams, async (args) => {
|
|
1603
|
-
if (args.candidate_id !== undefined) {
|
|
1604
|
-
const applied = applyTransitionCandidate(args.candidate_id);
|
|
1605
|
-
return { ok: true, applied: true, ...applied };
|
|
1606
|
-
}
|
|
1607
|
-
const candidate = await createTransitionCandidate(args);
|
|
1608
|
-
return {
|
|
1609
|
-
ok: true,
|
|
1610
|
-
applied: false,
|
|
1611
|
-
requestId: candidate.generationId,
|
|
1612
|
-
generationId: candidate.generationId,
|
|
1613
|
-
candidate,
|
|
1614
|
-
source: candidate.source,
|
|
1615
|
-
next: "Preview with ui_timeline_preview_transition_candidate, then call this tool with candidate_id to apply."
|
|
1616
|
-
};
|
|
1617
|
-
}),
|
|
1618
|
-
sharedTool("ui_timeline_set_mask", async ({ target, mask }) => {
|
|
1619
|
-
const clip = resolveClip(target);
|
|
1620
|
-
if (mask === null) {
|
|
1621
|
-
delete clip.mask;
|
|
1622
|
-
}
|
|
1623
|
-
else {
|
|
1624
|
-
clip.mask = buildMask(mask, parseSvgPath);
|
|
1625
|
-
}
|
|
1626
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
1627
|
-
}),
|
|
1628
|
-
sharedTool("ui_timeline_set_matte", async ({ target, matte }) => {
|
|
1629
|
-
const clip = resolveClip(target);
|
|
1630
|
-
if (matte === null) {
|
|
1631
|
-
delete clip.matte;
|
|
1632
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
1633
|
-
}
|
|
1634
|
-
const input = matte;
|
|
1635
|
-
const source = resolveClip(input.source);
|
|
1636
|
-
if (source.id === clip.id) {
|
|
1637
|
-
throw new Error(`"${clip.name}" cannot be its own matte source — name another clip.`);
|
|
1638
|
-
}
|
|
1639
|
-
const matteOut = {
|
|
1640
|
-
sourceClipId: source.id,
|
|
1641
|
-
mode: input.mode
|
|
1642
|
-
};
|
|
1643
|
-
if (input.invert !== undefined)
|
|
1644
|
-
matteOut.invert = input.invert;
|
|
1645
|
-
clip.matte = matteOut;
|
|
1646
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
1647
|
-
}),
|
|
1648
|
-
// Headless-only: the inspector's own controls write these knobs into the
|
|
1649
|
-
// store. Both this and the ops module's `set_generated_matte` reach
|
|
1650
|
-
// `selectGeneratedMatteVersion`, so the version list cannot fork.
|
|
1651
|
-
tool("ui_timeline_set_generated_matte", SET_GENERATED_MATTE_DESCRIPTION, setGeneratedMatteParams, async (args) => {
|
|
1652
|
-
const input = args;
|
|
1653
|
-
const clip = resolveClip(input.target);
|
|
1654
|
-
if (input.clear === true) {
|
|
1655
|
-
delete clip.generatedMatte;
|
|
1656
|
-
return {
|
|
1657
|
-
ok: true,
|
|
1658
|
-
clip: serializeClip(clip),
|
|
1659
|
-
generatedMatte: null
|
|
1660
|
-
};
|
|
1661
|
-
}
|
|
1662
|
-
if (!clip.generatedMatte) {
|
|
1663
|
-
throw new Error(`"${clip.name}" carries no generated matte. Run isolate_subject ` +
|
|
1664
|
-
"on it first; this op only adjusts one that exists.");
|
|
1665
|
-
}
|
|
1666
|
-
if (input.selectVersionAssetId !== undefined) {
|
|
1667
|
-
clip.generatedMatte = selectGeneratedMatteVersion(clip, input.selectVersionAssetId).generatedMatte;
|
|
1668
|
-
}
|
|
1669
|
-
const matte = clip.generatedMatte;
|
|
1670
|
-
if (input.invert !== undefined)
|
|
1671
|
-
matte.invert = input.invert;
|
|
1672
|
-
if (input.strength !== undefined) {
|
|
1673
|
-
matte.strength = Math.min(1, Math.max(0, input.strength));
|
|
1674
|
-
}
|
|
1675
|
-
if (input.featherPx !== undefined) {
|
|
1676
|
-
matte.featherPx = Math.max(0, input.featherPx);
|
|
1677
|
-
}
|
|
1678
|
-
return { ok: true, clip: serializeClip(clip), generatedMatte: matte };
|
|
1679
|
-
}),
|
|
1680
|
-
sharedTool("ui_timeline_set_time_remap", async ({ target, timeRemap }) => {
|
|
1681
|
-
const clip = resolveClip(target);
|
|
1682
|
-
if (timeRemap === null) {
|
|
1683
|
-
delete clip.timeRemap;
|
|
1684
|
-
}
|
|
1685
|
-
else {
|
|
1686
|
-
clip.timeRemap = buildTimeRemap(timeRemap);
|
|
1687
|
-
}
|
|
1688
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
1689
|
-
}),
|
|
1690
|
-
sharedTool("ui_timeline_set_effects", async ({ target, effects }) => {
|
|
1691
|
-
const clip = resolveClip(target);
|
|
1692
|
-
const list = effects.map(buildEffect);
|
|
1693
|
-
if (list.length === 0) {
|
|
1694
|
-
delete clip.effects;
|
|
1695
|
-
}
|
|
1696
|
-
else {
|
|
1697
|
-
clip.effects = list;
|
|
1698
|
-
}
|
|
1699
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
1700
|
-
}),
|
|
1701
|
-
sharedTool("ui_timeline_set_clip_binding", async ({ target, ...patch }) => {
|
|
1702
|
-
const clip = resolveClip(target);
|
|
1703
|
-
if (clip.sourceType !== "generated") {
|
|
1704
|
-
throw new Error(`"${clip.name}" is not a generated clip — ui_timeline_set_clip_binding only applies to clips created with ui_timeline_generate_clip.`);
|
|
1705
|
-
}
|
|
1706
|
-
if (patch.prompt !== undefined)
|
|
1707
|
-
clip.prompt = patch.prompt;
|
|
1708
|
-
if (patch.negativePrompt !== undefined)
|
|
1709
|
-
clip.negativePrompt = patch.negativePrompt;
|
|
1710
|
-
if (patch.provider !== undefined)
|
|
1711
|
-
clip.provider = patch.provider;
|
|
1712
|
-
if (patch.model !== undefined)
|
|
1713
|
-
clip.model = patch.model;
|
|
1714
|
-
if (patch.voice !== undefined)
|
|
1715
|
-
clip.voice = patch.voice;
|
|
1716
|
-
if (patch.width !== undefined)
|
|
1717
|
-
clip.width = patch.width;
|
|
1718
|
-
if (patch.height !== undefined)
|
|
1719
|
-
clip.height = patch.height;
|
|
1720
|
-
if (patch.aspectRatio !== undefined)
|
|
1721
|
-
clip.aspectRatio = patch.aspectRatio;
|
|
1722
|
-
if (patch.resolution !== undefined)
|
|
1723
|
-
clip.resolution = patch.resolution;
|
|
1724
|
-
if (patch.strength !== undefined)
|
|
1725
|
-
clip.strength = patch.strength;
|
|
1726
|
-
if (patch.numInferenceSteps !== undefined)
|
|
1727
|
-
clip.numInferenceSteps = patch.numInferenceSteps;
|
|
1728
|
-
if (patch.regenerate)
|
|
1729
|
-
clip.status = "queued";
|
|
1730
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
1731
|
-
}),
|
|
1732
|
-
sharedTool("ui_timeline_animate_clip", async ({ target, mode, animations }) => {
|
|
1733
|
-
const clip = resolveClip(target);
|
|
1734
|
-
const inputs = animations;
|
|
1735
|
-
const built = [];
|
|
1736
|
-
for (const input of inputs) {
|
|
1737
|
-
if (input.preset === CUSTOM_ANIMATION_PRESET_ID) {
|
|
1738
|
-
// `{preset: "custom", custom: {curves}}` reads as naturally as the
|
|
1739
|
-
// flat form, so lift it rather than refusing it.
|
|
1740
|
-
built.push(await buildCustomAnimation(clip, liftCustomAnimation(input)));
|
|
1741
|
-
continue;
|
|
1742
|
-
}
|
|
1743
|
-
const preset = ANIMATION_PRESETS.find((p) => p.id === input.preset);
|
|
1744
|
-
if (!preset) {
|
|
1745
|
-
const ids = ANIMATION_PRESETS.map((p) => p.id).join(", ");
|
|
1746
|
-
throw new Error(`Unknown animation preset "${input.preset}". Valid presets: ${ids}, ${CUSTOM_ANIMATION_PRESET_ID}.`);
|
|
1747
|
-
}
|
|
1748
|
-
if (!preset.roles.includes(input.role)) {
|
|
1749
|
-
throw new Error(`Preset "${input.preset}" does not support role "${input.role}". Valid roles for "${input.preset}": ${preset.roles.join(", ")}.`);
|
|
1750
|
-
}
|
|
1751
|
-
if (input.preset === "typewriter" && clip.mediaType !== "text") {
|
|
1752
|
-
throw new Error('Preset "typewriter" requires a text clip.');
|
|
1753
|
-
}
|
|
1754
|
-
const timing = input.preset === "typewriter"
|
|
1755
|
-
? typewriterTiming(clip.textStyle?.text ?? "", clip.durationMs - (input.delayMs ?? 0), input.durationMs, input.stagger)
|
|
1756
|
-
: null;
|
|
1757
|
-
built.push({
|
|
1758
|
-
id: nextAnimId(),
|
|
1759
|
-
role: input.role,
|
|
1760
|
-
preset: input.preset,
|
|
1761
|
-
durationMs: timing?.durationMs ?? input.durationMs ?? preset.defaultDurationMs,
|
|
1762
|
-
delayMs: input.delayMs,
|
|
1763
|
-
easing: input.easing,
|
|
1764
|
-
params: input.params,
|
|
1765
|
-
stagger: timing?.stagger ?? input.stagger,
|
|
1766
|
-
caret: input.preset === "typewriter" ? input.caret : undefined
|
|
1767
|
-
});
|
|
1768
|
-
}
|
|
1769
|
-
clip.animations =
|
|
1770
|
-
mode === "add"
|
|
1771
|
-
? [...(clip.animations ?? []), ...built]
|
|
1772
|
-
: built;
|
|
1773
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
1774
|
-
}),
|
|
1775
|
-
// Headless-only: the browser writes curves through the inspector, not
|
|
1776
|
-
// through an agent call. Both this and the ops module's `set_baked_animation`
|
|
1777
|
-
// go through `buildBakedAnimation`, so the replace rule cannot fork.
|
|
1778
|
-
tool("ui_timeline_set_baked_animation", SET_BAKED_ANIMATION_DESCRIPTION, setBakedAnimationParams, async ({ target, animation }) => {
|
|
1779
|
-
const clip = resolveClip(target);
|
|
1780
|
-
const outcome = buildBakedAnimation(clip, animation, nextAnimId);
|
|
1781
|
-
clip.animations = outcome.animations;
|
|
1782
|
-
return {
|
|
1783
|
-
ok: true,
|
|
1784
|
-
clip: serializeClip(clip),
|
|
1785
|
-
animationId: outcome.animationId,
|
|
1786
|
-
keyframeCount: outcome.animation.custom?.curves[0]?.keyframes.length ?? 0,
|
|
1787
|
-
replaced: outcome.replaced
|
|
1788
|
-
};
|
|
1789
|
-
}),
|
|
1790
|
-
sharedTool("ui_timeline_stagger_animations", async ({ clip_ids, offset_ms }) => {
|
|
1791
|
-
const selected = clip_ids.map(resolveClip);
|
|
1792
|
-
const ids = selected.map((clip) => clip.id);
|
|
1793
|
-
if (new Set(ids).size !== ids.length) {
|
|
1794
|
-
throw new Error("clip_ids must contain distinct clip IDs.");
|
|
1795
|
-
}
|
|
1796
|
-
for (const clip of selected) {
|
|
1797
|
-
if (!clip.animations?.length)
|
|
1798
|
-
throw new Error(`Clip "${clip.name}" has no animations to stagger.`);
|
|
1799
|
-
}
|
|
1800
|
-
const staggered = staggerClipAnimations(clips, ids, offset_ms);
|
|
1801
|
-
const byId = new Map(staggered.map((clip) => [clip.id, clip]));
|
|
1802
|
-
clips.splice(0, clips.length, ...staggered);
|
|
1803
|
-
return { ok: true, clips: ids.map((id) => serializeClip(byId.get(id))) };
|
|
1804
|
-
}),
|
|
1805
|
-
sharedTool("ui_timeline_clear_animations", async ({ target, role }) => {
|
|
1806
|
-
const clip = resolveClip(target);
|
|
1807
|
-
clip.animations = role
|
|
1808
|
-
? (clip.animations ?? []).filter((a) => a.role !== role)
|
|
1809
|
-
: [];
|
|
1810
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
1811
|
-
}),
|
|
1812
|
-
sharedTool("ui_timeline_list_animation_presets", async () => {
|
|
1813
|
-
const presets = ANIMATION_PRESETS.map((p) => ({
|
|
1814
|
-
id: p.id,
|
|
1815
|
-
roles: p.roles,
|
|
1816
|
-
defaultDurationMs: p.defaultDurationMs,
|
|
1817
|
-
defaultEasing: p.defaultEasing,
|
|
1818
|
-
params: p.params,
|
|
1819
|
-
describe: p.describe
|
|
1820
|
-
}));
|
|
1821
|
-
return {
|
|
1822
|
-
ok: true,
|
|
1823
|
-
presets,
|
|
1824
|
-
custom: CUSTOM_ANIMATION_CONTRACT,
|
|
1825
|
-
properties: CUSTOM_ANIMATION_CONTRACT.properties
|
|
1826
|
-
};
|
|
1827
|
-
}),
|
|
1828
|
-
sharedTool("ui_timeline_select_clip", async ({ target }) => {
|
|
1829
|
-
const t = target;
|
|
1830
|
-
if (!t) {
|
|
1831
|
-
selectedClipIds = [];
|
|
1832
|
-
return { ok: true, selected: null };
|
|
1833
|
-
}
|
|
1834
|
-
const clip = resolveClip(t);
|
|
1835
|
-
selectedClipIds = [clip.id];
|
|
1836
|
-
return { ok: true, selected: serializeClip(clip) };
|
|
1837
|
-
}),
|
|
1838
|
-
sharedTool("ui_timeline_seek", async ({ timeMs }) => {
|
|
1839
|
-
playheadMs = Math.max(0, timeMs);
|
|
1840
|
-
return { ok: true, playheadMs };
|
|
1841
|
-
}),
|
|
1842
|
-
sharedTool("ui_timeline_add_marker", async ({ timeMs, label, color, note }) => {
|
|
1843
|
-
const at = timeMs;
|
|
1844
|
-
if (at < 0) {
|
|
1845
|
-
throw new Error(`A marker cannot sit before zero; got ${at}ms.`);
|
|
1846
|
-
}
|
|
1847
|
-
const marker = {
|
|
1848
|
-
id: nextMarkerId(),
|
|
1849
|
-
timeMs: Math.round(at),
|
|
1850
|
-
label: label ?? ""
|
|
1851
|
-
};
|
|
1852
|
-
if (color !== undefined)
|
|
1853
|
-
marker.color = color;
|
|
1854
|
-
if (note !== undefined)
|
|
1855
|
-
marker.note = note;
|
|
1856
|
-
markers.push(marker);
|
|
1857
|
-
return { ok: true, marker: { ...marker } };
|
|
1858
|
-
}),
|
|
1859
|
-
// ── Guided video flow (PRD § 8.6) ───────────────────────────────────
|
|
1860
|
-
sharedTool("ui_timeline_set_setup", async ({ stage, brief, format, voiceover }) => {
|
|
1861
|
-
setup = {
|
|
1862
|
-
stage: stage ?? setup?.stage ?? "idea",
|
|
1863
|
-
brief: brief ?? setup?.brief ?? "",
|
|
1864
|
-
format: format ?? setup?.format,
|
|
1865
|
-
// Absent leaves the choice unsaid; `false` is a deliberate silence,
|
|
1866
|
-
// so it is written rather than read as "nothing passed".
|
|
1867
|
-
voiceover: voiceover ?? setup?.voiceover,
|
|
1868
|
-
beats: setup?.beats
|
|
1869
|
-
};
|
|
1870
|
-
return { ok: true, setup: structuredClone(setup) };
|
|
1871
|
-
}),
|
|
1872
|
-
sharedTool("ui_timeline_plan_beats", async ({ beats }) => {
|
|
1873
|
-
// There is no Director on this surface — the caller *is* the model, so
|
|
1874
|
-
// it writes the plan rather than asking for one. Refusing without
|
|
1875
|
-
// `beats` says which half of the shared contract this host implements.
|
|
1876
|
-
const written = beats;
|
|
1877
|
-
if (!written || written.length === 0) {
|
|
1878
|
-
throw new Error("This surface has no Director: pass `beats` with the plan you want. Each beat needs a prompt and a durationMs.");
|
|
1879
|
-
}
|
|
1880
|
-
const planned = written.map((beat) => ({
|
|
1881
|
-
id: `beat_${++beatSeq}`,
|
|
1882
|
-
prompt: beat.prompt,
|
|
1883
|
-
duration_ms: Math.max(1, Math.round(beat.durationMs)),
|
|
1884
|
-
transition: beat.transition,
|
|
1885
|
-
voiceover: beat.voiceover,
|
|
1886
|
-
music: beat.music
|
|
1887
|
-
}));
|
|
1888
|
-
setup = {
|
|
1889
|
-
stage: "review",
|
|
1890
|
-
brief: setup?.brief ?? "",
|
|
1891
|
-
format: setup?.format,
|
|
1892
|
-
voiceover: setup?.voiceover,
|
|
1893
|
-
beats: planned
|
|
1894
|
-
};
|
|
1895
|
-
// Named in the answer because it is the contract: the plan is text,
|
|
1896
|
-
// and costs nothing until generate_from_beats runs (D4).
|
|
1897
|
-
return {
|
|
1898
|
-
ok: true,
|
|
1899
|
-
beats: planned.map((beat) => ({ ...beat })),
|
|
1900
|
-
clipsCreated: 0,
|
|
1901
|
-
jobsStarted: 0
|
|
1902
|
-
};
|
|
1903
|
-
}),
|
|
1904
|
-
sharedTool("ui_timeline_update_beat", async ({ beat, prompt, durationMs, transition, voiceover, music }) => {
|
|
1905
|
-
const found = resolveBeat(beat);
|
|
1906
|
-
const next = { ...found };
|
|
1907
|
-
if (prompt !== undefined)
|
|
1908
|
-
next.prompt = prompt;
|
|
1909
|
-
if (durationMs !== undefined) {
|
|
1910
|
-
next.duration_ms = Math.max(1, Math.round(durationMs));
|
|
1911
|
-
}
|
|
1912
|
-
if (transition !== undefined) {
|
|
1913
|
-
next.transition = transition ?? undefined;
|
|
1914
|
-
}
|
|
1915
|
-
if (voiceover !== undefined)
|
|
1916
|
-
next.voiceover = voiceover;
|
|
1917
|
-
if (music !== undefined)
|
|
1918
|
-
next.music = music;
|
|
1919
|
-
setup = {
|
|
1920
|
-
stage: setup?.stage ?? "review",
|
|
1921
|
-
brief: setup?.brief ?? "",
|
|
1922
|
-
format: setup?.format,
|
|
1923
|
-
voiceover: setup?.voiceover,
|
|
1924
|
-
beats: (setup?.beats ?? []).map((candidate) => candidate.id === found.id ? next : candidate)
|
|
1925
|
-
};
|
|
1926
|
-
return { ok: true, beat: { ...next } };
|
|
1927
|
-
}),
|
|
1928
|
-
sharedTool("ui_timeline_remove_beat", async ({ beat }) => {
|
|
1929
|
-
// Resolved before it is dropped, so the answer names what went and an
|
|
1930
|
-
// unresolvable target says so rather than reporting a silent success.
|
|
1931
|
-
const found = resolveBeat(beat);
|
|
1932
|
-
setup = {
|
|
1933
|
-
stage: setup?.stage ?? "review",
|
|
1934
|
-
brief: setup?.brief ?? "",
|
|
1935
|
-
format: setup?.format,
|
|
1936
|
-
voiceover: setup?.voiceover,
|
|
1937
|
-
beats: (setup?.beats ?? []).filter((candidate) => candidate.id !== found.id)
|
|
1938
|
-
};
|
|
1939
|
-
return { ok: true, removed: { ...found } };
|
|
1940
|
-
}),
|
|
1941
|
-
sharedTool("ui_timeline_generate_from_beats", async ({ model, provider, voice, music }) => {
|
|
1942
|
-
const plan = setup?.beats ?? [];
|
|
1943
|
-
if (plan.length === 0) {
|
|
1944
|
-
throw new Error("There is no beat plan to generate from; call ui_timeline_plan_beats first.");
|
|
1945
|
-
}
|
|
1946
|
-
const videoTrack = findOrCreateTrack("video");
|
|
1947
|
-
const wantsMusic = music ??
|
|
1948
|
-
plan.some((beat) => beat.music === true);
|
|
1949
|
-
const voiceTrack = voice !== undefined && plan.some((beat) => (beat.voiceover ?? "") !== "")
|
|
1950
|
-
? findOrCreateTrack("audio")
|
|
1951
|
-
: null;
|
|
1952
|
-
const videoClipIds = [];
|
|
1953
|
-
const voiceoverClipIds = [];
|
|
1954
|
-
const planned = [];
|
|
1955
|
-
let startMs = 0;
|
|
1956
|
-
for (const [index, beat] of plan.entries()) {
|
|
1957
|
-
const durationMs = Math.max(1, Math.round(beat.duration_ms));
|
|
1958
|
-
const clip = makeClip({
|
|
1959
|
-
id: nextClipId(),
|
|
1960
|
-
trackId: videoTrack.id,
|
|
1961
|
-
name: `Beat ${index + 1}`,
|
|
1962
|
-
startMs,
|
|
1963
|
-
durationMs,
|
|
1964
|
-
mediaType: "video",
|
|
1965
|
-
sourceType: "generated",
|
|
1966
|
-
bindingKind: "text-to-video",
|
|
1967
|
-
prompt: beat.prompt,
|
|
1968
|
-
provider: provider,
|
|
1969
|
-
model: model,
|
|
1970
|
-
beatId: beat.id,
|
|
1971
|
-
status: "draft"
|
|
1972
|
-
});
|
|
1973
|
-
if (beat.transition) {
|
|
1974
|
-
clip.transitionIn = buildTransition({
|
|
1975
|
-
type: beat.transition,
|
|
1976
|
-
durationMs: 500
|
|
1977
|
-
});
|
|
1978
|
-
}
|
|
1979
|
-
clips.push(clip);
|
|
1980
|
-
videoClipIds.push(clip.id);
|
|
1981
|
-
const line = (beat.voiceover ?? "").trim();
|
|
1982
|
-
if (voiceTrack && line.length > 0) {
|
|
1983
|
-
const voiceClip = makeClip({
|
|
1984
|
-
id: nextClipId(),
|
|
1985
|
-
trackId: voiceTrack.id,
|
|
1986
|
-
name: `Beat ${index + 1} voiceover`,
|
|
1987
|
-
startMs,
|
|
1988
|
-
durationMs,
|
|
1989
|
-
mediaType: "audio",
|
|
1990
|
-
sourceType: "generated",
|
|
1991
|
-
bindingKind: "text-to-audio",
|
|
1992
|
-
prompt: line,
|
|
1993
|
-
voice: voice,
|
|
1994
|
-
beatId: beat.id,
|
|
1995
|
-
status: "draft"
|
|
1996
|
-
});
|
|
1997
|
-
clips.push(voiceClip);
|
|
1998
|
-
voiceoverClipIds.push(voiceClip.id);
|
|
1999
|
-
}
|
|
2000
|
-
planned.push({ ...beat, clip_id: clip.id });
|
|
2001
|
-
startMs += durationMs;
|
|
2002
|
-
}
|
|
2003
|
-
let musicClipId = null;
|
|
2004
|
-
if (wantsMusic) {
|
|
2005
|
-
// One bed under the whole cut, never one per beat: overlapping music
|
|
2006
|
-
// clips would play at once.
|
|
2007
|
-
const musicTrack = findOrCreateTrack("audio");
|
|
2008
|
-
const musicClip = makeClip({
|
|
2009
|
-
id: nextClipId(),
|
|
2010
|
-
trackId: musicTrack.id,
|
|
2011
|
-
name: "Music",
|
|
2012
|
-
startMs: 0,
|
|
2013
|
-
durationMs: startMs,
|
|
2014
|
-
mediaType: "audio",
|
|
2015
|
-
sourceType: "generated",
|
|
2016
|
-
bindingKind: "text-to-audio",
|
|
2017
|
-
prompt: `Instrumental score under: ${setup?.brief ?? ""}`,
|
|
2018
|
-
status: "draft"
|
|
2019
|
-
});
|
|
2020
|
-
clips.push(musicClip);
|
|
2021
|
-
musicClipId = musicClip.id;
|
|
2022
|
-
}
|
|
2023
|
-
setup = {
|
|
2024
|
-
stage: "done",
|
|
2025
|
-
brief: setup?.brief ?? "",
|
|
2026
|
-
format: setup?.format,
|
|
2027
|
-
voiceover: setup?.voiceover,
|
|
2028
|
-
beats: planned
|
|
2029
|
-
};
|
|
2030
|
-
return {
|
|
2031
|
-
ok: true,
|
|
2032
|
-
videoClipIds,
|
|
2033
|
-
voiceoverClipIds,
|
|
2034
|
-
musicClipId,
|
|
2035
|
-
// Nothing renders headlessly: this authors the cut, and the clips are
|
|
2036
|
-
// drafts until a renderer runs them.
|
|
2037
|
-
startedClipIds: [],
|
|
2038
|
-
note: "Clips created as drafts — this surface renders nothing."
|
|
2039
|
-
};
|
|
2040
|
-
}),
|
|
2041
|
-
sharedTool("ui_timeline_delete_marker", async ({ target }) => {
|
|
2042
|
-
const marker = resolveMarker(target);
|
|
2043
|
-
markers = markers.filter((m) => m.id !== marker.id);
|
|
2044
|
-
return { ok: true, deleted: { ...marker } };
|
|
2045
|
-
}),
|
|
2046
|
-
sharedTool("ui_timeline_set_markers_from_beats", async ({ onsets_ms, bpm, offset_ms, count, label }) => {
|
|
2047
|
-
const grid = buildBeatGrid({
|
|
2048
|
-
onsetsMs: onsets_ms,
|
|
2049
|
-
bpm: bpm,
|
|
2050
|
-
offsetMs: offset_ms,
|
|
2051
|
-
count: count
|
|
2052
|
-
});
|
|
2053
|
-
const stem = (label ?? "Beat").trim() || "Beat";
|
|
2054
|
-
const taken = new Set(markers.map((m) => m.timeMs));
|
|
2055
|
-
const added = [];
|
|
2056
|
-
const skipped = [];
|
|
2057
|
-
for (const [index, timeMs] of grid.entries()) {
|
|
2058
|
-
if (taken.has(timeMs)) {
|
|
2059
|
-
skipped.push(timeMs);
|
|
2060
|
-
continue;
|
|
2061
|
-
}
|
|
2062
|
-
const marker = {
|
|
2063
|
-
id: nextMarkerId(),
|
|
2064
|
-
timeMs,
|
|
2065
|
-
label: `${stem} ${index + 1}`
|
|
2066
|
-
};
|
|
2067
|
-
markers.push(marker);
|
|
2068
|
-
taken.add(timeMs);
|
|
2069
|
-
added.push(marker);
|
|
2070
|
-
}
|
|
2071
|
-
return {
|
|
2072
|
-
ok: true,
|
|
2073
|
-
grid: { count: grid.length, firstMs: grid[0], lastMs: grid[grid.length - 1] },
|
|
2074
|
-
added: added.map((m) => ({ ...m })),
|
|
2075
|
-
skipped_times_ms: skipped,
|
|
2076
|
-
markers: markers.length
|
|
2077
|
-
};
|
|
2078
|
-
}),
|
|
2079
|
-
sharedTool("ui_timeline_snap_to_beats", async ({ targets, onsets_ms, bpm, offset_ms, tolerance_ms, mode, action }) => {
|
|
2080
|
-
const named = targets === undefined || targets === "all"
|
|
2081
|
-
? undefined
|
|
2082
|
-
: targets;
|
|
2083
|
-
const { clips: targeted, missing } = resolveSnapTargets(named);
|
|
2084
|
-
const offsetMs = offset_ms ?? 0;
|
|
2085
|
-
// A tempo grid has to reach the last boundary being snapped, so its
|
|
2086
|
-
// length comes from the targets rather than from the caller.
|
|
2087
|
-
const reachMs = targeted.reduce((end, clip) => Math.max(end, clip.startMs + clip.durationMs), 0);
|
|
2088
|
-
const grid = buildBeatGrid({
|
|
2089
|
-
onsetsMs: onsets_ms,
|
|
2090
|
-
bpm: bpm,
|
|
2091
|
-
offsetMs: offset_ms,
|
|
2092
|
-
count: bpm === undefined
|
|
2093
|
-
? undefined
|
|
2094
|
-
: beatCountToCover(bpm, offsetMs, reachMs)
|
|
2095
|
-
});
|
|
2096
|
-
const options = {};
|
|
2097
|
-
if (tolerance_ms !== undefined) {
|
|
2098
|
-
options.toleranceMs = tolerance_ms;
|
|
2099
|
-
}
|
|
2100
|
-
if (mode !== undefined)
|
|
2101
|
-
options.mode = mode;
|
|
2102
|
-
if (action !== undefined)
|
|
2103
|
-
options.action = action;
|
|
2104
|
-
const result = snapClipsToGrid(targeted.map((clip) => ({
|
|
2105
|
-
id: clip.id,
|
|
2106
|
-
startMs: clip.startMs,
|
|
2107
|
-
durationMs: clip.durationMs
|
|
2108
|
-
})), grid, options);
|
|
2109
|
-
const byId = new Map(targeted.map((clip) => [clip.id, clip]));
|
|
2110
|
-
const reported = result.clips.map((entry) => {
|
|
2111
|
-
const clip = byId.get(entry.clipId);
|
|
2112
|
-
if (entry.snapped && clip) {
|
|
2113
|
-
// Through the same ops the caller would use: a group carries its
|
|
2114
|
-
// children (D4) and a trim carries the source points, neither of
|
|
2115
|
-
// which a raw startMs/durationMs write does.
|
|
2116
|
-
try {
|
|
2117
|
-
if (entry.after.durationMs === entry.before.durationMs) {
|
|
2118
|
-
applyMove(clip, { startMs: entry.after.startMs });
|
|
2119
|
-
}
|
|
2120
|
-
else {
|
|
2121
|
-
let trimmed = clip;
|
|
2122
|
-
if (entry.after.startMs !== entry.before.startMs) {
|
|
2123
|
-
trimmed = applyTrimStart(clip, entry.after.startMs);
|
|
2124
|
-
}
|
|
2125
|
-
applyTrim(trimmed, { durationMs: entry.after.durationMs });
|
|
2126
|
-
}
|
|
2127
|
-
}
|
|
2128
|
-
catch (error) {
|
|
2129
|
-
return {
|
|
2130
|
-
...entry,
|
|
2131
|
-
snapped: false,
|
|
2132
|
-
after: entry.before,
|
|
2133
|
-
delta: { startMs: 0, endMs: 0 },
|
|
2134
|
-
reason: error instanceof Error ? error.message : String(error),
|
|
2135
|
-
clipName: clip.name
|
|
2136
|
-
};
|
|
2137
|
-
}
|
|
2138
|
-
}
|
|
2139
|
-
return { ...entry, clipName: clip?.name ?? null };
|
|
2140
|
-
});
|
|
2141
|
-
// A name nothing matched is a skip like any other: the caller has to
|
|
2142
|
-
// see it in the same list, not infer it from a shorter one.
|
|
2143
|
-
for (const target of missing) {
|
|
2144
|
-
reported.push({
|
|
2145
|
-
clipId: target,
|
|
2146
|
-
clipName: null,
|
|
2147
|
-
snapped: false,
|
|
2148
|
-
before: { startMs: 0, endMs: 0, durationMs: 0 },
|
|
2149
|
-
after: { startMs: 0, endMs: 0, durationMs: 0 },
|
|
2150
|
-
delta: { startMs: 0, endMs: 0 },
|
|
2151
|
-
reason: `no clip matches "${target}"`
|
|
2152
|
-
});
|
|
2153
|
-
}
|
|
2154
|
-
return {
|
|
2155
|
-
ok: true,
|
|
2156
|
-
grid: {
|
|
2157
|
-
count: grid.length,
|
|
2158
|
-
firstMs: grid[0],
|
|
2159
|
-
lastMs: grid[grid.length - 1]
|
|
2160
|
-
},
|
|
2161
|
-
toleranceMs: result.toleranceMs,
|
|
2162
|
-
mode: result.mode,
|
|
2163
|
-
action: result.action,
|
|
2164
|
-
snapped: result.snapped,
|
|
2165
|
-
skipped: result.skipped + missing.length,
|
|
2166
|
-
clips: reported
|
|
2167
|
-
};
|
|
2168
|
-
}),
|
|
2169
|
-
// ── MIDI ────────────────────────────────────────────────────────────
|
|
2170
|
-
// Notes live on the clip, the instrument on the track, and the tempo on
|
|
2171
|
-
// the document, so each gets its own op.
|
|
2172
|
-
sharedTool("ui_timeline_add_midi_clip", async ({ track, start_ms, duration_ms, name, notes }) => {
|
|
2173
|
-
const midiTrack = resolveMidiTrack(track);
|
|
2174
|
-
const clipName = name ?? "Phrase";
|
|
2175
|
-
const clip = makeClip({
|
|
2176
|
-
id: nextClipId(),
|
|
2177
|
-
trackId: midiTrack.id,
|
|
2178
|
-
name: clipName,
|
|
2179
|
-
startMs: start_ms,
|
|
2180
|
-
durationMs: duration_ms,
|
|
2181
|
-
mediaType: "midi",
|
|
2182
|
-
sourceType: "imported",
|
|
2183
|
-
status: "generated",
|
|
2184
|
-
notes: buildNotes(notes, clipName)
|
|
2185
|
-
});
|
|
2186
|
-
clips.push(clip);
|
|
2187
|
-
selectedClipIds = [clip.id];
|
|
2188
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
2189
|
-
}),
|
|
2190
|
-
sharedTool("ui_timeline_set_notes", async ({ clip: target, notes }) => {
|
|
2191
|
-
const clip = resolveMidiClip(target);
|
|
2192
|
-
// Built before anything is written: a refused list must leave the clip
|
|
2193
|
-
// exactly as it was, not half replaced.
|
|
2194
|
-
clip.notes = buildNotes(notes, clip.name);
|
|
2195
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
2196
|
-
}),
|
|
2197
|
-
sharedTool("ui_timeline_set_tempo", async ({ bpm, offset_ms, beats_per_bar, beat_unit }) => {
|
|
2198
|
-
const previous = resolveTempo({ tempo });
|
|
2199
|
-
const next = {
|
|
2200
|
-
bpm: bpm,
|
|
2201
|
-
offsetMs: offset_ms ?? previous.offsetMs,
|
|
2202
|
-
timeSignature: {
|
|
2203
|
-
beatsPerBar: beats_per_bar ??
|
|
2204
|
-
previous.timeSignature.beatsPerBar,
|
|
2205
|
-
beatUnit: beat_unit ?? previous.timeSignature.beatUnit
|
|
2206
|
-
}
|
|
2207
|
-
};
|
|
2208
|
-
// Milliseconds are the master clock, so the stored ms of every midi
|
|
2209
|
-
// clip move and nothing else on the timeline does.
|
|
2210
|
-
clips = rescaleClipsForTempo(clips, tracks, previous, next);
|
|
2211
|
-
tempo = next;
|
|
2212
|
-
return {
|
|
2213
|
-
ok: true,
|
|
2214
|
-
tempo: next,
|
|
2215
|
-
previousTempo: previous,
|
|
2216
|
-
rescaledClipIds: clips
|
|
2217
|
-
.filter((c) => c.mediaType === "midi")
|
|
2218
|
-
.map((c) => c.id)
|
|
2219
|
-
};
|
|
2220
|
-
}),
|
|
2221
|
-
sharedTool("ui_timeline_set_track_instrument", async ({ track, instrument }) => {
|
|
2222
|
-
const midiTrack = resolveMidiTrack(track);
|
|
2223
|
-
// Resolved before the track is touched, so an unknown preset id leaves
|
|
2224
|
-
// the voice the track already had.
|
|
2225
|
-
midiTrack.instrument = resolveInstrumentArg(instrument);
|
|
2226
|
-
return { ok: true, track: serializeTrack(midiTrack) };
|
|
2227
|
-
}),
|
|
2228
|
-
// The note edits. Each runs the whole list through one pure function from
|
|
2229
|
-
// `@nodetool-ai/timeline` and replaces it — the ids survive, so a
|
|
2230
|
-
// selection and an undo in the editor still point at the same notes.
|
|
2231
|
-
sharedTool("ui_timeline_transpose_clip", async ({ clip: target, semitones }) => {
|
|
2232
|
-
const clip = resolveMidiClip(target);
|
|
2233
|
-
clip.notes = transposeNotes(clip.notes ?? [], semitones);
|
|
2234
|
-
return {
|
|
2235
|
-
ok: true,
|
|
2236
|
-
clip: serializeClip(clip),
|
|
2237
|
-
noteCount: clip.notes.length
|
|
2238
|
-
};
|
|
2239
|
-
}),
|
|
2240
|
-
sharedTool("ui_timeline_quantize_notes", async ({ clip: clipRef, division, strength, target }) => {
|
|
2241
|
-
const clip = resolveMidiClip(clipRef);
|
|
2242
|
-
const before = clip.notes ?? [];
|
|
2243
|
-
const options = { division: division };
|
|
2244
|
-
if (strength !== undefined)
|
|
2245
|
-
options.strength = strength;
|
|
2246
|
-
if (target !== undefined)
|
|
2247
|
-
options.target = target;
|
|
2248
|
-
const quantized = quantizeNotes(before, options);
|
|
2249
|
-
// The notes come back in the order they went in, so this pairs each
|
|
2250
|
-
// one with itself. A phrase already on the grid reports 0 moved
|
|
2251
|
-
// instead of reading as an edit that changed something.
|
|
2252
|
-
const movedNoteCount = quantized.filter((note, i) => note.startTick !== before[i].startTick ||
|
|
2253
|
-
note.durationTick !== before[i].durationTick).length;
|
|
2254
|
-
clip.notes = quantized;
|
|
2255
|
-
return {
|
|
2256
|
-
ok: true,
|
|
2257
|
-
clip: serializeClip(clip),
|
|
2258
|
-
noteCount: quantized.length,
|
|
2259
|
-
movedNoteCount
|
|
2260
|
-
};
|
|
2261
|
-
}),
|
|
2262
|
-
sharedTool("ui_timeline_scale_velocity", async ({ clip: target, factor }) => {
|
|
2263
|
-
const clip = resolveMidiClip(target);
|
|
2264
|
-
clip.notes = scaleVelocity(clip.notes ?? [], factor);
|
|
2265
|
-
return {
|
|
2266
|
-
ok: true,
|
|
2267
|
-
clip: serializeClip(clip),
|
|
2268
|
-
noteCount: clip.notes.length
|
|
2269
|
-
};
|
|
2270
|
-
}),
|
|
2271
|
-
tool("ui_timeline_insert_composition", "Insert a stored composition — a group of clips with named parameters (a lower third, a title card, a callout) — at a timecode. Its children keep the layering the template declares: each template track becomes an overlay track of its own, front-most on top, reused across insertions. `params` overrides the template's defaults by name; anything omitted keeps its default. Call list_compositions for the ids and their parameters.", z.object({
|
|
2272
|
-
composition_id: z
|
|
2273
|
-
.string()
|
|
2274
|
-
.describe("Composition id, from list_compositions."),
|
|
2275
|
-
startMs: z
|
|
2276
|
-
.number()
|
|
2277
|
-
.describe("Where the group starts on the timeline, in ms."),
|
|
2278
|
-
trackId: z
|
|
2279
|
-
.string()
|
|
2280
|
-
.optional()
|
|
2281
|
-
.describe("Track for the group clip itself. Its children still get their own overlay tracks. Defaults to an overlay track."),
|
|
2282
|
-
params: z
|
|
2283
|
-
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
|
|
2284
|
-
.optional()
|
|
2285
|
-
.describe("Parameter overrides by name, e.g. {name: \"Ada Lovelace\"}. A name the template does not declare, or a value of the wrong type, is refused.")
|
|
2286
|
-
}), async ({ composition_id, startMs, trackId, params }) => {
|
|
2287
|
-
if (!loadComposition) {
|
|
2288
|
-
throw new Error("This surface has no composition library, so insert_composition cannot resolve a template.");
|
|
2289
|
-
}
|
|
2290
|
-
const id = composition_id;
|
|
2291
|
-
const composition = await loadComposition.get(id);
|
|
2292
|
-
if (!composition) {
|
|
2293
|
-
const available = await loadComposition.listIds();
|
|
2294
|
-
throw new Error(`No composition with id "${id}". ` +
|
|
2295
|
-
(available.length > 0
|
|
2296
|
-
? `Available: ${available.join(", ")}.`
|
|
2297
|
-
: "This install has none — save one with save_composition."));
|
|
2298
|
-
}
|
|
2299
|
-
const minted = instantiateComposition(composition, {
|
|
2300
|
-
startMs: startMs,
|
|
2301
|
-
params: params,
|
|
2302
|
-
newId: nextClipId
|
|
2303
|
-
});
|
|
2304
|
-
// Template track ids are names, not document ids. Two clips overlapping
|
|
2305
|
-
// on one track auto-dissolve into each other, so each template track
|
|
2306
|
-
// becomes a track of its own — created front-most first, because the
|
|
2307
|
-
// lowest track index draws on top (I9).
|
|
2308
|
-
const templateTracks = [];
|
|
2309
|
-
for (const child of composition.children) {
|
|
2310
|
-
if (!templateTracks.includes(child.trackId)) {
|
|
2311
|
-
templateTracks.push(child.trackId);
|
|
2312
|
-
}
|
|
2313
|
-
}
|
|
2314
|
-
const mapped = new Map();
|
|
2315
|
-
for (const name of [...templateTracks].reverse()) {
|
|
2316
|
-
const existing = tracks.find((t) => t.type === "overlay" && t.name === name);
|
|
2317
|
-
mapped.set(name, (existing ?? addTrackInternal("overlay", name)).id);
|
|
2318
|
-
}
|
|
2319
|
-
// The group draws nothing, but it still occupies its track's timeline,
|
|
2320
|
-
// and a group sharing a track with one of its children reads as an
|
|
2321
|
-
// overlap. It gets a track named after the composition instead.
|
|
2322
|
-
const groupTrack = trackId
|
|
2323
|
-
? resolveTrack(trackId)
|
|
2324
|
-
: (tracks.find((t) => t.type === "overlay" && t.name === composition.name) ?? addTrackInternal("overlay", composition.name));
|
|
2325
|
-
const [group, ...children] = minted;
|
|
2326
|
-
group.trackId = groupTrack.id;
|
|
2327
|
-
for (const [index, child] of children.entries()) {
|
|
2328
|
-
child.trackId =
|
|
2329
|
-
mapped.get(composition.children[index].trackId) ?? groupTrack.id;
|
|
2330
|
-
}
|
|
2331
|
-
clips.push(group, ...children);
|
|
2332
|
-
selectedClipIds = [group.id];
|
|
2333
|
-
return {
|
|
2334
|
-
ok: true,
|
|
2335
|
-
compositionId: composition.id,
|
|
2336
|
-
clip: serializeClip(group),
|
|
2337
|
-
children: children.map((child) => serializeClip(child)),
|
|
2338
|
-
params: group.compositionParams ?? {}
|
|
2339
|
-
};
|
|
2340
|
-
}),
|
|
2341
|
-
// Headless-only: the editor's ClipVersionHistory panel writes take
|
|
2342
|
-
// switches, renames and deletes straight into TimelineStore via
|
|
2343
|
-
// restoreVersion/renameTake/deleteTake. This op reaches the same
|
|
2344
|
-
// `selectTake`/`renameTake`/`deleteTake` helpers, so the take list cannot
|
|
2345
|
-
// fork between the two hosts (P0 AI Video, PRD § 8.10).
|
|
2346
|
-
tool("ui_timeline_list_takes", "List a clip's take history (clip.versions): id, label, source, " +
|
|
2347
|
-
"provider, model, createdAt, costCredits, durationMs, status, " +
|
|
2348
|
-
"favorite, and which one is active.", z.object({ target: z.string().describe("Clip id or name.") }), async ({ target }) => {
|
|
2349
|
-
const clip = resolveClip(target);
|
|
2350
|
-
const versions = clip.versions ?? [];
|
|
2351
|
-
const activeId = activeTakeIdOf(clip);
|
|
2352
|
-
return {
|
|
2353
|
-
ok: true,
|
|
2354
|
-
takes: versions.map((v) => ({
|
|
2355
|
-
id: v.id,
|
|
2356
|
-
label: v.label,
|
|
2357
|
-
source: v.source,
|
|
2358
|
-
provider: v.provider,
|
|
2359
|
-
model: v.model,
|
|
2360
|
-
createdAt: v.createdAt,
|
|
2361
|
-
costCredits: v.costCredits,
|
|
2362
|
-
durationMs: v.durationMs,
|
|
2363
|
-
status: v.status,
|
|
2364
|
-
favorite: v.favorite,
|
|
2365
|
-
active: v.id === activeId
|
|
2366
|
-
})),
|
|
2367
|
-
activeTakeId: activeId ?? null
|
|
2368
|
-
};
|
|
2369
|
-
}),
|
|
2370
|
-
tool("ui_timeline_select_take", "Make a stored take current on a clip — the same switch " +
|
|
2371
|
-
"restoreVersion does in the editor. Refuses a take that is not " +
|
|
2372
|
-
'`status: "success"`.', z.object({
|
|
2373
|
-
target: z.string().describe("Clip id or name."),
|
|
2374
|
-
takeId: z.string().describe("A take id, from list_takes.")
|
|
2375
|
-
}), async ({ target, takeId }) => {
|
|
2376
|
-
const clip = resolveClip(target);
|
|
2377
|
-
if (clip.mediaType === "model3d") {
|
|
2378
|
-
throw new Error(`Clip "${clip.name}" is a 3D clip — its version history includes ` +
|
|
2379
|
-
"bake renders that must not replace the glTF source. Take " +
|
|
2380
|
-
"selection is not available for model3d clips yet.");
|
|
2381
|
-
}
|
|
2382
|
-
const version = (clip.versions ?? []).find((v) => v.id === takeId);
|
|
2383
|
-
if (!version) {
|
|
2384
|
-
throw new Error(`No take "${takeId}" on "${clip.name}". ` +
|
|
2385
|
-
validUnits((clip.versions ?? []).map((v) => ({
|
|
2386
|
-
id: v.id,
|
|
2387
|
-
name: v.label ?? v.id
|
|
2388
|
-
})), "take"));
|
|
2389
|
-
}
|
|
2390
|
-
if (version.status !== "success") {
|
|
2391
|
-
throw new Error(`Take "${takeId}" on "${clip.name}" did not finish successfully ` +
|
|
2392
|
-
`(status: ${version.status}) — only a successful take can be selected.`);
|
|
2393
|
-
}
|
|
2394
|
-
const next = selectTake(clip, takeId);
|
|
2395
|
-
clips = clips.map((c) => (c.id === clip.id ? next : c));
|
|
2396
|
-
return { ok: true, clip: serializeClip(next) };
|
|
2397
|
-
}),
|
|
2398
|
-
tool("ui_timeline_rename_take", "Set a take's display label.", z.object({
|
|
2399
|
-
target: z.string().describe("Clip id or name."),
|
|
2400
|
-
takeId: z.string().describe("A take id, from list_takes."),
|
|
2401
|
-
label: z.string().describe("The new display label.")
|
|
2402
|
-
}), async ({ target, takeId, label }) => {
|
|
2403
|
-
const clip = resolveClip(target);
|
|
2404
|
-
if (!(clip.versions ?? []).some((v) => v.id === takeId)) {
|
|
2405
|
-
throw new Error(`No take "${takeId}" on "${clip.name}". ` +
|
|
2406
|
-
validUnits((clip.versions ?? []).map((v) => ({
|
|
2407
|
-
id: v.id,
|
|
2408
|
-
name: v.label ?? v.id
|
|
2409
|
-
})), "take"));
|
|
2410
|
-
}
|
|
2411
|
-
const next = renameTakeOnClip(clip, takeId, label);
|
|
2412
|
-
clips = clips.map((c) => (c.id === clip.id ? next : c));
|
|
2413
|
-
return { ok: true, clip: serializeClip(next) };
|
|
2414
|
-
}),
|
|
2415
|
-
tool("ui_timeline_delete_take", "Remove a take from a clip's history. Refuses when `takeId` is the " +
|
|
2416
|
-
"active take — select_take a different take first, then delete the " +
|
|
2417
|
-
"one you no longer want; a clip must always keep at least one take.", z.object({
|
|
2418
|
-
target: z.string().describe("Clip id or name."),
|
|
2419
|
-
takeId: z.string().describe("A take id, from list_takes.")
|
|
2420
|
-
}), async ({ target, takeId }) => {
|
|
2421
|
-
const clip = resolveClip(target);
|
|
2422
|
-
const { clip: next, error } = deleteTakeOnClip(clip, takeId);
|
|
2423
|
-
if (error)
|
|
2424
|
-
throw new Error(error);
|
|
2425
|
-
clips = clips.map((c) => (c.id === clip.id ? next : c));
|
|
2426
|
-
return { ok: true, clip: serializeClip(next) };
|
|
2427
|
-
}),
|
|
2428
|
-
// Subject/object tracks (P0 AI Video, Phase 2). The async provider call
|
|
2429
|
-
// that fills a track's samples is the `track_object` capability, not an
|
|
2430
|
-
// op — these four are the structural half over it, and they carry the
|
|
2431
|
-
// same semantics as `applyTimelineOp`'s own cases so the two hosts cannot
|
|
2432
|
-
// fork (`packages/timeline/src/ops/apply.ts`).
|
|
2433
|
-
tool("ui_timeline_list_tracks", "List the document's subject/object tracks — id, clipId, name, kind, " +
|
|
2434
|
-
"status, source window and sample count. The samples themselves are " +
|
|
2435
|
-
"not returned: a track carries one per frame it covers, which is a " +
|
|
2436
|
-
"wall of numbers no caller reads through a listing.", z.object({
|
|
2437
|
-
target: z
|
|
2438
|
-
.string()
|
|
2439
|
-
.optional()
|
|
2440
|
-
.describe("A clip id or name to filter by. Omit for every track.")
|
|
2441
|
-
}), async ({ target }) => {
|
|
2442
|
-
const clipId = typeof target === "string" && target.trim() !== ""
|
|
2443
|
-
? resolveClip(target).id
|
|
2444
|
-
: undefined;
|
|
2445
|
-
const listed = mediaTracks.filter((t) => clipId === undefined || t.clipId === clipId);
|
|
2446
|
-
return {
|
|
2447
|
-
ok: true,
|
|
2448
|
-
tracks: listed.map((t) => ({
|
|
2449
|
-
id: t.id,
|
|
2450
|
-
clipId: t.clipId,
|
|
2451
|
-
name: t.name,
|
|
2452
|
-
kind: t.kind,
|
|
2453
|
-
status: t.status,
|
|
2454
|
-
sourceStartMs: t.sourceStartMs,
|
|
2455
|
-
sourceEndMs: t.sourceEndMs,
|
|
2456
|
-
sampleCount: t.samples.length,
|
|
2457
|
-
confidence: t.confidence
|
|
2458
|
-
}))
|
|
2459
|
-
};
|
|
2460
|
-
}),
|
|
2461
|
-
tool("ui_timeline_delete_track_object", "Delete a subject/object track and unbind every clip that was " +
|
|
2462
|
-
"following it — a clip left pointing at a track that is gone reads " +
|
|
2463
|
-
"as bound while following nothing.", z.object({
|
|
2464
|
-
trackId: z.string().describe("A track id, from list_tracks.")
|
|
2465
|
-
}), async ({ trackId }) => {
|
|
2466
|
-
const track = mediaTracks.find((t) => t.id === trackId);
|
|
2467
|
-
if (!track) {
|
|
2468
|
-
throw new Error(`No track "${trackId}". ` + validUnits(mediaTracks, "track"));
|
|
2469
|
-
}
|
|
2470
|
-
mediaTracks = mediaTracks.filter((t) => t.id !== trackId);
|
|
2471
|
-
clips = clips.map((c) => {
|
|
2472
|
-
if (c.trackBinding?.trackId !== trackId)
|
|
2473
|
-
return c;
|
|
2474
|
-
const { trackBinding: _dropped, ...rest } = c;
|
|
2475
|
-
return rest;
|
|
2476
|
-
});
|
|
2477
|
-
return { ok: true, deleted: { id: track.id, name: track.name } };
|
|
2478
|
-
}),
|
|
2479
|
-
tool("ui_timeline_bind_to_track", "Make a clip follow a subject/object track, so its position moves " +
|
|
2480
|
-
'with the tracked subject. Only "position" and "position_scale" are ' +
|
|
2481
|
-
"implemented; the other modes are named by the document format for a " +
|
|
2482
|
-
"later phase and are refused here rather than silently doing nothing.", z.object({
|
|
2483
|
-
target: z.string().describe("Clip id or name."),
|
|
2484
|
-
trackId: z.string().describe("A track id, from list_tracks."),
|
|
2485
|
-
mode: z
|
|
2486
|
-
.string()
|
|
2487
|
-
.describe('"position" or "position_scale".'),
|
|
2488
|
-
offset: z
|
|
2489
|
-
.object({ x: z.number(), y: z.number() })
|
|
2490
|
-
.optional()
|
|
2491
|
-
.describe("Canvas-pixel offset added on top of the tracked point."),
|
|
2492
|
-
scale: z
|
|
2493
|
-
.number()
|
|
2494
|
-
.optional()
|
|
2495
|
-
.describe('Scale multiplier, "position_scale" only.'),
|
|
2496
|
-
rotationOffset: z
|
|
2497
|
-
.number()
|
|
2498
|
-
.optional()
|
|
2499
|
-
.describe("Radians added to the clip's rotation."),
|
|
2500
|
-
smoothing: z
|
|
2501
|
-
.number()
|
|
2502
|
-
.optional()
|
|
2503
|
-
.describe("0..1; higher follows the track more loosely.")
|
|
2504
|
-
}), async ({ target, trackId, mode, offset, scale, rotationOffset, smoothing }) => {
|
|
2505
|
-
const clip = resolveClip(target);
|
|
2506
|
-
if (!mediaTracks.some((t) => t.id === trackId)) {
|
|
2507
|
-
throw new Error(`No track "${trackId}". ` + validUnits(mediaTracks, "track"));
|
|
2508
|
-
}
|
|
2509
|
-
if (mode !== "position" && mode !== "position_scale") {
|
|
2510
|
-
throw new Error(`bind_to_track mode "${String(mode)}" is not implemented yet — ` +
|
|
2511
|
-
`only "position" and "position_scale" affect rendering in this ` +
|
|
2512
|
-
"build. The field accepts the other modes so a document can " +
|
|
2513
|
-
"name the intent without a schema migration later, but binding " +
|
|
2514
|
-
"to one today would be a no-op, so the call is refused instead.");
|
|
2515
|
-
}
|
|
2516
|
-
const binding = {
|
|
2517
|
-
trackId: trackId,
|
|
2518
|
-
mode: mode
|
|
2519
|
-
};
|
|
2520
|
-
if (offset !== undefined) {
|
|
2521
|
-
binding.offset = offset;
|
|
2522
|
-
}
|
|
2523
|
-
if (scale !== undefined)
|
|
2524
|
-
binding.scale = scale;
|
|
2525
|
-
if (rotationOffset !== undefined) {
|
|
2526
|
-
binding.rotationOffset = rotationOffset;
|
|
2527
|
-
}
|
|
2528
|
-
if (smoothing !== undefined)
|
|
2529
|
-
binding.smoothing = smoothing;
|
|
2530
|
-
const next = { ...clip, trackBinding: binding };
|
|
2531
|
-
clips = clips.map((c) => (c.id === clip.id ? next : c));
|
|
2532
|
-
return { ok: true, clip: serializeClip(next) };
|
|
2533
|
-
}),
|
|
2534
|
-
tool("ui_timeline_unbind_track", "Stop a clip following a track. A clip that follows none is a no-op.", z.object({ target: z.string().describe("Clip id or name.") }), async ({ target }) => {
|
|
2535
|
-
const clip = resolveClip(target);
|
|
2536
|
-
if (!clip.trackBinding)
|
|
2537
|
-
return { ok: true, clip: serializeClip(clip) };
|
|
2538
|
-
const { trackBinding: _dropped, ...next } = clip;
|
|
2539
|
-
clips = clips.map((c) => (c.id === clip.id ? next : c));
|
|
2540
|
-
return { ok: true, clip: serializeClip(next) };
|
|
2541
|
-
}),
|
|
2542
|
-
sharedTool("ui_timeline_retarget_format", async ({ aspect_ratio, strategy, safe_margin, track_ids }) => {
|
|
2543
|
-
if (track_ids) {
|
|
2544
|
-
for (const [clipId, trackId] of Object.entries(track_ids)) {
|
|
2545
|
-
const clip = clips.find((candidate) => candidate.id === clipId);
|
|
2546
|
-
if (!clip) {
|
|
2547
|
-
throw new Error(`track_ids names unknown clip "${clipId}". ` +
|
|
2548
|
-
validUnits(clips, "clip"));
|
|
2549
|
-
}
|
|
2550
|
-
const track = mediaTracks.find((candidate) => candidate.id === trackId);
|
|
2551
|
-
if (!track || track.clipId !== clip.id) {
|
|
2552
|
-
throw new Error(`Track "${trackId}" does not belong to clip "${clip.name}". ` +
|
|
2553
|
-
validUnits(mediaTracks.filter((candidate) => candidate.clipId === clip.id), "track"));
|
|
2554
|
-
}
|
|
2555
|
-
if (track.status !== "ready") {
|
|
2556
|
-
throw new Error(`Track "${track.name}" is ${track.status}; a format adaptation can only follow a ready track.`);
|
|
2557
|
-
}
|
|
2558
|
-
}
|
|
2559
|
-
}
|
|
2560
|
-
const now = new Date().toISOString();
|
|
2561
|
-
const source = {
|
|
2562
|
-
id: sequenceId,
|
|
2563
|
-
projectId,
|
|
2564
|
-
name: sequenceName,
|
|
2565
|
-
fps,
|
|
2566
|
-
width,
|
|
2567
|
-
height,
|
|
2568
|
-
durationMs: clips.reduce((end, clip) => Math.max(end, clip.startMs + clip.durationMs), 0),
|
|
2569
|
-
tracks: tracks.map((track) => structuredClone(track)),
|
|
2570
|
-
clips: clips.map((clip) => structuredClone(clip)),
|
|
2571
|
-
markers: markers.map((marker) => structuredClone(marker)),
|
|
2572
|
-
mediaTracks: mediaTracks.map((track) => structuredClone(track)),
|
|
2573
|
-
createdAt: now,
|
|
2574
|
-
updatedAt: now
|
|
2575
|
-
};
|
|
2576
|
-
if (seed?.transcript !== undefined) {
|
|
2577
|
-
source.transcript = structuredClone(seed.transcript);
|
|
2578
|
-
}
|
|
2579
|
-
if (seed?.scriptEnabled !== undefined) {
|
|
2580
|
-
source.scriptEnabled = seed.scriptEnabled;
|
|
2581
|
-
}
|
|
2582
|
-
if (seed?.templateId !== undefined)
|
|
2583
|
-
source.templateId = seed.templateId;
|
|
2584
|
-
if (tempo)
|
|
2585
|
-
source.tempo = structuredClone(tempo);
|
|
2586
|
-
source.camera2d = structuredClone(seed?.camera2d ?? null);
|
|
2587
|
-
if (setup)
|
|
2588
|
-
source.setup = structuredClone(setup);
|
|
2589
|
-
const adapted = adaptSequenceFormat(source, aspect_ratio, {
|
|
2590
|
-
strategy: strategy,
|
|
2591
|
-
safeMargin: safe_margin,
|
|
2592
|
-
trackIdByClipId: track_ids
|
|
2593
|
-
});
|
|
2594
|
-
const stored = retargetFormat
|
|
2595
|
-
? await retargetFormat(structuredClone(adapted.sequence))
|
|
2596
|
-
: { sequenceId: adapted.sequence.id, name: adapted.sequence.name };
|
|
2597
|
-
derivedSequences.push(structuredClone(adapted.sequence));
|
|
2598
|
-
return {
|
|
2599
|
-
ok: true,
|
|
2600
|
-
sourceSequenceId: sequenceId,
|
|
2601
|
-
sequenceId: stored.sequenceId,
|
|
2602
|
-
name: stored.name ?? adapted.sequence.name,
|
|
2603
|
-
width: adapted.sequence.width,
|
|
2604
|
-
height: adapted.sequence.height,
|
|
2605
|
-
strategy,
|
|
2606
|
-
croppedClipIds: adapted.croppedClipIds
|
|
2607
|
-
};
|
|
2608
|
-
}),
|
|
2609
|
-
sharedTool("ui_timeline_set_reframe_subject", async ({ clip_id, track_id, safe_margin, smoothing }) => {
|
|
2610
|
-
const clip = resolveClip(clip_id);
|
|
2611
|
-
const track = mediaTracks.find((candidate) => candidate.id === track_id);
|
|
2612
|
-
if (!track || track.clipId !== clip.id) {
|
|
2613
|
-
throw new Error(`No track "${String(track_id)}" belongs to clip "${clip.name}". ` +
|
|
2614
|
-
validUnits(mediaTracks.filter((candidate) => candidate.clipId === clip.id), "track"));
|
|
2615
|
-
}
|
|
2616
|
-
if (track.status !== "ready") {
|
|
2617
|
-
throw new Error(`Track "${track.name}" is ${track.status}; Smart Reframe can only follow a ready track.`);
|
|
2618
|
-
}
|
|
2619
|
-
if (!mediaTrackCanDriveReframe(track) ||
|
|
2620
|
-
isMediaTrackStale(track, clip)) {
|
|
2621
|
-
throw new Error(`Track "${track.name}" has no current analysis for clip "${clip.name}".`);
|
|
2622
|
-
}
|
|
2623
|
-
const previous = clip.reframe;
|
|
2624
|
-
const reframe = {
|
|
2625
|
-
mode: "track",
|
|
2626
|
-
trackId: track.id,
|
|
2627
|
-
sourceAssetId: clip.currentAssetId
|
|
2628
|
-
};
|
|
2629
|
-
if (safe_margin !== undefined)
|
|
2630
|
-
reframe.safeMargin = safe_margin;
|
|
2631
|
-
else if (previous?.safeMargin !== undefined) {
|
|
2632
|
-
reframe.safeMargin = previous.safeMargin;
|
|
2633
|
-
}
|
|
2634
|
-
if (smoothing !== undefined)
|
|
2635
|
-
reframe.smoothing = smoothing;
|
|
2636
|
-
else if (previous?.smoothing !== undefined) {
|
|
2637
|
-
reframe.smoothing = previous.smoothing;
|
|
2638
|
-
}
|
|
2639
|
-
if (previous?.keyframes) {
|
|
2640
|
-
reframe.keyframes = structuredClone(previous.keyframes);
|
|
2641
|
-
}
|
|
2642
|
-
if (previous?.sourceWidth !== undefined) {
|
|
2643
|
-
reframe.sourceWidth = previous.sourceWidth;
|
|
2644
|
-
}
|
|
2645
|
-
if (previous?.sourceHeight !== undefined) {
|
|
2646
|
-
reframe.sourceHeight = previous.sourceHeight;
|
|
2647
|
-
}
|
|
2648
|
-
const next = { ...clip, reframe };
|
|
2649
|
-
clips = clips.map((candidate) => candidate.id === clip.id ? next : candidate);
|
|
2650
|
-
return { ok: true, clip: serializeClip(next) };
|
|
2651
|
-
}),
|
|
2652
|
-
sharedTool("ui_timeline_add_reframe_keyframe", async ({ clip_id, source_ms, x, y, zoom }) => {
|
|
2653
|
-
const clip = resolveClip(clip_id);
|
|
2654
|
-
if (!clip.reframe) {
|
|
2655
|
-
throw new Error(`Clip "${clip.name}" has no Smart Reframe path. Retarget the format or set_reframe_subject first.`);
|
|
2656
|
-
}
|
|
2657
|
-
const keyframe = {
|
|
2658
|
-
sourceMs: source_ms,
|
|
2659
|
-
x: x,
|
|
2660
|
-
y: y
|
|
2661
|
-
};
|
|
2662
|
-
if (zoom !== undefined) {
|
|
2663
|
-
keyframe.zoom = zoom;
|
|
2664
|
-
}
|
|
2665
|
-
const next = {
|
|
2666
|
-
...clip,
|
|
2667
|
-
reframe: addReframeKeyframe(clip.reframe, keyframe)
|
|
2668
|
-
};
|
|
2669
|
-
clips = clips.map((candidate) => candidate.id === clip.id ? next : candidate);
|
|
2670
|
-
return { ok: true, clip: serializeClip(next), keyframe };
|
|
2671
|
-
}),
|
|
2672
|
-
sharedTool("ui_timeline_clear_reframe", async ({ clip_id }) => {
|
|
2673
|
-
const clip = resolveClip(clip_id);
|
|
2674
|
-
const next = clearReframe(clip);
|
|
2675
|
-
clips = clips.map((candidate) => candidate.id === clip.id ? next : candidate);
|
|
2676
|
-
return { ok: true, clip: serializeClip(next) };
|
|
2677
|
-
})
|
|
2678
|
-
];
|
|
2679
|
-
// Opt-in, and outside the `ui_timeline_` prefix on purpose: `edit_timeline`
|
|
2680
|
-
// builds this same bridge and matches its ops by that prefix, so a preview
|
|
2681
|
-
// tool it can never call has no business in its op catalogue. A case that
|
|
2682
|
-
// grades "did it look at the frame" asks for it.
|
|
2683
|
-
if (initial.preview) {
|
|
2684
|
-
tools.push(tool("preview_timeline_frame", "Look at the sequence at one or more timecodes: what is on screen, layered top of the stack first, with each layer's opacity and track index. This surface has no rasterizer, so it reports the layer stack rather than pixels — enough to see a layer that is covered, missing, or drawing nothing. Call it after an edit, before you say you are done.", z.object({
|
|
2685
|
-
times_ms: z
|
|
2686
|
-
.array(z.number().nonnegative())
|
|
2687
|
-
.min(1)
|
|
2688
|
-
.max(8)
|
|
2689
|
-
.describe("Absolute timeline positions to look at, in ms.")
|
|
2690
|
-
}), async ({ times_ms }) => {
|
|
2691
|
-
const frames = times_ms.map((timeMs) => ({
|
|
2692
|
-
time_ms: timeMs,
|
|
2693
|
-
layers: computeActiveLayers(tracks, clips, timeMs)
|
|
2694
|
-
.map((layer) => ({
|
|
2695
|
-
clip_id: layer.clipId,
|
|
2696
|
-
clip_name: clips.find((c) => c.id === layer.clipId)?.name ?? layer.clipId,
|
|
2697
|
-
kind: layer.kind,
|
|
2698
|
-
track_index: layer.trackIndex,
|
|
2699
|
-
z_index: 1000 - layer.trackIndex,
|
|
2700
|
-
opacity: layer.opacity,
|
|
2701
|
-
text: layer.textStyle?.text
|
|
2702
|
-
}))
|
|
2703
|
-
// Top of the stack first, the order the skill's report describes.
|
|
2704
|
-
.sort((a, b) => b.z_index - a.z_index)
|
|
2705
|
-
}));
|
|
2706
|
-
previewTimesMs.push(...times_ms);
|
|
2707
|
-
for (const frame of frames) {
|
|
2708
|
-
for (const layer of frame.layers) {
|
|
2709
|
-
previewedLayerKinds.add(layer.kind);
|
|
2710
|
-
}
|
|
2711
|
-
}
|
|
2712
|
-
return { ok: true, width, height, frames };
|
|
2713
|
-
}));
|
|
2714
|
-
}
|
|
2715
|
-
// Recorded rather than counted: a predicate asks where the last edit sits
|
|
2716
|
-
// relative to a preview, which a tally cannot answer. The push happens
|
|
2717
|
-
// before the call, so a tool that throws is still in the transcript.
|
|
2718
|
-
const recorded = tools.map((entry) => ({
|
|
2719
|
-
...entry,
|
|
2720
|
-
execute: (args) => {
|
|
2721
|
-
toolLog.push(entry.name);
|
|
2722
|
-
return entry.execute(args);
|
|
2723
|
-
}
|
|
2724
|
-
}));
|
|
2725
|
-
return {
|
|
2726
|
-
tools: recorded,
|
|
2727
|
-
finalState: () => ({
|
|
2728
|
-
fps,
|
|
2729
|
-
width,
|
|
2730
|
-
height,
|
|
2731
|
-
durationMs: clips.reduce((m, c) => Math.max(m, c.startMs + c.durationMs), 0),
|
|
2732
|
-
playheadMs,
|
|
2733
|
-
tracks: tracks.map((t) => ({
|
|
2734
|
-
id: t.id,
|
|
2735
|
-
name: t.name,
|
|
2736
|
-
type: t.type,
|
|
2737
|
-
index: t.index
|
|
2738
|
-
})),
|
|
2739
|
-
clips: clips.map((c) => ({
|
|
2740
|
-
id: c.id,
|
|
2741
|
-
name: c.name,
|
|
2742
|
-
trackId: c.trackId,
|
|
2743
|
-
mediaType: c.mediaType,
|
|
2744
|
-
startMs: c.startMs,
|
|
2745
|
-
durationMs: c.durationMs,
|
|
2746
|
-
prompt: c.prompt,
|
|
2747
|
-
animations: (c.animations ?? []).map((a) => ({
|
|
2748
|
-
role: a.role,
|
|
2749
|
-
preset: a.preset
|
|
2750
|
-
}))
|
|
2751
|
-
})),
|
|
2752
|
-
documentTracks: tracks.map((t) => structuredClone(t)),
|
|
2753
|
-
documentClips: clips.map((c) => structuredClone(c)),
|
|
2754
|
-
markers: markers.map((m) => structuredClone(m)),
|
|
2755
|
-
mediaTracks: mediaTracks.map((t) => structuredClone(t)),
|
|
2756
|
-
derivedSequences: derivedSequences.map((sequence) => structuredClone(sequence)),
|
|
2757
|
-
tempo: tempo ? structuredClone(tempo) : undefined,
|
|
2758
|
-
camera2d: structuredClone(seed?.camera2d ?? null),
|
|
2759
|
-
setup: setup ? structuredClone(setup) : null,
|
|
2760
|
-
transitionCandidates: structuredClone(transitionCandidates),
|
|
2761
|
-
auditionedTransitionCandidateId,
|
|
2762
|
-
appliedTransitionCandidates: structuredClone(appliedTransitionCandidates),
|
|
2763
|
-
toolLog: [...toolLog],
|
|
2764
|
-
previewTimesMs: [...previewTimesMs],
|
|
2765
|
-
previewedLayerKinds: [...previewedLayerKinds]
|
|
2766
|
-
})
|
|
2767
|
-
};
|
|
2768
|
-
}
|
|
2769
155
|
const TIMELINE_SYSTEM_PROMPT = `You are an assistant driving a timeline / video editor through UI tools.
|
|
2770
156
|
|
|
2771
157
|
Use the ui_timeline_* tools to inspect and modify the sequence:
|
|
@@ -2813,7 +199,10 @@ export const TIMELINE_TOOL_LOOP_CASES = [
|
|
|
2813
199
|
createBridge: () => createTimelineToolBridge(),
|
|
2814
200
|
systemPrompt: TIMELINE_SYSTEM_PROMPT,
|
|
2815
201
|
expect: {
|
|
2816
|
-
requiredTools: [
|
|
202
|
+
requiredTools: [
|
|
203
|
+
"ui_timeline_add_text_clip",
|
|
204
|
+
"ui_timeline_animate_clip"
|
|
205
|
+
],
|
|
2817
206
|
ordering: [["ui_timeline_add_text_clip", "ui_timeline_animate_clip"]],
|
|
2818
207
|
noErrorResults: true,
|
|
2819
208
|
minToolCalls: 2,
|
|
@@ -3252,8 +641,7 @@ export const TIMELINE_TOOL_LOOP_CASES = [
|
|
|
3252
641
|
const active = clip?.versions?.find((take) => take.id === clip.activeTakeId);
|
|
3253
642
|
return (clip?.startMs === 1200 &&
|
|
3254
643
|
clip.durationMs === 4000 &&
|
|
3255
|
-
clip.currentAssetId?.startsWith("generative://clip-trimmed/") ===
|
|
3256
|
-
true &&
|
|
644
|
+
clip.currentAssetId?.startsWith("generative://clip-trimmed/") === true &&
|
|
3257
645
|
clip.inPointMs === 0 &&
|
|
3258
646
|
clip.outPointMs === 4000 &&
|
|
3259
647
|
active?.mediaEdit?.sourceContext.sourceStartMs === 40000 &&
|