diff --git a/SKILL/SKILL.md b/SKILL/SKILL.md index 01439f2..2bfc444 100644 --- a/SKILL/SKILL.md +++ b/SKILL/SKILL.md @@ -1,287 +1,201 @@ --- name: ae-mcp -description: After Effects automation through Model Context Protocol. Use when users request creating, modifying, or animating content in Adobe After Effects, including compositions, layers, keyframes, effects, expressions, text animations, lower thirds, title cards, logo reveals, or any motion graphics tasks. Trigger on phrases like "create in After Effects", "animate this", "make a motion graphic", "add keyframes", "apply effects", or when users mention AE-specific concepts like compositions, precomps, expressions, or time remapping. +description: Drive Adobe After Effects through the ae-mcp tools. Use when the user wants to create, edit, animate or render anything in After Effects - compositions, text, shape layers and bezier paths, trim paths and other shape operators, masks, keyframes and easing, expressions, effects and particles (CC Particle World, Particle Systems II, Particle Playground), 3D layers with cameras, lights and extrusion, lower thirds, title cards, transitions, logo reveals, markers, and the render queue or Adobe Media Encoder. Trigger on phrases like "in After Effects", "make a motion graphic", "animate this", "add keyframes", "draw on a line", "mask this", "extrude the text", "render this comp", or AE terms like comp, precomp, expression, trim paths, render queue. --- -# After Effects MCP Skill +# After Effects MCP -## Overview +These tools send ExtendScript to After Effects through a CEP panel. **The AE-MCP panel must be open** (Window > Extensions > AE-MCP). If every call times out, ask the user to open it. -This skill enables automation of Adobe After Effects through MCP tools. It covers project management, composition creation, layer manipulation, animation, effects, and expressions. +For worked recipes, read `examples.md` in this skill. -## Critical Property Path Formats +## Before you act -After Effects properties require exact naming. Common patterns: +- **Ask before calling tools that change the open project as a whole**: `save_project`, `close_project`, `open_project`, `create_project`, `reduce_project`, `collect_files`, `organize_project_items`, `import_footage`, `import_folder`, `replace_footage`. The user usually has real work open. +- **Look before you edit.** Call `list_compositions`, `list_layers` or `get_layer_info` rather than guessing names. +- **Each tool call runs inside an undo group.** Prefer a few purposeful calls over many tiny ones, so the user can undo cleanly. +- Times are in **seconds**. Colours are **0-1** RGB objects: `{"r": 1, "g": 0.5, "b": 0}`. -**Transform properties:** -- Position: `"Position"` -- Scale: `"Scale"` -- Rotation: `"Rotation"` or `"Z Rotation"` -- Opacity: `"Opacity"` -- Anchor Point: `"Anchor Point"` +## Property paths -**Text properties:** -- Source Text: `"Source Text"` -- For text animators: Use relative paths like `"Opacity"` within animator context +Keyframe, expression and effect-property tools take a `property` string. -**Shape layer properties:** -- Position within shape group: `"Contents.Shape 1.Transform.Position"` -- Scale within shape group: `"Contents.Shape 1.Transform.Scale"` +**1. Prefer the camelCase shortcuts.** They resolve to internal match names, so they also work on non-English installs: -**Effect properties:** -- Effect parameter: `"Effects.Gaussian Blur.Blurriness"` +| Shortcut | Property | +|---|---| +| `position`, `anchorPoint`, `scale`, `opacity` | Transform | +| `rotation` | Rotation (Z rotation on 3D layers) | +| `rotationX`, `rotationY`, `rotationZ` | 3D rotation | +| `sourceText` | Text layer Source Text | +| `audioLevels` | Audio levels | +| `pointOfInterest` | Camera point of interest (AE stores it as the camera's anchor point) | +| `zoom`, `focusDistance`, `aperture` | Camera options | +| `lightIntensity`, `lightColor`, `coneAngle`, `coneFeather`, `castsShadows`, `shadowDarkness`, `shadowDiffusion` | Light options | -## Value Formats by Property Type +English display names (`"Position"`, `"Rotation"`, `"Source Text"`) also work on an English install. -Different properties require specific value formats: - -**Position**: `{"x": 960, "y": 540}` or `[960, 540]` -**Scale**: `[100, 100]` (percentages) -**Rotation**: `45` (degrees) -**Opacity**: `100` (percentage, 0-100) -**Color (RGB 0-1 range)**: `{"r": 1.0, "g": 0.5, "b": 0.0}` -**Source Text**: String value - -## Common Workflows - -### 1. Creating a Basic Animated Scene +**2. For nested properties, separate levels with `/` - never `.`.** ``` -1. Create project (optional name) -2. Create composition with specs (width, height, duration, frameRate) -3. Add layers (solid, shape, text, AV) -4. Set initial properties (position, scale, opacity) -5. Add keyframes at different times -6. Apply easing for smooth motion -7. Save project +Effects/Gaussian Blur/Blurriness +Effects/Gradient Ramp/Start Color +Contents/Group 1/Transform/Position +Contents/Group 1/Contents/Trim Paths 1/End ``` -### 2. Text Animation Pattern - -``` -1. Add text layer with content -2. Set text properties (fontSize, color, position, justification) -3. Use create_text_animator for preset animations: - - typewriter - - fadeInChars - - scaleInChars - - slideInChars - - randomize - - wave -4. Or manually add keyframes to text properties -``` - -### 3. Effect Application Pattern - -``` -1. Apply effect to layer using apply_effect -2. Modify effect properties using modify_effect_properties -3. Or use apply_effect_template for preset effects: - - gaussianBlur, directionalBlur, glassBlur - - curves, colorBalance, brightnessContrast, vibrance - - glow, dropShadow, vignette - - cinematicLook, vhsRetro, neonGlow, filmGrain - - chromaticAberration, duotone -``` - -### 4. Expression-Based Animation - -``` -1. Set expression on property using set_expression -2. Common expression templates available via apply_expression_template: - - wiggle, wiggleSmooth, wiggleFadeIn, wiggleFadeOut - - loopCycle, loopPingpong, loopOffset, loopContinue - - time, clock, countdown, frameNumber - - matchPosition, offsetPosition, inverseRotation - - followPath, bounce, inertia, overshoot, springy -3. Link properties between layers using link_properties -``` - -## Key Tool Behaviors and Gotchas - -### Keyframe Setting - -**set_keyframe** - Basic keyframe setting: -- Use for simple keyframes without easing -- Value format must match property type -- Time in seconds (not frames) - -**set_keyframe_advanced** - With easing control: -- inType/outType: `LINEAR`, `BEZIER`, `HOLD` -- inEase/outEase: `{speed: number, influence: number}` -- Use for smooth, professional motion - -**apply_easy_ease** - Quick easing application: -- Apply to existing keyframes -- Type: `IN`, `OUT`, `BOTH` -- Can target specific keyframe by index or all keyframes - -### Layer References - -Layers can be identified by: -- `layerName`: Preferred for clarity -- `layerIndex`: 1-based (1 = top layer) - -When modifying layers, always verify layer exists first via list_layers or get_layer_info. - -### Composition Management - -**Creating compositions:** -- Specify complete dimensions and timing -- backgroundColor uses 0-1 RGB values -- frameRate commonly: 24, 30, 60 - -**Modifying compositions:** -- Can change any parameter after creation -- Changes affect all nested instances - -### Shape Layer Limitations - -Shape layers have complex property hierarchies: -- Must navigate through Contents group -- Transform properties nested within shape groups -- Some properties may be hidden initially - -When shape operations fail with "property is hidden" error, the layer structure may need manual adjustment or different approach. - -### Pre-built Templates - -**Lower thirds** (create_lower_third): -- Styles: modern, corporate, news, minimal, social -- Position: bottomLeft, bottomRight, bottomCenter -- Automatically creates text and animated background - -**Title cards** (create_title_card): -- Styles: cinematic, documentary, social, minimal -- Full-screen animated title sequences - -**Transitions** (create_transition): -- Types: wipe_left, wipe_right, wipe_up, wipe_down, dissolve, push, slide, zoom -- Duration and easing customizable - -**Logo reveals** (create_logo_reveal): -- Requires imported logo footage -- Styles: fade, scale, slide, spin, glitch, particle - -### Expression Best Practices +A dotted path such as `Effects.Gaussian Blur.Blurriness` is looked up as one property with that literal name and fails with "Property not found". + +**3. Shape groups created by these tools are named `Group 1`**, not `Shape 1` (that name only appears on shapes drawn by hand with the Pen tool). Operators live *inside* the group's `Contents`, so Trim Paths is `Contents/Group 1/Contents/Trim Paths 1/...`. + +For camera, light and material properties, use the dedicated `set_*_options` tools instead of paths - they report which properties the current renderer refused. + +## Value formats + +| Property | Format | +|---|---| +| Position | `[960, 540]`, `[x, y, z]` on 3D layers, or `{"x": 960, "y": 540}` | +| Scale | `[100, 100]` (percent) | +| Rotation | `45` (degrees) | +| Opacity | `100` (0-100) | +| Shape fill / stroke colour | `{"r", "g", "b"}` | +| Effect colour parameter via `set_keyframe` | `[r, g, b, a]`, 0-1 | + +**Enum values are After Effects constants, in capitals.** `modify_layer` `blendMode` takes `NORMAL`, `SCREEN`, `MULTIPLY`, `ADD`, `OVERLAY` and so on - `"Screen"` fails with "Value is undefined". Text `justification` is `LEFT` / `CENTER` / `RIGHT`; `apply_easy_ease` `type` is `IN` / `OUT` / `BOTH`. + +## Tool map + +| Area | Tools | +|---|---| +| Project | `get_project_info`, `list_project_items`, `find_missing_footage`, `set_proxy`, `remove_proxy`, plus the project-wide tools listed under "Before you act" | +| Compositions | `create_composition`, `modify_composition`, `duplicate_composition`, `delete_composition`, `list_compositions`, `get_composition_info`, `get_comp_report`, `set_active_composition`, `set_work_area`, `render_frame` | +| Layers | `add_solid_layer`, `add_text_layer`, `add_text_layer_advanced`, `add_shape_layer`, `add_null_layer`, `add_adjustment_layer`, `add_camera_layer`, `add_light_layer`, `add_av_layer`, `modify_layer`, `delete_layer`, `list_layers`, `get_layer_info`, `precompose_layers` (see Known issues) | +| Shape paths | `create_path`, `get_path`, `set_path_keyframes`, `add_shape_operator` | +| Masks | `add_mask`, `list_masks`, `get_mask_path`, `set_mask_path`, `set_mask_keyframes`, `set_mask_properties`, `delete_mask` | +| 3D | `set_comp_renderer`, `get_3d_info`, `set_3d_layer`, `set_material_options`, `set_geometry_options`, `set_camera_options`, `set_light_options` | +| Keyframes | `set_keyframe`, `set_keyframe_advanced`, `get_keyframes`, `apply_easy_ease`, `set_temporal_ease`, `offset_keyframes`, `scale_keyframe_timing`, `reverse_keyframes`, `copy_keyframes` | +| Expressions | `set_expression`, `get_expression`, `remove_expression`, `enable_expression`, `batch_set_expressions`, `add_expression_control`, `apply_expression_template`, `link_properties` | +| Effects | `apply_effect`, `apply_effect_template`, `modify_effect_properties`, `remove_effect`, `reorder_effects`, `copy_effects`, `list_effects` | +| Templates | `create_lower_third`, `create_title_card`, `create_transition`, `create_logo_reveal`, `create_text_animator` | +| Markers and time | `add_composition_marker`, `add_layer_marker`, `get_markers`, `delete_marker`, `get_current_time`, `set_current_time`, `snap_to_marker`, `get_nearest_marker`, `navigate_markers` | +| Render | `add_to_render_queue`, `list_render_queue`, `list_render_templates`, `set_render_queue_item`, `remove_from_render_queue`, `control_render`, `queue_in_ame` | -1. **Use templates when possible**: apply_expression_template provides tested, working expressions -2. **Property references**: Use `thisLayer.transform.position` not just `position` -3. **Time-based**: Use `time` variable for continuous animation -4. **Value references**: Use `value` to reference current property value +**Not available** - say so rather than improvising: duplicating a layer, time remapping, the motion-blur switch, track mattes, layer styles, and setting gradient colours. -### Common Errors and Solutions +## Keyframes -**"Property not found"** -- Verify exact property name spelling -- Check if property path includes parent groups -- Use get_layer_info to see available properties +- `set_keyframe` - value at a time. `set_keyframe_advanced` adds `inType` / `outType` (`LINEAR`, `BEZIER`, `HOLD`) and `inEase` / `outEase` as `{"speed": 0, "influence": 33}`. +- `apply_easy_ease` - `keyframeIndex` for one key, omit it for all. Works on Position. +- `set_temporal_ease` - `keyframeIndex`, `inSpeed`, `inInfluence`, `outSpeed`, `outInfluence`. +- `offset_keyframes` - `offset` in seconds. Refuses, changing nothing, if any key would move before 0. +- `scale_keyframe_timing` - **`scale`** (must be > 0; 2 doubles the timing) and optional `anchorTime`. Refuses if keys would move before 0 or merge onto one frame. +- `copy_keyframes` - `sourceLayerName`, `sourceProperty`, `targetLayerName`, `targetProperty`, `timeOffset`. + +If one of these refuses with "Nothing was changed", the keyframes are untouched - fix the input and retry. + +## Shape layers and paths + +- `add_shape_layer` - `shape`: `rectangle` (supports `roundness`), `ellipse`, `polygon`, `star` (`points`, `outerRadius`, `innerRadius`). +- `create_path` - a real editable bezier path. `vertices` are `[x, y]` points relative to the layer, which the tool places at the comp centre. **`inTangents` / `outTangents` are relative to their own vertex**, not absolute. Omit them for corner points. `closed: false` for an open line. +- `set_path_keyframes` - each keyframe carries its own full vertex list. Keep the vertex count the same between keys for clean morphing. +- `add_shape_operator` - `operator` is one of `trimPaths`, `repeater`, `roundCorners`, `zigZag`, `twist`, `puckerBloat`, `offsetPaths`, `wigglePaths`, `wiggleTransform`, `mergePaths`, `gradientFill`, `gradientStroke`. Set values with friendly keys, for example `{"start": 0, "end": 50}` for trim paths or `{"copies": 6}` for a repeater. To animate an operator, keyframe it through its path (see Property paths). +- **Gradient colour stops cannot be set by script.** After Effects exposes them as read-only, so gradients render with default colours. Tell the user to set the colours by hand. + +## Masks + +- `add_mask` - optional `vertices` / tangents in the layer's own pixel space, where `(0, 0)` is the layer's **top-left** corner. Also `mode` (`none`, `add`, `subtract`, `intersect`, `lighten`, `darken`, `difference`), `inverted`, `feather` (pixels), `opacity`, `expansion`. +- `set_mask_keyframes` animates the path; `set_mask_properties` changes settings and works through a lock. +- Identify masks by `maskName` or 1-based `maskIndex`. With neither, the first mask is used. + +## 3D + +1. **Check the renderer first** with `get_3d_info`. New comps use the Classic renderer. Its internal name is `ADBE Advanced 3d` - that is *not* Advanced 3D. +2. `set_comp_renderer` takes `classic`, `advanced` or `cinema4d`. Each exposes a **different** set of properties; newer is not simply better: + +| Feature | classic | advanced | cinema4d | +|---|---|---|---| +| Extrusion and bevel | no | yes | yes | +| Camera blur level, iris, highlights | yes | no | no | +| Depth of field, focus distance, aperture | yes | yes | no | +| Shadow colour, focus area width | no | yes | no | +| Reflections | no | no | yes | + +3. `set_3d_layer` with `enable3D: true` before setting 3D properties. +4. Extrusion (`set_geometry_options` `extrusionDepth`, `bevelDepth`, `bevelStyle`) works on **text and shape layers only**, never solids. +5. The `set_*_options` tools apply what they can and return `applied` and `failed`. Each failure lists `availableUnderRenderers`. Read it and switch renderer, or tell the user the property isn't available - don't assume the call fully succeeded. + +## Templates + +- `create_lower_third` - `style` (`modern`, `corporate`, `news`, `minimal`, `social`), `position` (`bottomLeft`, `bottomRight`, `bottomCenter`). **`name` is the name of the precomp it builds**; `title` is the main line and `subtitle` the second. It adds one precomp layer to the target comp. +- `create_title_card` - `style`: `cinematic`, `documentary`, `social`, `minimal`. +- `create_transition` - `type`: `wipe_left`, `wipe_right`, `wipe_up`, `wipe_down`, `dissolve`, `push`, `slide`, `zoom`; `easing`: `linear`, `easeIn`, `easeOut`, `easeInOut`. +- `create_logo_reveal` - needs an **already imported** logo (`logoItemName` or `logoItemId`). `style`: `fade`, `scale`, `slide`, `spin`, `glitch`, `particle`. `particle` assembles the logo out of particles with CC Scatterize and fires a soft CC Particle Systems II burst as it lands. For text, put the text in its own comp first and pass that comp as `logoItemName`. The burst goes on its own solid, "Logo Particles", and the result says whether it was added. +- `create_text_animator` - needs a text layer (`layerName`); `animatorType`: `typewriter`, `fadeInChars`, `scaleInChars`, `slideInChars`, `randomize`, `wave`; optional `duration`, `delay`. + +## Effects and expressions + +- `apply_effect` - `effect` is the effect's English name (`"Gaussian Blur"`, `"Glow"`, `"Gradient Ramp"`, `"Turbulent Displace"`) or its match name (`"ADBE Gaussian Blur 2"`). **Third-party plugins can share a display name** with a built-in effect, and the name resolves to whichever loads first. Check the `matchName` in the result, and pass the match name when it matters. +- `apply_effect_template` - `template`: `gaussianBlur`, `directionalBlur`, `glassBlur`, `curves`, `colorBalance`, `brightnessContrast`, `vibrance`, `glow`, `dropShadow`, `vignette`, `cinematicLook`, `vhsRetro`, `neonGlow`, `filmGrain`, `chromaticAberration`, `duotone`; optional `intensity`. +- `apply_expression_template` - pass settings in `params`. Unknown keys are ignored; numeric settings must be numbers. + - Wiggle: `wiggle`, `wiggleSmooth`, `wiggleFadeIn`, `wiggleFadeOut` (`frequency`, `amplitude`); `loopingWiggle` repeats seamlessly (`frequency`, `amplitude`, `loopTime`); `wiggleOneAxis` wiggles one axis and leaves the others on their keyframes (`axis`: 0 x, 1 y, 2 z). + - Loops and time: `loopCycle`, `loopPingpong`, `loopOffset`, `loopContinue`, `time`, `clock`, `countdown`, `frameNumber`. + - Linking: `matchPosition`, `offsetPosition`, `inverseRotation`, `followPath`. + - **Physics - these act after every keyframe on the property:** + - `overshoot` wobbles past each key and settles (`frequency`, `decay`). Higher frequency means a smaller, tighter overshoot. + - `bounce` retains the original bouncy-overshoot parameters (`amplitude`, `frequency`, `decay`). + - `bounceBack` rebounds off each key value like a ball off a floor (`elasticity` 0-1, `gravity`, `maxBounces`). For a dropped object, keyframe only the fall and let the template add the rebounds. + - `inertia` drifts on past each key and eases to a stop (`friction`). + - `springy` springs past each key (`mass`, `stiffness`, `damping`; damping must stay below 2 x sqrt(mass x stiffness) or nothing happens). + - All five take their size from how fast the property arrives at the key, so a key with no incoming motion gets no effect - including a key with easy ease on its incoming side. + - `speedControl` accumulates a keyframed speed slider (units per second) from the layer's `inPoint`. Add the slider first with `add_expression_control(controlType="slider", controlName="Speed")`; `params`: `controlName`, `multiplier`. Exact for linear and HOLD keys, including intervals clipped by trimming. Eased keys use a midpoint approximation; use linear/HOLD keys when accurate integration matters. On array properties such as Scale it adds to every dimension. +- **Effect parameter lists are flat and reuse names.** Particle Playground has a dozen `Position`, `Affects`, `Min` and `Max` entries; CC Particle World has two `Distance`s. A display-name path such as `Effects/Particle Playground/Position` silently hits the first one. Use the parameter's match name as the last path segment instead: `Effects/CC Particle Systems II/CC Particle Systems II-0001`. +- `link_properties` - `offset` is **added** to the linked value. It shifts a layer; it cannot scale motion. For parallax or other multipliers, write the expression with `set_expression`. +- In your own expressions, use `value` for the pre-expression value and `thisComp.layer("Name")` to reference other layers. + +## Particles + +There is no particle API in ExtendScript. Particles come from the bundled effects, added with `apply_effect` like any other effect: `CC Particle World`, `CC Particle Systems II`, `Particle Playground`, and effects that break a layer apart - `CC Scatterize`, `CC Pixel Polly`, `Shatter`. Emitters (Particle World, Particle Systems II, Playground) replace the layer they sit on, so put them on their own solid, usually with `blendMode: ADD`. + +CC Particle Systems II behaves in ways that aren't obvious (match names in brackets): +- **Radius X/Y (`-0005`/`-0006`) and Velocity (`-0010`) are relative, not pixels.** On a 1920-wide layer one radius unit is roughly 10 px, so a radius of 30 surrounds a mid-size logo and 100 fills the frame. Keep velocity well under 1 on a comp-sized solid. +- **Particle Type (`-0018`) is a numbered menu**: 1 lines (the default), 2 stars, 4 shaded spheres, **5 faded spheres (soft dots)**, 7 bubbles, 10-15 polygon shards, 17-19 large bokeh lenses. 3, 8 and 16 are menu separators and render almost nothing. +- Birth Rate (`-0001`) runs 0-1000 and is continuous. For a burst, keyframe it on and off with HOLD keyframes. + +**Effects are clipped to their layer's bounds.** Scatterize or a glow on a tight precomp fills that rectangle and stops. To give an effect room: +- **Precomps, solids and vector layers:** `modify_layer` with `collapseTransformation: true` renders the layer's effects in the parent comp's space. +- **Raster footage (PNG, JPG, video)** can't collapse. Add `apply_effect(effect="ADBE GROW BOUNDS")` *before* the effect that needs room, and set its Pixels (`Effects/ADBE GROW BOUNDS/ADBE GROW BOUNDS-0001`). Use the match name: Red Giant's plugin is also called "Grow Bounds", and the display name picks whichever loads first. + +`create_logo_reveal` style `particle` does this automatically and reports which method it used in `logoBoundsExpandedBy`. + +Check a particle setup with `render_frame` before calling it done - these parameters are hard to judge any other way. For a full preview, **render through the render queue** rather than frame by frame: a single particle frame has to re-simulate from time 0, so 30 frames via `render_frame` took minutes, while a 4-second comp rendered through the queue in 4 seconds. + +## Rendering + +- Call `list_render_templates` first and use the user's existing presets by name. +- `add_to_render_queue` - `outputPath`, `renderSettingsTemplate`, `outputModuleTemplate`, `timeSpanStart`, `timeSpanDuration`. **The output module decides the file type**: a `.mov` path with an H.264 module produces `.mp4`. Report the returned `outputPath`. +- **`control_render` only supports `start` and `showWindow`.** `start` is synchronous and blocks the CEP bridge until the whole queue finishes: polling and stop/pause/resume are unavailable. **A command timeout does not cancel rendering.** Do not retry or modify the queue after a timeout until AE finishes; use AE's UI to interrupt a native render. Prefer `queue_in_ame` (`renderImmediately: true` to start encoding) for long jobs. `list_render_queue` inspects the native queue before/after rendering, not AME progress. +- `render_frame` saves a single PNG (`time`, optional `outputDir` and `fileName`) and waits until the file is fully written. Use it to check your work. + +## Known issues -**"Value is not an array"** -- Check value format matches property type -- Scale requires array: `[100, 100]` -- Position can use object or array +- **`precompose_layers` is broken.** It moves the layers into the new precomp and then errors. If you must use it, check the result with `list_layers` afterwards. +- `delete_composition` does not delete the solids that comp used. They stay in the project's Solids folder. -**"Property is hidden"** -- Shape layer properties may be hidden initially -- Try different approach or manual layer setup -- Some properties only accessible after layer creation - -**"File couldn't be opened for writing"** -- Provide full absolute path for save operations -- Mac: `/Users/username/path/to/file.aep` -- Windows: `C:/Users/username/path/to/file.aep` - -## Project Organization Best Practices - -1. **Layer naming**: Use descriptive names for easy reference -2. **Precomposing**: Group related layers into precomps for organization -3. **Markers**: Add markers for timing references and notes -4. **Work area**: Set work area to focus rendering on specific sections -5. **Project structure**: Organize footage into folders using organize_project_items - -## Animation Timing Guidelines - -**Duration by content type:** -- UI animations: 0.3-0.5 seconds -- Text reveals: 0.5-1.5 seconds -- Logo animations: 1-3 seconds -- Full title cards: 3-5 seconds -- Transitions: 0.5-1.5 seconds - -**Easing recommendations:** -- Natural motion: Easy Ease (both) -- Bouncy entrance: Ease Out with overshoot -- Snappy exit: Ease In -- Continuous loop: Linear - -## Common Use Cases - -### Social Media Graphics -1. Create 1080x1920 (portrait) or 1920x1080 (landscape) comp -2. Add branded colors and text -3. Use quick animations (0.3-0.8s) -4. Apply modern, bold styles - -### Corporate Videos -1. Create 1920x1080 comp at 30fps -2. Use corporate/professional templates -3. Longer, smoother animations (1-2s) -4. Professional color grading effects - -### Motion Graphics Loops -1. Set looping expressions on properties -2. Use modulo operations for seamless loops -3. Match first and last frames -4. Time remap for speed variations - -### Explainer Animations -1. Build scene sequentially -2. Use text animators for callouts -3. Coordinate timing with markers -4. Parent layers for grouped motion - -## Performance Considerations - -- Complex expressions can slow preview -- Disable expressions during editing if needed -- Use proxies for heavy footage -- Reduce composition complexity when possible -- Pre-render complex sections - -## Integration Tips - -When working with imported footage: -1. Use import_footage for single files -2. Use import_folder for batches -3. Replace footage items with replace_footage -4. Find missing footage with find_missing_footage -5. Collect files before sharing: collect_files - -## Quick Reference: Most Used Functions - -**Essential:** -- create_project, save_project -- create_composition -- add_text_layer, add_shape_layer, add_solid_layer -- set_keyframe, apply_easy_ease -- save_project - -**Animation:** -- set_keyframe_advanced -- apply_expression_template -- create_text_animator - -**Effects:** -- apply_effect_template -- modify_effect_properties - -**Templates:** -- create_lower_third -- create_title_card -- create_transition - -**Organization:** -- list_compositions, list_layers -- get_composition_info, get_layer_info -- precompose_layers \ No newline at end of file +## Common errors + +| Error | Likely cause | +|---|---| +| Timeout on every call | AE-MCP panel closed, or a modal dialog open in After Effects | +| "Property not found" | Dotted path, wrong group name (`Group 1`, not `Shape 1`), or the property doesn't exist on this layer type | +| "property or a parent property is hidden" | Renderer doesn't support it (see 3D), layer isn't 3D, or you asked for extrusion on a solid | +| "Value is undefined" | Enum written in the wrong case, such as `"Screen"` instead of `SCREEN` | +| "Unknown expression template" | Name not in the template list, such as `randomize`, which is a text animator type | +| "Nothing was changed" | A keyframe tool refused input that would have lost keys; nothing was modified | +| "Composition not found" / "Layer not found" | Check exact names with `list_compositions` / `list_layers` | +| File couldn't be saved | Use a full absolute path with forward slashes, such as `C:/Projects/job.aep` | + +## Timing guidelines + +- UI elements: 0.3-0.5 s. Text reveals: 0.5-1.5 s. Logo animations: 1-3 s. Title cards: 3-5 s. Transitions: 0.5-1.5 s. +- Natural motion: easy ease both ways. Entrances: ease out. Exits: ease in. Loops: linear. +- Common comp sizes: 1920x1080 landscape, 1080x1920 vertical, at 24, 30 or 60 fps. diff --git a/SKILL/examples.md b/SKILL/examples.md index 3e459f2..f37e805 100644 --- a/SKILL/examples.md +++ b/SKILL/examples.md @@ -1,232 +1,319 @@ -# Animation Examples +# ae-mcp Examples -This reference provides detailed examples of common After Effects animation patterns using the MCP tools. +Worked recipes using the ae-mcp tools. Every call uses named parameters that match the tool schemas. Adjust names, sizes and timings to the job. -## Example 1: Bouncing Ball Animation +Ask before running anything that changes the whole project (see SKILL.md), and check existing names with `list_compositions` / `list_layers` before editing. -Complete workflow for creating a bouncing ball with realistic physics: +--- + +## 1. Bouncing ball ``` -1. create_composition("Ball Animation", 1920, 1080, 5, 30) +create_composition(name="Ball", width=1920, height=1080, frameRate=30, duration=5) + +add_shape_layer(compName="Ball", name="Ball", shape="ellipse", + size={"width": 100, "height": 100}, + fillColor={"r": 1, "g": 0.3, "b": 0.2}, + position={"x": 960, "y": 200}) -2. add_shape_layer( - compName="Ball Animation", - name="Ball", - shape="ellipse", - size={"width": 100, "height": 100}, - fillColor={"r": 1.0, "g": 0.3, "b": 0.2}, - position={"x": 960, "y": 200} -) +# Drop and bounce - one call per key +set_keyframe(compName="Ball", layerName="Ball", property="position", time=0, value=[960, 200]) +set_keyframe(compName="Ball", layerName="Ball", property="position", time=1, value=[960, 900]) +set_keyframe(compName="Ball", layerName="Ball", property="position", time=1.5, value=[960, 700]) +set_keyframe(compName="Ball", layerName="Ball", property="position", time=2, value=[960, 900]) -3. Set position keyframes: - - time 0: position [960, 200] - - time 1: position [960, 900] - - time 1.5: position [960, 700] - - time 2: position [960, 900] - - time 2.3: position [960, 800] - - time 2.5: position [960, 900] +apply_easy_ease(compName="Ball", layerName="Ball", property="position", type="BOTH") -4. apply_easy_ease on position keyframes with type="IN" on downward motion +# Squash on impact +set_keyframe(compName="Ball", layerName="Ball", property="scale", time=0.9, value=[100, 100]) +set_keyframe(compName="Ball", layerName="Ball", property="scale", time=1, value=[120, 80]) +set_keyframe(compName="Ball", layerName="Ball", property="scale", time=1.1, value=[100, 100]) +``` -5. Add squash/stretch on Scale: - - At impact frames: [120, 80] - - At peak frames: [90, 110] +Alternative - keyframe only the fall and let the `bounceBack` template add the rebounds. Each rebound keeps `elasticity` of the speed, and they get shorter as it loses energy: -Alternative: Use bounce expression template: -apply_expression_template("bounce", "Position", params={amplitude: 200, frequency: 2, decay: 0.8}) +``` +set_keyframe(compName="Ball", layerName="Ball", property="position", time=0, value=[960, 200]) +set_keyframe(compName="Ball", layerName="Ball", property="position", time=1, value=[960, 900]) +apply_expression_template(compName="Ball", layerName="Ball", property="position", + template="bounceBack", + params={"elasticity": 0.7, "gravity": 1500, "maxBounces": 9}) ``` -## Example 2: Kinetic Typography +This linear fall lands at 700 px/s, and gives a first rebound of about 80 px and seven rebounds in all. Rebound height is speed² / (2 × gravity): lower gravity or a faster landing gives bigger bounces. **Don't easy-ease the landing key** - the ball would arrive at zero speed and not bounce at all. -Animated text with multiple layers and timing: +For a settle-and-wobble instead of a bounce, use `template="overshoot"` with `params={"frequency": 3, "decay": 5}`. + +--- + +## 2. Kinetic typography ``` -1. create_composition("Kinetic Text", 1920, 1080, 8, 30) +create_composition(name="Kinetic", width=1920, height=1080, frameRate=30, duration=8) + +add_text_layer(compName="Kinetic", name="Word1", text="MOTION", fontSize=120, + justification="CENTER", position={"x": 960, "y": 400}) +add_text_layer(compName="Kinetic", name="Word2", text="DESIGN", fontSize=120, + justification="CENTER", position={"x": 960, "y": 600}) +add_text_layer(compName="Kinetic", name="Word3", text="STUDIO", fontSize=120, + justification="CENTER", position={"x": 960, "y": 800}) + +# create_text_animator needs a text layer +create_text_animator(compName="Kinetic", layerName="Word1", animatorType="slideInChars", duration=1, delay=0.05) +create_text_animator(compName="Kinetic", layerName="Word2", animatorType="slideInChars", duration=1, delay=0.05) +create_text_animator(compName="Kinetic", layerName="Word3", animatorType="slideInChars", duration=1, delay=0.05) + +# Stagger the words by shifting each layer in time +modify_layer(compName="Kinetic", layerName="Word2", startTime=0.5) +modify_layer(compName="Kinetic", layerName="Word3", startTime=1) +``` -2. Add three text layers: - add_text_layer("MOTION", fontSize=120, position={x: 960, y: 400}) - add_text_layer("DESIGN", fontSize=120, position={x: 960, y: 600}) - add_text_layer("STUDIO", fontSize=120, position={x: 960, y: 800}) +--- -3. Stagger animations: - - Word 1: Animate at 0-1s - - Word 2: Animate at 0.5-1.5s - - Word 3: Animate at 1-2s +## 3. Logo reveal with an animated mask -4. Use text animator for each: - create_text_animator("slideInChars", duration=1, delay=0.05) +The logo must already be in the project - ask before importing. -5. Add background sweep: - add_solid_layer(width=1920, height=200, color={r: 0.2, g: 0.6, b: 1.0}) - Animate position from top to bottom behind text ``` +import_footage(path="C:/Assets/logo.png") # confirm with the user first + +create_composition(name="Logo", width=1920, height=1080, frameRate=30, duration=4) +add_av_layer(compName="Logo", itemName="logo.png") -## Example 3: Logo Animation with Mask Reveal +# Mask vertices are in the layer's own pixels, (0,0) = top-left. +# For a W x H logo, start with a zero-width rectangle on the left edge... +add_mask(compName="Logo", layerName="logo.png", name="Reveal", + vertices=[[0, 0], [0, 0], [0, H], [0, H]], feather=40) +# ...and widen it to cover the whole logo. +set_mask_keyframes(compName="Logo", layerName="logo.png", maskName="Reveal", + keyframes=[ + {"time": 0, "vertices": [[0, 0], [0, 0], [0, H], [0, H]]}, + {"time": 1.5, "vertices": [[0, 0], [W, 0], [W, H], [0, H]]} + ]) + +apply_effect_template(compName="Logo", layerName="logo.png", template="glow", intensity=50) + +set_keyframe(compName="Logo", layerName="logo.png", property="rotation", time=0, value=-5) +set_keyframe(compName="Logo", layerName="logo.png", property="rotation", time=2, value=0) +apply_easy_ease(compName="Logo", layerName="logo.png", property="rotation", type="OUT") ``` -1. import_footage("logo.png") -2. add_av_layer(itemName="logo.png", startTime=0) +For a canned animation instead, `create_logo_reveal(compName="Logo", logoItemName="logo.png", style="scale")`. `style="particle"` assembles the logo out of particles and adds a sparkle burst as it lands. -3. Create mask: - - Add rectangle mask covering logo - - Animate Mask Path to reveal from left to right - -4. Add glow effect: - apply_effect_template("glow", intensity=50) +--- -5. Fade in logo: - set_keyframe(property="Opacity", time=0, value=0) - set_keyframe(property="Opacity", time=1, value=100) - apply_easy_ease(property="Opacity", type="OUT") +## 4. Line draw-on with Trim Paths -6. Add subtle rotation: - set_keyframe(property="Rotation", time=0, value=-5) - set_keyframe(property="Rotation", time=2, value=0) +``` +create_composition(name="DrawOn", width=1920, height=1080, frameRate=30, duration=3) + +# An open two-point path, centred on the comp +create_path(compName="DrawOn", name="Line", pathName="Stroke Path", + vertices=[[-400, 0], [400, 0]], closed=false, + strokeColor={"r": 1, "g": 1, "b": 1}, strokeWidth=6) + +add_shape_operator(compName="DrawOn", layerName="Line", + operator="trimPaths", properties={"end": 0}) + +# The operator lives inside the group's Contents - note the slashes +set_keyframe(compName="DrawOn", layerName="Line", + property="Contents/Group 1/Contents/Trim Paths 1/End", time=0, value=0) +set_keyframe(compName="DrawOn", layerName="Line", + property="Contents/Group 1/Contents/Trim Paths 1/End", time=1, value=100) +apply_easy_ease(compName="DrawOn", layerName="Line", + property="Contents/Group 1/Contents/Trim Paths 1/End", type="BOTH") ``` -## Example 4: Social Media Call-to-Action +For a curve, add tangents (relative to each vertex). A circle of radius 100: ``` -1. create_composition("CTA", 1080, 1920, 6, 30) # Vertical for Stories - -2. Background gradient: - add_solid_layer(color={r: 0.1, g: 0.1, b: 0.3}) - apply_effect("Gradient Ramp") +vertices = [[0, -100], [100, 0], [0, 100], [-100, 0]] +inTangents = [[-55, 0], [0, -55], [55, 0], [0, 55]] +outTangents = [[55, 0], [0, 55], [-55, 0], [0, -55]] +``` -3. Main text: - add_text_layer("FOLLOW US", fontSize=80, position={x: 540, y: 800}) - create_text_animator("scaleInChars", duration=0.8) +--- -4. Animated button: - add_shape_layer(shape="rectangle", size={width: 400, height: 100}) - Set position keyframes with bounce - -5. Pulsing effect: - apply_expression_template("springy", "Scale", params={frequency: 2}) +## 5. Extruded 3D title -6. Add arrow icon: - add_shape_layer(shape="polygon", points=3) - Rotate and position as arrow - Animate position with wiggle ``` +create_composition(name="Title3D", width=1920, height=1080, frameRate=30, duration=5) +add_text_layer(compName="Title3D", name="Title", text="LAUNCH", fontSize=200, + justification="CENTER", position={"x": 960, "y": 540}) + +get_3d_info(compName="Title3D") # new comps start on the Classic renderer +set_comp_renderer(compName="Title3D", renderer="advanced") + +set_3d_layer(compName="Title3D", layerName="Title", enable3D=true, orientation=[0, 20, 0]) +set_geometry_options(compName="Title3D", layerName="Title", + extrusionDepth=60, bevelStyle=2, bevelDepth=4) +set_material_options(compName="Title3D", layerName="Title", + castsShadows=true, ambient=40, diffuse=70, specularIntensity=55) + +add_camera_layer(compName="Title3D", name="Cam", type="TWO_NODE") +set_camera_options(compName="Title3D", layerName="Cam", + zoom=1200, depthOfField=true, focusDistance=1200, aperture=40) + +add_light_layer(compName="Title3D", name="Key", type="SPOT") +set_light_options(compName="Title3D", layerName="Key", + intensity=120, color={"r": 1, "g": 0.9, "b": 0.7}, + coneAngle=70, coneFeather=40, castsShadows=true, shadowDarkness=60) +``` + +Read the `failed` list in each `set_*_options` result. Under the `advanced` renderer, for example, camera `blurLevel` and the iris controls are refused, and each failure names the renderer that supports it. -## Example 5: Data Visualization Counter +--- +## 6. Number counter + +``` +create_composition(name="Counter", width=1920, height=1080, frameRate=30, duration=5) +add_text_layer(compName="Counter", name="Count", text="0", fontSize=200, + justification="CENTER", position={"x": 960, "y": 540}) + +set_expression(compName="Counter", layerName="Count", property="sourceText", + expression="Math.round(linear(time, 0, 3, 0, 1000)).toString()") ``` -1. create_composition("Counter", 1920, 1080, 5, 30) -2. add_text_layer("0", fontSize=200, position={x: 960, y: 540}) +For a prefix or suffix: `"$" + Math.round(linear(time, 0, 3, 0, 1000)).toString()`. + +--- -3. Apply expression to Source Text: - expression = ` - startValue = 0; - endValue = 1000; - duration = 3; - - if (time < duration) { - currentValue = Math.round(linear(time, 0, duration, startValue, endValue)); - } else { - currentValue = endValue; - } - - currentValue.toString(); - ` - set_expression(property="Source Text", expression=expression) +## 7. Parallax with a controller null -4. Add prefix/suffix for units ($, %, etc.) +`link_properties` can only *add* an offset, so parallax uses an expression with a multiplier. -5. Add subtle scale pulse: - apply_expression_template("springy", "Scale") ``` +add_null_layer(compName="Scene", name="Camera Control") + +# Each layer moves by a fraction of the controller's motion. +# 0.2 = far background, 0.5 = midground, 1.0 = foreground. +set_expression(compName="Scene", layerName="Background", property="position", + expression='var c = thisComp.layer("Camera Control").transform.position; value + (c - c.valueAtTime(0)) * 0.2') +set_expression(compName="Scene", layerName="Midground", property="position", + expression='var c = thisComp.layer("Camera Control").transform.position; value + (c - c.valueAtTime(0)) * 0.5') + +# Animate only the controller +set_keyframe(compName="Scene", layerName="Camera Control", property="position", time=0, value=[960, 540]) +set_keyframe(compName="Scene", layerName="Camera Control", property="position", time=10, value=[560, 540]) +``` + +--- -## Example 6: Parallax Scrolling Scene +## 8. Glitch title + +There is no duplicate-layer tool, so build the three copies directly. ``` -1. create_composition("Parallax", 1920, 1080, 10, 30) +create_composition(name="Glitch", width=1920, height=1080, frameRate=30, duration=4) -2. Import multiple layers (background, midground, foreground) +add_text_layer(compName="Glitch", name="Red", text="GLITCH", fontSize=150, + justification="CENTER", color={"r": 1, "g": 0, "b": 0}, position={"x": 950, "y": 540}) +add_text_layer(compName="Glitch", name="Green", text="GLITCH", fontSize=150, + justification="CENTER", color={"r": 0, "g": 1, "b": 0}, position={"x": 960, "y": 540}) +add_text_layer(compName="Glitch", name="Blue", text="GLITCH", fontSize=150, + justification="CENTER", color={"r": 0, "g": 0, "b": 1}, position={"x": 970, "y": 540}) -3. Set up null object as camera controller: - add_null_layer(name="Camera Control") +# Blend modes are AE constants in capitals +modify_layer(compName="Glitch", layerName="Red", blendMode="SCREEN") +modify_layer(compName="Glitch", layerName="Blue", blendMode="SCREEN") -4. Link all layers to null: - For each layer, link_properties( - sourceProperty="Position", - targetProperty="Position", - offset=[layer_specific_multiplier, 0] - ) +apply_effect(compName="Glitch", layerName="Green", effect="Turbulent Displace") -5. Animate null object position: - set_keyframe on Camera Control position - Different layers move at different speeds based on offset +# Flicker +apply_expression_template(compName="Glitch", layerName="Red", property="opacity", + template="wiggle", params={"frequency": 12, "amplitude": 40}) +``` + +--- + +## 9. Render and deliver -6. Add motion blur for smoothness: - modify_layer(layerName=each_layer, motionBlur=true) ``` +list_render_templates() # use the user's existing presets by name -## Example 7: Glitch Effect Title +add_to_render_queue(compName="Title3D", + outputPath="D:/Deliveries/Title3D.mp4", + renderSettingsTemplate="Best Settings", + outputModuleTemplate="H.264 - Match Render Settings - 15 Mbps") +list_render_queue() # confirm status "queued" and the real outputPath + +queue_in_ame(renderImmediately=true) # returns immediately; AME does the encoding ``` -1. create_composition("Glitch Title", 1920, 1080, 4, 30) -2. add_text_layer("GLITCH", fontSize=150) +Avoid `control_render(action="start")` for anything long: it synchronously blocks the CEP bridge until the queue finishes, so polling and stop/pause/resume are unavailable. A command timeout does not cancel rendering; do not retry or modify the queue until AE finishes. Use AE's UI to interrupt native rendering. `list_render_queue` does not track AME progress. -3. Duplicate layer 3 times +--- -4. Apply chromatic aberration: - - Layer 1 (Red): Shift position left, blend mode "Screen" - - Layer 2 (Green): Keep center - - Layer 3 (Blue): Shift position right, blend mode "Screen" +## 10. Particle text reveal -5. Add displacement: - apply_effect("Turbulent Displace") - Animate Evolution for movement +Text assembling out of particles, then a sparkle burst as it lands. The reveal treats a comp as its "logo", so the text goes in its own tight comp first. -6. Flicker opacity: - apply_expression_template("randomize", "Opacity", params={min: 50, max: 100}) +``` +# 1. The text, in a comp just big enough to hold it +create_composition(name="Particle Text - Source", width=1500, height=340, frameRate=30, duration=4) +add_text_layer(compName="Particle Text - Source", name="Title", text="MOTION", fontSize=240, + justification="CENTER", color={"r": 1, "g": 1, "b": 1}, + position={"x": 750, "y": 255}) + +# 2. The main comp and the reveal +create_composition(name="Particle Text Reveal", width=1920, height=1080, frameRate=30, duration=4) +create_logo_reveal(compName="Particle Text Reveal", logoItemName="Particle Text - Source", + style="particle", duration=4, + backgroundColor={"r": 0.03, "g": 0.035, "b": 0.07}) +# -> logoBoundsExpandedBy: "collapseTransformation", particleBurstAdded: true + +# 3. A slow push-in so the end frame doesn't sit dead +set_keyframe(compName="Particle Text Reveal", layerName="Particle Text - Source", + property="scale", time=0, value=[92, 92]) +set_keyframe(compName="Particle Text Reveal", layerName="Particle Text - Source", + property="scale", time=4, value=[100, 100]) + +set_active_composition(compName="Particle Text Reveal") + +# 4. Preview - through the render queue, not frame by frame +add_to_render_queue(compName="Particle Text Reveal", + outputPath="D:/Previews/particle_text_reveal.mp4", + renderSettingsTemplate="Best Settings", + outputModuleTemplate="H.264 - Match Render Settings - 15 Mbps") +queue_in_ame(renderImmediately=true) # render duration is not predictable; prefer AME +# Monitor completion in AME; list_render_queue is not an AME progress monitor. ``` -## Example 8: Progress Bar Animation +Timeline: the text fades in as a particle cloud, forms letters by about 1.7 s, lands at 2 s (half the duration) with the burst, and holds from there. The source comp layer gets Collapse Transformations automatically, so the scatter isn't clipped to the 1500x340 box. -``` -1. create_composition("Progress", 1920, 1080, 3, 30) +--- -2. Create background bar: - add_shape_layer(shape="rectangle", size={width: 1200, height: 80}) - fillColor = gray +## 11. Speed ramp without the rewind -3. Create progress bar: - add_shape_layer(shape="rectangle", size={width: 1200, height: 80}) - fillColor = blue - -4. Mask progress bar: - Add mask to progress bar layer - Animate Mask Path from 0% to 100% width +Spinning something from a keyframed speed. Multiplying the slider by `time` makes it spin backwards as soon as the speed eases off; `speedControl` accumulates the speed instead. -5. Add percentage text: - add_text_layer("0%") - Apply counter expression synced with mask animation +``` +add_null_layer(compName="Scene", name="Wheel") +add_expression_control(compName="Scene", layerName="Wheel", + controlType="slider", controlName="Speed") + +# Speed in degrees per second: spin up, hold, spin down +set_keyframe(compName="Scene", layerName="Wheel", property="Effects/Speed/Slider", time=0, value=0) +set_keyframe(compName="Scene", layerName="Wheel", property="Effects/Speed/Slider", time=1, value=360) +set_keyframe(compName="Scene", layerName="Wheel", property="Effects/Speed/Slider", time=2, value=360) +set_keyframe(compName="Scene", layerName="Wheel", property="Effects/Speed/Slider", time=3, value=0) + +apply_expression_template(compName="Scene", layerName="Wheel", property="rotation", + template="speedControl", params={"controlName": "Speed"}) ``` -## Timing and Easing Patterns - -**Sharp transitions** (UI elements): -- Duration: 0.3s -- Easing: Easy Ease In/Out -- Use for: Buttons, menus, toggles +Rotation runs 0 → 180° at 1 s → 540° at 2 s → 720° at 3 s, then holds at 720°. -**Smooth reveals** (content): -- Duration: 0.8-1.2s -- Easing: Easy Ease Out -- Use for: Text, images, cards +--- -**Energetic motion** (attention-grabbing): -- Duration: 0.4-0.6s -- Easing: Overshoot/bounce -- Use for: Logos, highlights, CTAs +## Timing and easing -**Continuous animation** (ambient): -- Duration: Loop (2-4s) -- Easing: Linear or sine wave -- Use for: Backgrounds, subtle movement \ No newline at end of file +| Use | Duration | Easing | +|---|---|---| +| UI elements, buttons | 0.3 s | Easy ease both ways | +| Text and image reveals | 0.8-1.2 s | Ease out | +| Logos, call-to-action | 0.4-0.6 s | `overshoot` or `bounce` expression | +| Ambient loops | 2-4 s | Linear, or `loopCycle` | diff --git a/package-lock.json b/package-lock.json index 071f6f6..210fa7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", + "@modelcontextprotocol/sdk": "^1.32.1", "winston": "^3.11.0", "zod": "^3.22.4" }, @@ -488,24 +488,24 @@ } }, "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { "hono": "^4" } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.25.3", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.3.tgz", - "integrity": "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ==", + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -513,14 +513,15 @@ "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", - "express": "^5.0.1", - "express-rate-limit": "^7.5.0", - "jose": "^6.1.1", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.0" + "zod-to-json-schema": "^3.25.1" }, "engines": { "node": ">=18" @@ -578,9 +579,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -617,20 +618,20 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.0.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -640,6 +641,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -725,9 +739,9 @@ } }, "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", "engines": { "node": ">=18" @@ -875,9 +889,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -1008,10 +1022,14 @@ } }, "node_modules/express-rate-limit": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", - "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, "engines": { "node": ">= 16" }, @@ -1029,9 +1047,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -1181,9 +1199,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -1193,11 +1211,10 @@ } }, "node_modules/hono": { - "version": "4.11.7", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.7.tgz", - "integrity": "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw==", + "version": "4.13.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.8.tgz", + "integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -1223,9 +1240,9 @@ } }, "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -1244,6 +1261,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -1331,12 +1357,16 @@ } }, "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", "license": "MIT", "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/merge-descriptors": { @@ -1383,12 +1413,32 @@ "license": "MIT" }, "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/object-assign": { @@ -1461,9 +1511,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "funding": { "type": "opencollective", @@ -1480,9 +1530,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "license": "MIT", "dependencies": { "forwarded": "0.2.0", @@ -1490,15 +1540,20 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -1508,12 +1563,16 @@ } }, "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "license": "MIT", "engines": { "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/raw-body": { @@ -1678,14 +1737,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -1697,13 +1756,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -1820,17 +1879,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { diff --git a/package.json b/package.json index 79eabe4..6a65a2d 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "author": "", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.0.0", + "@modelcontextprotocol/sdk": "^1.32.1", "winston": "^3.11.0", "zod": "^3.22.4" }, diff --git a/src/ae-integration/generators/assetGenerators.ts b/src/ae-integration/generators/assetGenerators.ts index 824cbd3..ebd75d9 100644 --- a/src/ae-integration/generators/assetGenerators.ts +++ b/src/ae-integration/generators/assetGenerators.ts @@ -57,12 +57,13 @@ export function generateImportFolder(params: { script += 'var rootFolder = app.project.items.addFolder(folder.name);\n'; script += 'importFilesFromFolder(folder, rootFolder);\n'; - script += '{\n'; + script += 'var result = ({\n'; script += ' folderId: rootFolder.id,\n'; script += ' folderName: rootFolder.name,\n'; script += ' importedCount: importedItems.length,\n'; script += ' items: importedItems\n'; - script += '};\n'; + script += '});\n'; + script += 'result;\n'; return wrapInUndoGroup(script, 'Import Folder'); } @@ -271,11 +272,12 @@ export function generateCollectFiles(params: { script += 'var projectFile = new File(outputFolder.fsName + "/" + (app.project.file ? app.project.file.name : "collected_project.aep"));\n'; script += 'app.project.save(projectFile);\n'; - script += '{\n'; + script += 'var result = ({\n'; script += ' outputPath: outputFolder.fsName,\n'; script += ' collectedCount: collectedFiles.length,\n'; script += ' files: collectedFiles\n'; - script += '};\n'; + script += '});\n'; + script += 'result;\n'; return wrapInUndoGroup(script, 'Collect Files'); } diff --git a/src/ae-integration/generators/compositionGenerators.ts b/src/ae-integration/generators/compositionGenerators.ts index abb726a..e9fb39c 100644 --- a/src/ae-integration/generators/compositionGenerators.ts +++ b/src/ae-integration/generators/compositionGenerators.ts @@ -293,15 +293,37 @@ export function generateRenderFrame(params: { script += 'if (!comp.saveFrameToPng) {\n'; script += ' throw new Error("saveFrameToPng is not available in this After Effects version");\n'; script += '}\n'; + // Remove any previous render first. Otherwise an old file satisfies the + // existence check below even when nothing new was written. + script += 'if (outFile.exists && !outFile.remove()) {\n'; + script += ' throw new Error("Cannot remove the previous frame render: " + outFile.fsName);\n'; + script += '}\n'; script += 'comp.saveFrameToPng(t, outFile);\n'; + // saveFrameToPng returns BEFORE the file is on disk (measured on AE 26.5: it + // appears ~50 ms later). Checking immediately reported every render as a + // failure. Wait for the file to appear, then for its size to stop changing, + // as a best-effort check for an incomplete write. + script += 'var waitedMs = 0;\n'; + script += 'while (!outFile.exists && waitedMs < 15000) { $.sleep(50); waitedMs += 50; }\n'; script += 'if (!outFile.exists) {\n'; - script += ' throw new Error("Frame render did not produce a file: " + outFile.fsName);\n'; + script += ' throw new Error("Frame render did not produce a file within 15s: " + outFile.fsName);\n'; + script += '}\n'; + script += 'var lastSize = -1, stableChecks = 0;\n'; + script += 'while (stableChecks < 3 && waitedMs < 15000) {\n'; + script += ' $.sleep(50); waitedMs += 50;\n'; + script += ' if (outFile.length > 0 && outFile.length === lastSize) { stableChecks++; } else { stableChecks = 0; }\n'; + script += ' lastSize = outFile.length;\n'; + script += '}\n'; + script += 'if (stableChecks < 3 || !outFile.exists || outFile.length <= 0) {\n'; + script += ' throw new Error("Frame render did not finish writing within 15s: " + outFile.fsName);\n'; script += '}\n'; script += generateResultObject({ success: 'true', time: 't', - path: 'outFile.fsName' + path: 'outFile.fsName', + bytes: 'outFile.length', + waitedMs: 'waitedMs' }); return script; diff --git a/src/ae-integration/generators/effectsGenerators.ts b/src/ae-integration/generators/effectsGenerators.ts index 3f0385e..4cc774f 100644 --- a/src/ae-integration/generators/effectsGenerators.ts +++ b/src/ae-integration/generators/effectsGenerators.ts @@ -450,16 +450,21 @@ export function generateReorderEffects(params: { script += generateCompAccess(params.compId, params.compName); script += generateLayerAccess('comp', params.layerIndex, params.layerName); - script += 'var effects = layer.property("Effects");\n'; - script += 'var effect = effects(' + params.effectIndex + ');\n'; - script += 'if (!effect) {\n'; - script += ' throw new Error("Effect not found at index: ' + params.effectIndex + '");\n'; + script += 'var effects = layer.property("ADBE Effect Parade");\n'; + script += 'if (' + params.effectIndex + ' < 1 || ' + params.effectIndex + ' > effects.numProperties) {\n'; + script += ' throw new Error("Effect index ' + params.effectIndex + ' out of range; layer has " + effects.numProperties + " effect(s)");\n'; + script += '}\n'; + script += 'if (' + params.newIndex + ' < 1 || ' + params.newIndex + ' > effects.numProperties) {\n'; + script += ' throw new Error("New index ' + params.newIndex + ' out of range; layer has " + effects.numProperties + " effect(s)");\n'; script += '}\n'; + script += 'var effect = effects.property(' + params.effectIndex + ');\n'; + // moveTo() invalidates this reference, so read the name first. + script += 'var movedEffectName = effect.name;\n'; script += 'effect.moveTo(' + params.newIndex + ');\n'; script += generateResultObject({ - effectName: 'effect.name', + effectName: 'movedEffectName', newIndex: String(params.newIndex) }); @@ -523,10 +528,11 @@ export function generateCopyEffects(params: { script += ' }\n'; script += '}\n'; - script += '{\n'; + script += 'var result = ({\n'; script += ' copiedEffects: copiedEffects,\n'; script += ' count: copiedEffects.length\n'; - script += '};\n'; + script += '});\n'; + script += 'result;\n'; return wrapInUndoGroup(script, 'Copy Effects'); } diff --git a/src/ae-integration/generators/expressionGenerators.ts b/src/ae-integration/generators/expressionGenerators.ts index 3428612..7c4c539 100644 --- a/src/ae-integration/generators/expressionGenerators.ts +++ b/src/ae-integration/generators/expressionGenerators.ts @@ -10,10 +10,25 @@ import { generateCompAccess, generateLayerAccess, generatePropertyAccess, + getPropertyPath, wrapInUndoGroup, generateResultObject } from './helpers.js'; +// Finds the most recent keyframe at or before `time` (n = 0 if none), and the +// time since it. Shared by the physics templates. +const PREV_KEY = [ + 'var n = 0;', + 'if (numKeys > 0) {', + ' n = nearestKey(time).index;', + ' if (key(n).time > time) { n--; }', + '}', + 'var t = (n > 0) ? time - key(n).time : 0;' +].join('\n'); + +// Velocity arriving at key n: sampled a tenth of a frame before it. +const V_IN = 'velocityAtTime(key(n).time - thisComp.frameDuration / 10)'; + // Expression templates library const EXPRESSION_TEMPLATES: Record 0 && t > 0) {', + ' var v = ' + V_IN + ';', + ' var w = freq * Math.PI * 2;', + ' value + v * Math.sin(w * t) / Math.exp(decay * t) / w;', + '} else {', + ' value;', + '}' + ].join('\n'), + description: 'Wobbles past each keyframe and settles, continuing the incoming velocity', + params: { + frequency: { type: 'number', default: 3, description: 'Oscillations per second. Higher = tighter, smaller overshoot' }, + decay: { type: 'number', default: 5, description: 'How quickly the wobble dies away' } + } + }, bounce: { - expression: 'var amplitude = {{amplitude}};\nvar frequency = {{frequency}};\nvar decay = {{decay}};\nvar numKeys = numKeys;\nif (numKeys > 0) {\n var t = time - key(numKeys).time;\n if (t > 0) {\n var v = velocityAtTime(key(numKeys).time - 0.001);\n value + v * amplitude * Math.sin(frequency * t * 2 * Math.PI) / Math.exp(decay * t);\n } else {\n value;\n }\n} else {\n value;\n}', - description: 'Bouncy overshoot after keyframes', + // Preserve the existing amplitude/frequency/decay API. The gravity-based + // rebound is a separate template, not a silent replacement for this one. + expression: [ + PREV_KEY, + 'var amplitude = {{amplitude}};', + 'var frequency = {{frequency}};', + 'var decay = {{decay}};', + 'if (n > 0 && t > 0) {', + ' var v = ' + V_IN + ';', + ' value + v * amplitude * Math.sin(frequency * t * 2 * Math.PI) / Math.exp(decay * t);', + '} else {', + ' value;', + '}' + ].join('\n'), + description: 'Bouncy overshoot after each keyframe, using the original amplitude/frequency/decay parameters', params: { amplitude: { type: 'number', default: 0.1, description: 'Bounce amplitude' }, frequency: { type: 'number', default: 3, description: 'Bounce frequency' }, decay: { type: 'number', default: 5, description: 'Decay rate' } } }, - inertia: { - expression: 'var friction = {{friction}};\nvar numKeys = numKeys;\nif (numKeys > 0) {\n var t = time - key(numKeys).time;\n if (t > 0) {\n var v = velocityAtTime(key(numKeys).time - 0.001);\n value + v * (1 - Math.exp(-friction * t)) / friction;\n } else {\n value;\n }\n} else {\n value;\n}', - description: 'Inertia/momentum after keyframes', + bounceBack: { + // Bounce-back: the property rebounds off the keyframe value like a ball + // off a floor - gravity pulls it back, each rebound keeps `elasticity` of + // the speed, and rebounds get shorter and more frequent as energy is lost. + // The original was a damped-sine overshoot under this name. + expression: [ + PREV_KEY, + 'var e = {{elasticity}};', + 'var g = {{gravity}};', + 'var nMax = {{maxBounces}};', + 'if (n > 0 && t > 0) {', + ' var v = -' + V_IN + ' * e;', + ' var isArr = (v instanceof Array);', + ' var vl = isArr ? length(v) : Math.abs(v);', + ' var vu = isArr ? (vl > 0 ? normalize(v) : v) : (v < 0 ? -1 : 1);', + ' var tCur = 0;', + ' var seg = 2 * vl / g;', + ' var tNext = seg;', + ' var nb = 1;', + ' while (tNext < t && nb <= nMax) { vl *= e; seg *= e; tCur = tNext; tNext += seg; nb++; }', + ' if (nb <= nMax) {', + ' var d = t - tCur;', + ' value + vu * d * (vl - g * d / 2);', + ' } else {', + ' value;', + ' }', + '} else {', + ' value;', + '}' + ].join('\n'), + description: 'Rebounds off each keyframe value under gravity, like a ball hitting a floor', params: { - friction: { type: 'number', default: 5, description: 'Friction coefficient' } + elasticity: { type: 'number', default: 0.7, description: 'Fraction of speed kept on each rebound (0-1)' }, + gravity: { type: 'number', default: 5000, description: 'Pull back toward the keyframe value, in units per second squared' }, + maxBounces: { type: 'number', default: 9, description: 'Stop after this many rebounds' } } }, - overshoot: { - expression: 'var frequency = {{frequency}};\nvar decay = {{decay}};\nvar numKeys = numKeys;\nif (numKeys > 0) {\n var t = time - key(numKeys).time;\n if (t > 0) {\n var startVal = key(numKeys).value;\n var endVal = value;\n var delta = endVal - startVal;\n endVal + delta * Math.sin(frequency * t * Math.PI) * Math.exp(-decay * t);\n } else {\n value;\n }\n} else {\n value;\n}', - description: 'Overshoot animation effect', + inertia: { + // Coasts on in the incoming direction and slows to a stop. + expression: [ + PREV_KEY, + 'var friction = {{friction}};', + 'if (n > 0 && t > 0) {', + ' var v = ' + V_IN + ';', + ' value + v * (1 - Math.exp(-friction * t)) / friction;', + '} else {', + ' value;', + '}' + ].join('\n'), + description: 'Drifts on past each keyframe in the direction of travel and eases to a stop', params: { - frequency: { type: 'number', default: 3, description: 'Oscillation frequency' }, - decay: { type: 'number', default: 5, description: 'Decay rate' } + friction: { type: 'number', default: 5, description: 'How quickly the drift stops' } } }, springy: { - expression: 'var mass = {{mass}};\nvar stiffness = {{stiffness}};\nvar damping = {{damping}};\nvar numKeys = numKeys;\nif (numKeys > 0) {\n var t = time - key(numKeys).time;\n if (t > 0) {\n var omega = Math.sqrt(stiffness / mass);\n var zeta = damping / (2 * Math.sqrt(mass * stiffness));\n var amplitude = velocityAtTime(key(numKeys).time - 0.001) / omega;\n if (zeta < 1) {\n var omegaD = omega * Math.sqrt(1 - zeta * zeta);\n value + amplitude * Math.exp(-zeta * omega * t) * Math.sin(omegaD * t);\n } else {\n value;\n }\n } else {\n value;\n }\n} else {\n value;\n}', - description: 'Spring physics simulation', + // Underdamped spring. Divides by the DAMPED frequency wd, which is the one + // it oscillates at, so it starts at the incoming velocity. The original + // divided by the undamped omega and started about 13% slow. + expression: [ + PREV_KEY, + 'var mass = {{mass}};', + 'var stiffness = {{stiffness}};', + 'var damping = {{damping}};', + 'if (n > 0 && t > 0) {', + ' var omega = Math.sqrt(stiffness / mass);', + ' var zeta = damping / (2 * Math.sqrt(mass * stiffness));', + ' if (zeta < 1) {', + ' var wd = omega * Math.sqrt(1 - zeta * zeta);', + ' var v = ' + V_IN + ';', + ' value + v / wd * Math.exp(-zeta * omega * t) * Math.sin(wd * t);', + ' } else {', + ' value;', + ' }', + '} else {', + ' value;', + '}' + ].join('\n'), + description: 'Spring past each keyframe (no effect when critically or over-damped)', params: { mass: { type: 'number', default: 1, description: 'Mass' }, stiffness: { type: 'number', default: 100, description: 'Spring stiffness' }, - damping: { type: 'number', default: 10, description: 'Damping coefficient' } + damping: { type: 'number', default: 10, description: 'Damping. Must stay below 2 * sqrt(mass * stiffness) to oscillate' } + } + }, + + // Speed control + speedControl: { + // Integrates a keyframed "units per second" slider over time. Multiplying + // the slider by time instead makes the property run BACKWARDS whenever the + // speed eases off, because expressions have no memory of earlier frames. + // Midpoint areas are exact for linear and HOLD intervals; eased keys are + // approximate. Sampling inside an interval avoids the discontinuity at a + // HOLD key's right endpoint. Clip every interval to the visible start. + expression: [ + 'var s = effect("{{controlName}}")("ADBE Slider Control-0001");', + 'var mult = {{multiplier}};', + 'var acc = 0;', + 'var start = inPoint;', + 'if (time > start) {', + ' for (var k = 1; k <= s.numKeys && start < time; k++) {', + ' var end = Math.min(time, s.key(k).time);', + ' if (end > start) {', + ' acc += s.valueAtTime((start + end) / 2) * (end - start);', + ' start = end;', + ' }', + ' }', + ' if (start < time) { acc += s.valueAtTime((start + time) / 2) * (time - start); }', + '}', + 'acc *= mult;', + 'if (value instanceof Array) {', + ' var out = [];', + ' for (var i = 0; i < value.length; i++) { out.push(value[i] + acc); }', + ' out;', + '} else {', + ' value + acc;', + '}' + ].join('\n'), + description: 'Accumulates a speed slider from the layer inPoint. Exact for linear and HOLD keys; midpoint approximation for eased keys. Use linear/HOLD keys for predictable speed integration. Add a Slider Control first (add_expression_control)', + params: { + controlName: { type: 'string', default: 'Speed', description: 'Name of the Slider Control on this layer holding units per second' }, + multiplier: { type: 'number', default: 1, description: 'Scales the accumulated amount (e.g. to convert units)' } + } + }, + + // Wiggle variants + loopingWiggle: { + // Crossfades a wiggle sampled at t with one sampled at t - loopTime, so the + // end of each cycle lands exactly on its start. + expression: [ + 'var freq = {{frequency}};', + 'var amp = {{amplitude}};', + 'var loopTime = {{loopTime}};', + 'var t = time % loopTime;', + 'var w1 = wiggle(freq, amp, 1, 0.5, t);', + 'var w2 = wiggle(freq, amp, 1, 0.5, t - loopTime);', + 'linear(t, 0, loopTime, w1, w2);' + ].join('\n'), + description: 'Wiggle that repeats seamlessly every loopTime seconds', + params: { + frequency: { type: 'number', default: 2, description: 'Oscillations per second' }, + amplitude: { type: 'number', default: 50, description: 'Maximum deviation' }, + loopTime: { type: 'number', default: 3, description: 'Loop length in seconds' } + } + }, + wiggleOneAxis: { + expression: [ + 'var ax = {{axis}};', + 'var w = wiggle({{frequency}}, {{amplitude}});', + 'if (value instanceof Array) {', + ' var r = [];', + ' for (var i = 0; i < value.length; i++) { r.push(i === ax ? w[i] : value[i]); }', + ' r;', + '} else {', + ' w;', + '}' + ].join('\n'), + description: 'Wiggle along one axis only; the other axes keep their keyframed values', + params: { + axis: { type: 'number', default: 0, description: '0 = x, 1 = y, 2 = z' }, + frequency: { type: 'number', default: 2, description: 'Oscillations per second' }, + amplitude: { type: 'number', default: 50, description: 'Maximum deviation' } } } }; @@ -195,7 +391,26 @@ function processExpressionTemplate( ? params[paramName] : paramDef.default; const placeholder = '{{' + paramName + '}}'; - expression = expression.split(placeholder).join(String(value)); + // Values are pasted into expression source, so check them first: + // - number: must be a finite number, not arbitrary text; + // - string: sits inside double quotes, so escape it; + // - expression: deliberately inserted as raw code. + let replacement: string; + if (paramDef.type === 'number') { + const num = typeof value === 'number' ? value : Number(value); + if ((typeof value !== 'number' && typeof value !== 'string') || + (typeof value === 'string' && value.trim() === '') || !isFinite(num)) { + throw new Error( + 'Template "' + templateName + '" parameter "' + paramName + '" must be a number, got: ' + JSON.stringify(value) + ); + } + replacement = String(num); + } else if (paramDef.type === 'string') { + replacement = escapeString(String(value)).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029'); + } else { + replacement = String(value); + } + expression = expression.split(placeholder).join(replacement); } } @@ -324,6 +539,11 @@ export function generateAddExpressionControl(params: { controlName: string; defaultValue?: number | number[] | boolean | string; }): string { + // Without this, a missing name was written as the literal text "undefined", + // so expressions looking the control up by name could never find it. + if (typeof params.controlName !== 'string' || params.controlName === '') { + throw new Error('add_expression_control requires controlName - expressions find the control by this name'); + } let script = ''; script += generateProjectCheck(); script += generateCompAccess(params.compId, params.compName); @@ -483,7 +703,7 @@ export function generateBatchSetExpressions(params: { script += ' var prop' + i + ' = layer;\n'; // Navigate to property - const path = expr.property.split('/'); + const path = getPropertyPath(expr.property).split('/'); for (let j = 0; j < path.length; j++) { script += ' prop' + i + ' = prop' + i + '.property("' + escapeString(path[j]) + '");\n'; } diff --git a/src/ae-integration/generators/keyframeGenerators.ts b/src/ae-integration/generators/keyframeGenerators.ts index 7795107..c346b3b 100644 --- a/src/ae-integration/generators/keyframeGenerators.ts +++ b/src/ae-integration/generators/keyframeGenerators.ts @@ -101,9 +101,9 @@ export function generateSetKeyframeAdvanced(params: { if (params.inEase || params.outEase) { // Determine number of dimensions script += 'var numDims = 1;\n'; - script += 'if (prop.propertyValueType === PropertyValueType.TwoD || prop.propertyValueType === PropertyValueType.TwoD_SPATIAL) {\n'; + script += 'if (prop.propertyValueType === PropertyValueType.TwoD) {\n'; script += ' numDims = 2;\n'; - script += '} else if (prop.propertyValueType === PropertyValueType.ThreeD || prop.propertyValueType === PropertyValueType.ThreeD_SPATIAL) {\n'; + script += '} else if (prop.propertyValueType === PropertyValueType.ThreeD) {\n'; script += ' numDims = 3;\n'; script += '}\n'; @@ -171,9 +171,9 @@ export function generateApplyEasyEase(params: { // Get number of dimensions script += 'var numDims = 1;\n'; - script += 'if (prop.propertyValueType === PropertyValueType.TwoD || prop.propertyValueType === PropertyValueType.TwoD_SPATIAL) {\n'; + script += 'if (prop.propertyValueType === PropertyValueType.TwoD) {\n'; script += ' numDims = 2;\n'; - script += '} else if (prop.propertyValueType === PropertyValueType.ThreeD || prop.propertyValueType === PropertyValueType.ThreeD_SPATIAL) {\n'; + script += '} else if (prop.propertyValueType === PropertyValueType.ThreeD) {\n'; script += ' numDims = 3;\n'; script += '}\n'; @@ -235,9 +235,9 @@ export function generateSetTemporalEase(params: { // Get number of dimensions script += 'var numDims = 1;\n'; - script += 'if (prop.propertyValueType === PropertyValueType.TwoD || prop.propertyValueType === PropertyValueType.TwoD_SPATIAL) {\n'; + script += 'if (prop.propertyValueType === PropertyValueType.TwoD) {\n'; script += ' numDims = 2;\n'; - script += '} else if (prop.propertyValueType === PropertyValueType.ThreeD || prop.propertyValueType === PropertyValueType.ThreeD_SPATIAL) {\n'; + script += '} else if (prop.propertyValueType === PropertyValueType.ThreeD) {\n'; script += ' numDims = 3;\n'; script += '}\n'; @@ -284,6 +284,9 @@ export function generateOffsetKeyframes(params: { script += generateLayerAccess('comp', params.layerIndex, params.layerName); script += generatePropertyAccess('layer', params.property); + if (typeof params.offset !== 'number' || !isFinite(params.offset)) { + throw new Error('offset must be a finite number of seconds'); + } script += 'var offset = ' + params.offset + ';\n'; script += 'if (prop.numKeys === 0) {\n'; @@ -303,25 +306,38 @@ export function generateOffsetKeyframes(params: { script += ' });\n'; script += '}\n'; - // Remove all keyframes + // Compute and validate every new time BEFORE touching the property. This + // used to remove all keys first and silently drop any that landed before + // 0, destroying animation while reporting success. + script += 'var earliest = null;\n'; + script += 'for (var i = 0; i < keyData.length; i++) {\n'; + script += ' keyData[i].newTime = keyData[i].time + offset;\n'; + script += ' if (!isFinite(keyData[i].newTime) || (i > 0 && keyData[i].newTime <= keyData[i - 1].newTime)) {\n'; + script += ' throw new Error("Offset would produce non-finite or overlapping key times. Nothing was changed.");\n'; + script += ' }\n'; + script += ' if (earliest === null || keyData[i].newTime < earliest) { earliest = keyData[i].newTime; }\n'; + script += '}\n'; + script += 'if (earliest < 0) {\n'; + script += ' throw new Error("Offset " + offset + " would move a keyframe before time 0 (it would land at " + earliest + "s). Nothing was changed.");\n'; + script += '}\n'; + + // All destination times are valid before rewriting any keys. script += 'while (prop.numKeys > 0) {\n'; script += ' prop.removeKey(1);\n'; script += '}\n'; - // Re-add keyframes at new times + script += 'var keysWritten = 0;\n'; script += 'for (var i = 0; i < keyData.length; i++) {\n'; - script += ' var newTime = keyData[i].time + offset;\n'; - script += ' if (newTime >= 0) {\n'; - script += ' var keyIndex = prop.addKey(newTime);\n'; - script += ' prop.setValueAtKey(keyIndex, keyData[i].value);\n'; - script += ' prop.setInterpolationTypeAtKey(keyIndex, keyData[i].inType, keyData[i].outType);\n'; - script += ' prop.setTemporalEaseAtKey(keyIndex, keyData[i].inEase, keyData[i].outEase);\n'; - script += ' }\n'; + script += ' var keyIndex = prop.addKey(keyData[i].newTime);\n'; + script += ' prop.setValueAtKey(keyIndex, keyData[i].value);\n'; + script += ' prop.setInterpolationTypeAtKey(keyIndex, keyData[i].inType, keyData[i].outType);\n'; + script += ' prop.setTemporalEaseAtKey(keyIndex, keyData[i].inEase, keyData[i].outEase);\n'; + script += ' keysWritten++;\n'; script += '}\n'; script += generateResultObject({ success: 'true', - keyframesMoved: 'keyData.length', + keyframesMoved: 'keysWritten', offset: 'offset' }); @@ -347,6 +363,12 @@ export function generateScaleKeyframeTiming(params: { script += generatePropertyAccess('layer', params.property); const anchor = params.anchorTime !== undefined ? params.anchorTime : 0; + if (typeof params.scale !== 'number' || !isFinite(params.scale) || params.scale <= 0) { + throw new Error('scale must be a finite number greater than 0 (2 doubles the timing, 0.5 halves it)'); + } + if (params.anchorTime !== undefined && (typeof params.anchorTime !== 'number' || !isFinite(params.anchorTime))) { + throw new Error('anchorTime must be a finite number of seconds'); + } script += 'var scaleFactor = ' + params.scale + ';\n'; script += 'var anchorTime = ' + anchor + ';\n'; @@ -367,25 +389,43 @@ export function generateScaleKeyframeTiming(params: { script += ' });\n'; script += '}\n'; - // Remove all keyframes + // Compute and validate every new time BEFORE touching the property. This + // used to remove all keys first and re-add only those it could place, so + // a missing scale (NaN) or a key pushed before 0 wiped the animation while + // reporting success. + script += 'for (var i = 0; i < keyData.length; i++) {\n'; + script += ' var nt = anchorTime + (keyData[i].time - anchorTime) * scaleFactor;\n'; + script += ' if (!isFinite(nt)) {\n'; + script += ' throw new Error("Scaling would produce a non-finite key time. Nothing was changed.");\n'; + script += ' }\n'; + script += ' if (nt < 0) {\n'; + script += ' throw new Error("Scaling by " + scaleFactor + " around " + anchorTime + "s would move a keyframe before time 0 (to " + nt + "s). Nothing was changed.");\n'; + script += ' }\n'; + // AE supports subframe keys. Only reject times that actually collapse + // together through floating-point rounding, not keys in the same frame. + script += ' if (i > 0 && nt <= keyData[i - 1].newTime) {\n'; + script += ' throw new Error("Scaling would produce overlapping key times. Nothing was changed.");\n'; + script += ' }\n'; + script += ' keyData[i].newTime = nt;\n'; + script += '}\n'; + + // All destination times are valid before rewriting any keys. script += 'while (prop.numKeys > 0) {\n'; script += ' prop.removeKey(1);\n'; script += '}\n'; - // Re-add keyframes at scaled times + script += 'var keysWritten = 0;\n'; script += 'for (var i = 0; i < keyData.length; i++) {\n'; - script += ' var newTime = anchorTime + (keyData[i].time - anchorTime) * scaleFactor;\n'; - script += ' if (newTime >= 0) {\n'; - script += ' var keyIndex = prop.addKey(newTime);\n'; - script += ' prop.setValueAtKey(keyIndex, keyData[i].value);\n'; - script += ' prop.setInterpolationTypeAtKey(keyIndex, keyData[i].inType, keyData[i].outType);\n'; - script += ' prop.setTemporalEaseAtKey(keyIndex, keyData[i].inEase, keyData[i].outEase);\n'; - script += ' }\n'; + script += ' var keyIndex = prop.addKey(keyData[i].newTime);\n'; + script += ' prop.setValueAtKey(keyIndex, keyData[i].value);\n'; + script += ' prop.setInterpolationTypeAtKey(keyIndex, keyData[i].inType, keyData[i].outType);\n'; + script += ' prop.setTemporalEaseAtKey(keyIndex, keyData[i].inEase, keyData[i].outEase);\n'; + script += ' keysWritten++;\n'; script += '}\n'; script += generateResultObject({ success: 'true', - keyframesScaled: 'keyData.length', + keyframesScaled: 'keysWritten', scale: 'scaleFactor' }); @@ -465,6 +505,11 @@ export function generateCopyKeyframes(params: { targetProperty?: string; timeOffset?: number; }): string { + // Without this, a missing sourceProperty crashes in TypeScript with + // "Cannot read properties of undefined (reading 'split')". + if (!params.sourceProperty) { + throw new Error('copy_keyframes requires sourceProperty'); + } let script = ''; script += generateProjectCheck(); script += generateCompAccess(params.compId, params.compName); @@ -492,14 +537,30 @@ export function generateCopyKeyframes(params: { script += '}\n'; // Copy keyframes + // Snapshot every source key BEFORE writing any. The loop used to read + // sourceProp.numKeys live, so copying onto the same property grew the + // bound with every key added - a 2-key copy produced 4,321 keys - and the + // added keys also shifted the source indices mid-loop. + script += 'var srcCount = sourceProp.numKeys;\n'; + script += 'var snap = [];\n'; + script += 'for (var i = 1; i <= srcCount; i++) {\n'; + script += ' snap.push({\n'; + script += ' time: sourceProp.keyTime(i),\n'; + script += ' value: sourceProp.keyValue(i),\n'; + script += ' inInterp: sourceProp.keyInInterpolationType(i),\n'; + script += ' outInterp: sourceProp.keyOutInterpolationType(i),\n'; + script += ' inEase: sourceProp.keyInTemporalEase(i),\n'; + script += ' outEase: sourceProp.keyOutTemporalEase(i)\n'; + script += ' });\n'; + script += '}\n'; script += 'var keysCopied = 0;\n'; - script += 'for (var i = 1; i <= sourceProp.numKeys; i++) {\n'; - script += ' var newTime = sourceProp.keyTime(i) + ' + timeOffset + ';\n'; + script += 'for (var s = 0; s < snap.length; s++) {\n'; + script += ' var newTime = snap[s].time + ' + timeOffset + ';\n'; script += ' if (newTime >= 0) {\n'; script += ' var keyIndex = targetProp.addKey(newTime);\n'; - script += ' targetProp.setValueAtKey(keyIndex, sourceProp.keyValue(i));\n'; - script += ' targetProp.setInterpolationTypeAtKey(keyIndex, sourceProp.keyInInterpolationType(i), sourceProp.keyOutInterpolationType(i));\n'; - script += ' targetProp.setTemporalEaseAtKey(keyIndex, sourceProp.keyInTemporalEase(i), sourceProp.keyOutTemporalEase(i));\n'; + script += ' targetProp.setValueAtKey(keyIndex, snap[s].value);\n'; + script += ' targetProp.setInterpolationTypeAtKey(keyIndex, snap[s].inInterp, snap[s].outInterp);\n'; + script += ' targetProp.setTemporalEaseAtKey(keyIndex, snap[s].inEase, snap[s].outEase);\n'; script += ' keysCopied++;\n'; script += ' }\n'; script += '}\n'; diff --git a/src/ae-integration/generators/layerGenerators.ts b/src/ae-integration/generators/layerGenerators.ts index 9b3111e..9d51118 100644 --- a/src/ae-integration/generators/layerGenerators.ts +++ b/src/ae-integration/generators/layerGenerators.ts @@ -219,6 +219,7 @@ export function generateAddShapeLayer(params: { points?: number; innerRadius?: number; outerRadius?: number; + roundness?: number; }): string { let script = ''; script += generateProjectCheck(); @@ -230,10 +231,13 @@ export function generateAddShapeLayer(params: { script += 'layer.name = "' + escapeString(params.name) + '";\n'; } - // Add shape content - script += 'var contents = layer.property("Contents");\n'; + // Add shape content. + // Everything below addresses properties by matchName rather than English + // display name. property("Size") returns null on a non-English After Effects + // and the script then dies on a null dereference. + script += 'var contents = layer.property("ADBE Root Vectors Group");\n'; script += 'var shapeGroup = contents.addProperty("ADBE Vector Group");\n'; - script += 'var shapeContents = shapeGroup.property("Contents");\n'; + script += 'var shapeContents = shapeGroup.property("ADBE Vectors Group");\n'; const shape = params.shape || 'rectangle'; const sizeW = params.size?.width || 200; @@ -241,44 +245,50 @@ export function generateAddShapeLayer(params: { if (shape === 'rectangle') { script += 'var rect = shapeContents.addProperty("ADBE Vector Shape - Rect");\n'; - script += 'rect.property("Size").setValue([' + sizeW + ', ' + sizeH + ']);\n'; + script += 'rect.property("ADBE Vector Rect Size").setValue([' + sizeW + ', ' + sizeH + ']);\n'; + if (params.roundness !== undefined) { + script += 'rect.property("ADBE Vector Rect Roundness").setValue(' + params.roundness + ');\n'; + } } else if (shape === 'ellipse') { script += 'var ellipse = shapeContents.addProperty("ADBE Vector Shape - Ellipse");\n'; - script += 'ellipse.property("Size").setValue([' + sizeW + ', ' + sizeH + ']);\n'; + script += 'ellipse.property("ADBE Vector Ellipse Size").setValue([' + sizeW + ', ' + sizeH + ']);\n'; } else if (shape === 'polygon') { script += 'var poly = shapeContents.addProperty("ADBE Vector Shape - Star");\n'; - script += 'poly.property("Type").setValue(1);\n'; // polygon - script += 'poly.property("Points").setValue(' + (params.points || 6) + ');\n'; - script += 'poly.property("Outer Radius").setValue(' + (params.outerRadius || 100) + ');\n'; + // Polystar Type is 1 = STAR, 2 = POLYGON (verified: Inner Radius is only + // settable under 1). These were reversed, so 'polygon' silently drew a star. + script += 'poly.property("ADBE Vector Star Type").setValue(2);\n'; // polygon + script += 'poly.property("ADBE Vector Star Points").setValue(' + (params.points || 6) + ');\n'; + script += 'poly.property("ADBE Vector Star Outer Radius").setValue(' + (params.outerRadius || 100) + ');\n'; } else if (shape === 'star') { script += 'var star = shapeContents.addProperty("ADBE Vector Shape - Star");\n'; - script += 'star.property("Type").setValue(2);\n'; // star - script += 'star.property("Points").setValue(' + (params.points || 5) + ');\n'; - script += 'star.property("Outer Radius").setValue(' + (params.outerRadius || 100) + ');\n'; - script += 'star.property("Inner Radius").setValue(' + (params.innerRadius || 50) + ');\n'; + script += 'star.property("ADBE Vector Star Type").setValue(1);\n'; // star + script += 'star.property("ADBE Vector Star Points").setValue(' + (params.points || 5) + ');\n'; + script += 'star.property("ADBE Vector Star Outer Radius").setValue(' + (params.outerRadius || 100) + ');\n'; + script += 'star.property("ADBE Vector Star Inner Radius").setValue(' + (params.innerRadius || 50) + ');\n'; } // Add fill if (params.fillColor) { script += 'var fill = shapeContents.addProperty("ADBE Vector Graphic - Fill");\n'; - script += 'fill.property("Color").setValue(' + colorToES3(params.fillColor) + ');\n'; + script += 'fill.property("ADBE Vector Fill Color").setValue(' + colorToES3(params.fillColor) + ');\n'; } // Add stroke if (params.strokeColor) { script += 'var stroke = shapeContents.addProperty("ADBE Vector Graphic - Stroke");\n'; - script += 'stroke.property("Color").setValue(' + colorToES3(params.strokeColor) + ');\n'; + script += 'stroke.property("ADBE Vector Stroke Color").setValue(' + colorToES3(params.strokeColor) + ');\n'; if (params.strokeWidth !== undefined) { - script += 'stroke.property("Stroke Width").setValue(' + params.strokeWidth + ');\n'; + script += 'stroke.property("ADBE Vector Stroke Width").setValue(' + params.strokeWidth + ');\n'; } } // Position + script += 'var xform = layer.property("ADBE Transform Group");\n'; if (params.position) { - script += 'layer.property("Position").setValue(' + positionToES3(params.position) + ');\n'; + script += 'xform.property("ADBE Position").setValue(' + positionToES3(params.position) + ');\n'; } else { // Center in comp - script += 'layer.property("Position").setValue([comp.width/2, comp.height/2]);\n'; + script += 'xform.property("ADBE Position").setValue([comp.width/2, comp.height/2]);\n'; } script += generateResultObject({ @@ -371,7 +381,7 @@ export function generateAddCameraLayer(params: { } if (params.zoom) { - script += 'layer.property("Camera Options").property("Zoom").setValue(' + params.zoom + ');\n'; + script += 'layer.property("ADBE Camera Options Group").property("ADBE Camera Zoom").setValue(' + params.zoom + ');\n'; } script += generateResultObject({ @@ -406,10 +416,10 @@ export function generateAddLightLayer(params: { script += 'layer.lightType = ' + generateLightType(lightType) + ';\n'; if (params.color) { - script += 'layer.property("Light Options").property("Color").setValue(' + colorToES3(params.color) + ');\n'; + script += 'layer.property("ADBE Light Options Group").property("ADBE Light Color").setValue(' + colorToES3(params.color) + ');\n'; } if (params.intensity !== undefined) { - script += 'layer.property("Light Options").property("Intensity").setValue(' + params.intensity + ');\n'; + script += 'layer.property("ADBE Light Options Group").property("ADBE Light Intensity").setValue(' + params.intensity + ');\n'; } script += generateResultObject({ @@ -527,6 +537,7 @@ export function generateModifyLayer(params: { blendMode?: string; parent?: number; is3D?: boolean; + collapseTransformation?: boolean; position?: { x: number; y: number; z?: number }; scale?: number[]; rotation?: number; @@ -577,6 +588,13 @@ export function generateModifyLayer(params: { if (params.is3D !== undefined) { script += 'layer.threeDLayer = ' + params.is3D + ';\n'; } + if (params.collapseTransformation !== undefined) { + // Precomps, solids and vector layers can collapse; raster footage can't. + script += 'if (!layer.canSetCollapseTransformation) {\n'; + script += ' throw new Error("Collapse Transformations is not available on this layer (raster footage can\'t collapse; use the Grow Bounds effect to give effects more room): " + layer.name);\n'; + script += '}\n'; + script += 'layer.collapseTransformation = ' + (params.collapseTransformation ? 'true' : 'false') + ';\n'; + } if (params.position) { script += 'layer.property("Position").setValue(' + positionToES3(params.position) + ');\n'; } diff --git a/src/ae-integration/generators/markerGenerators.ts b/src/ae-integration/generators/markerGenerators.ts index c610639..1be2172 100644 --- a/src/ae-integration/generators/markerGenerators.ts +++ b/src/ae-integration/generators/markerGenerators.ts @@ -250,11 +250,12 @@ export function generateGetCurrentTime(params: { script += generateProjectCheck(); script += generateCompAccess(params.compId, params.compName); - script += '{\n'; + script += 'var result = ({\n'; script += ' time: comp.time,\n'; script += ' frame: Math.round(comp.time * comp.frameRate),\n'; script += ' frameRate: comp.frameRate\n'; - script += '};\n'; + script += '});\n'; + script += 'result;\n'; return script; } @@ -381,11 +382,12 @@ export function generateNavigateMarkers(params: { script += 'var markerTime = markerProp.keyTime(targetIndex);\n'; script += 'comp.time = markerTime;\n'; - script += '{\n'; + script += 'var result = ({\n'; script += ' markerIndex: targetIndex,\n'; script += ' time: markerTime,\n'; script += ' comment: markerProp.keyValue(targetIndex).comment\n'; - script += '};\n'; + script += '});\n'; + script += 'result;\n'; return script; } diff --git a/src/ae-integration/generators/maskGenerators.ts b/src/ae-integration/generators/maskGenerators.ts new file mode 100644 index 0000000..99c907d --- /dev/null +++ b/src/ae-integration/generators/maskGenerators.ts @@ -0,0 +1,460 @@ +/** + * Mask Generators + * + * Masks and shape-layer paths both use the same Shape object, so the path + * building and validation is shared with shapeGenerators rather than + * reimplemented. + * + * Match names verified empirically against After Effects 26.5: + * ADBE Mask Parade the mask group on a layer + * ADBE Mask Atom one mask + * ADBE Mask Shape the path (Shape-valued) + * ADBE Mask Feather feather ([x, y], TwoD) + * ADBE Mask Opacity opacity (percent) + * ADBE Mask Offset expansion (pixels) - note the name mismatch + */ + +import { + escapeString, + colorToES3, + generateCompAccess, + generateLayerAccess, + generateProjectCheck, + generateResultObject, + wrapInUndoGroup +} from './helpers.js'; + +import { + validatePathPoints, + zeroTangents, + emitShapeObject +} from './shapeGenerators.js'; + +export const MASK_MATCH_NAMES = { + parade: 'ADBE Mask Parade', + atom: 'ADBE Mask Atom', + path: 'ADBE Mask Shape', + feather: 'ADBE Mask Feather', + opacity: 'ADBE Mask Opacity', + expansion: 'ADBE Mask Offset' +}; + +const MASK_MODES = ['none', 'add', 'subtract', 'intersect', 'lighten', 'darken', 'difference']; + +/** Map a friendly mode string to its MaskMode constant. */ +function maskModeES3(mode: string): string { + const m = mode.toLowerCase(); + if (MASK_MODES.indexOf(m) === -1) { + throw new Error('mode must be one of: ' + MASK_MODES.join(', ')); + } + return 'MaskMode.' + m.toUpperCase(); +} + +/** Emit ES3 resolving `maskGroup`, and `mask` when a mask is identified. */ +function emitMaskAccess(maskIndex?: number, maskName?: string): string { + let s = ''; + s += 'var maskGroup = layer.property("' + MASK_MATCH_NAMES.parade + '");\n'; + s += 'if (!maskGroup) { throw new Error("Layer does not support masks: " + layer.name); }\n'; + + if (maskIndex !== undefined) { + s += 'if (' + maskIndex + ' < 1 || ' + maskIndex + ' > maskGroup.numProperties) {\n'; + s += ' throw new Error("Mask index ' + maskIndex + ' out of range; layer has " + maskGroup.numProperties + " mask(s)");\n'; + s += '}\n'; + s += 'var mask = maskGroup.property(' + maskIndex + ');\n'; + } else if (maskName !== undefined) { + s += 'var mask = null;\n'; + s += 'for (var mi = 1; mi <= maskGroup.numProperties; mi++) {\n'; + s += ' if (maskGroup.property(mi).name === "' + escapeString(maskName) + '") {\n'; + s += ' mask = maskGroup.property(mi);\n'; + s += ' break;\n'; + s += ' }\n'; + s += '}\n'; + s += 'if (mask === null) { throw new Error("Mask not found: ' + escapeString(maskName) + '"); }\n'; + } else { + s += 'if (maskGroup.numProperties === 0) { throw new Error("Layer has no masks: " + layer.name); }\n'; + s += 'var mask = maskGroup.property(1);\n'; + } + return s; +} + +/** + * Emit the shared mask-settings assignments used by add_mask and + * set_mask_properties. + */ +function emitMaskSettings(params: { + mode?: string; + inverted?: boolean; + locked?: boolean; + rotoBezier?: boolean; + feather?: number | number[]; + opacity?: number; + expansion?: number; + color?: { r: number; g: number; b: number }; + name?: string; +}): string { + let s = ''; + if (params.name !== undefined) { + s += 'mask.name = "' + escapeString(params.name) + '";\n'; + } + if (params.mode !== undefined) { + s += 'mask.maskMode = ' + maskModeES3(params.mode) + ';\n'; + } + if (params.inverted !== undefined) { + s += 'mask.inverted = ' + (params.inverted ? 'true' : 'false') + ';\n'; + } + if (params.locked !== undefined) { + // Set locked last where possible - a locked mask rejects further edits. + s += 'mask.locked = ' + (params.locked ? 'true' : 'false') + ';\n'; + } + if (params.rotoBezier !== undefined) { + s += 'mask.rotoBezier = ' + (params.rotoBezier ? 'true' : 'false') + ';\n'; + } + if (params.color) { + s += 'mask.color = ' + colorToES3(params.color) + ';\n'; + } + if (params.feather !== undefined) { + // Feather is a TwoD property - accept a single number for both axes. + const f = Array.isArray(params.feather) + ? '[' + params.feather[0] + ', ' + params.feather[1] + ']' + : '[' + params.feather + ', ' + params.feather + ']'; + s += 'mask.property("' + MASK_MATCH_NAMES.feather + '").setValue(' + f + ');\n'; + } + if (params.opacity !== undefined) { + s += 'mask.property("' + MASK_MATCH_NAMES.opacity + '").setValue(' + params.opacity + ');\n'; + } + if (params.expansion !== undefined) { + s += 'mask.property("' + MASK_MATCH_NAMES.expansion + '").setValue(' + params.expansion + ');\n'; + } + return s; +} + +/** + * Add a mask to a layer, with an optional path. + */ +export function generateAddMask(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + name?: string; + vertices?: number[][]; + inTangents?: number[][]; + outTangents?: number[][]; + closed?: boolean; + mode?: string; + inverted?: boolean; + rotoBezier?: boolean; + feather?: number | number[]; + opacity?: number; + expansion?: number; + color?: { r: number; g: number; b: number }; +}): string { + if (params.vertices) { + validatePathPoints(params.vertices, params.inTangents, params.outTangents); + } + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + + script += 'var maskGroup = layer.property("' + MASK_MATCH_NAMES.parade + '");\n'; + script += 'if (!maskGroup) { throw new Error("Layer does not support masks: " + layer.name); }\n'; + script += 'var mask = maskGroup.addProperty("' + MASK_MATCH_NAMES.atom + '");\n'; + + if (params.vertices) { + const inT = params.inTangents || zeroTangents(params.vertices.length); + const outT = params.outTangents || zeroTangents(params.vertices.length); + const closed = params.closed !== undefined ? params.closed : true; + script += emitShapeObject('maskShape', params.vertices, inT, outT, closed); + script += 'mask.property("' + MASK_MATCH_NAMES.path + '").setValue(maskShape);\n'; + } + + script += emitMaskSettings(params); + + // Read values out before anything else can invalidate the reference. + script += 'var createdMaskName = mask.name;\n'; + script += 'var createdMaskIndex = maskGroup.numProperties;\n'; + + script += generateResultObject({ + layerName: 'layer.name', + maskName: 'createdMaskName', + maskIndex: 'createdMaskIndex', + vertexCount: params.vertices ? String(params.vertices.length) : '0' + }); + + return wrapInUndoGroup(script, 'Add Mask'); +} + +/** + * List every mask on a layer with its settings. + */ +export function generateListMasks(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; +}): string { + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + + script += 'var maskGroup = layer.property("' + MASK_MATCH_NAMES.parade + '");\n'; + script += 'var masks = [];\n'; + script += 'if (maskGroup) {\n'; + script += ' for (var i = 1; i <= maskGroup.numProperties; i++) {\n'; + script += ' var m = maskGroup.property(i);\n'; + script += ' var pathProp = m.property("' + MASK_MATCH_NAMES.path + '");\n'; + script += ' masks.push({\n'; + script += ' index: i,\n'; + script += ' name: m.name,\n'; + script += ' inverted: m.inverted,\n'; + script += ' locked: m.locked,\n'; + script += ' rotoBezier: m.rotoBezier,\n'; + script += ' numVertices: pathProp.value.vertices.length,\n'; + script += ' pathIsAnimated: pathProp.numKeys > 0,\n'; + script += ' numPathKeys: pathProp.numKeys,\n'; + script += ' feather: m.property("' + MASK_MATCH_NAMES.feather + '").value,\n'; + script += ' opacity: m.property("' + MASK_MATCH_NAMES.opacity + '").value,\n'; + script += ' expansion: m.property("' + MASK_MATCH_NAMES.expansion + '").value\n'; + script += ' });\n'; + script += ' }\n'; + script += '}\n'; + + script += generateResultObject({ + layerName: 'layer.name', + numMasks: 'masks.length', + masks: 'masks' + }); + + return script; +} + +/** + * Read a mask path back, including keyframes when animated. + */ +export function generateGetMaskPath(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + maskIndex?: number; + maskName?: string; +}): string { + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + script += emitMaskAccess(params.maskIndex, params.maskName); + + script += 'var pathProp = mask.property("' + MASK_MATCH_NAMES.path + '");\n'; + script += 'var current = pathProp.value;\n'; + script += 'var keys = [];\n'; + script += 'for (var k = 1; k <= pathProp.numKeys; k++) {\n'; + script += ' var kv = pathProp.keyValue(k);\n'; + script += ' keys.push({\n'; + script += ' time: pathProp.keyTime(k),\n'; + script += ' vertices: kv.vertices,\n'; + script += ' inTangents: kv.inTangents,\n'; + script += ' outTangents: kv.outTangents,\n'; + script += ' closed: kv.closed\n'; + script += ' });\n'; + script += '}\n'; + + script += generateResultObject({ + layerName: 'layer.name', + maskName: 'mask.name', + vertices: 'current.vertices', + inTangents: 'current.inTangents', + outTangents: 'current.outTangents', + closed: 'current.closed', + isAnimated: 'pathProp.numKeys > 0', + numKeys: 'pathProp.numKeys', + keyframes: 'keys' + }); + + return script; +} + +/** + * Replace a mask's path. + */ +export function generateSetMaskPath(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + maskIndex?: number; + maskName?: string; + vertices: number[][]; + inTangents?: number[][]; + outTangents?: number[][]; + closed?: boolean; +}): string { + validatePathPoints(params.vertices, params.inTangents, params.outTangents); + + const inT = params.inTangents || zeroTangents(params.vertices.length); + const outT = params.outTangents || zeroTangents(params.vertices.length); + const closed = params.closed !== undefined ? params.closed : true; + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + script += emitMaskAccess(params.maskIndex, params.maskName); + + script += emitShapeObject('maskShape', params.vertices, inT, outT, closed); + script += 'mask.property("' + MASK_MATCH_NAMES.path + '").setValue(maskShape);\n'; + + script += generateResultObject({ + layerName: 'layer.name', + maskName: 'mask.name', + vertexCount: String(params.vertices.length) + }); + + return wrapInUndoGroup(script, 'Set Mask Path'); +} + +/** + * Animate a mask path. + * + * As with shape paths, Shape-valued properties must be keyed one at a time - + * setValuesAtTimes() does not work on them. + */ +export function generateSetMaskKeyframes(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + maskIndex?: number; + maskName?: string; + keyframes: Array<{ + time: number; + vertices: number[][]; + inTangents?: number[][]; + outTangents?: number[][]; + closed?: boolean; + }>; +}): string { + if (!params.keyframes || !Array.isArray(params.keyframes) || params.keyframes.length === 0) { + throw new Error('keyframes must be a non-empty array'); + } + for (let i = 0; i < params.keyframes.length; i++) { + const kf = params.keyframes[i]; + if (typeof kf.time !== 'number' || !Number.isFinite(kf.time)) { + throw new Error('keyframes[' + i + '].time must be a finite number'); + } + validatePathPoints(kf.vertices, kf.inTangents, kf.outTangents); + } + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + script += emitMaskAccess(params.maskIndex, params.maskName); + + script += 'var pathProp = mask.property("' + MASK_MATCH_NAMES.path + '");\n'; + + for (let i = 0; i < params.keyframes.length; i++) { + const kf = params.keyframes[i]; + const inT = kf.inTangents || zeroTangents(kf.vertices.length); + const outT = kf.outTangents || zeroTangents(kf.vertices.length); + const cl = kf.closed !== undefined ? kf.closed : true; + script += emitShapeObject('mkShape' + i, kf.vertices, inT, outT, cl); + script += 'pathProp.setValueAtTime(' + kf.time + ', mkShape' + i + ');\n'; + } + + script += generateResultObject({ + layerName: 'layer.name', + maskName: 'mask.name', + keysWritten: String(params.keyframes.length), + numKeys: 'pathProp.numKeys' + }); + + return wrapInUndoGroup(script, 'Set Mask Keyframes'); +} + +/** + * Change a mask's settings without touching its path. + */ +export function generateSetMaskProperties(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + maskIndex?: number; + maskName?: string; + name?: string; + mode?: string; + inverted?: boolean; + locked?: boolean; + rotoBezier?: boolean; + feather?: number | number[]; + opacity?: number; + expansion?: number; + color?: { r: number; g: number; b: number }; +}): string { + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + script += emitMaskAccess(params.maskIndex, params.maskName); + + // Unlock first so the other assignments are not rejected, then re-apply the + // requested lock state at the end. + script += 'var wasLocked = mask.locked;\n'; + script += 'try {\n'; + script += ' if (wasLocked) { mask.locked = false; }\n'; + + const settings = { ...params }; + delete settings.locked; + script += emitMaskSettings(settings); + script += '} finally {\n'; + + if (params.locked !== undefined) { + script += 'mask.locked = ' + (params.locked ? 'true' : 'false') + ';\n'; + } else { + script += 'mask.locked = wasLocked;\n'; + } + script += '}\n'; + + script += generateResultObject({ + layerName: 'layer.name', + maskName: 'mask.name', + locked: 'mask.locked', + inverted: 'mask.inverted' + }); + + return wrapInUndoGroup(script, 'Set Mask Properties'); +} + +/** + * Delete a mask. + */ +export function generateDeleteMask(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + maskIndex?: number; + maskName?: string; +}): string { + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + script += emitMaskAccess(params.maskIndex, params.maskName); + + script += 'var removedName = mask.name;\n'; + script += 'if (mask.locked) { mask.locked = false; }\n'; + script += 'mask.remove();\n'; + + script += generateResultObject({ + layerName: 'layer.name', + removed: 'removedName', + remainingMasks: 'maskGroup.numProperties' + }); + + return wrapInUndoGroup(script, 'Delete Mask'); +} diff --git a/src/ae-integration/generators/regressions.test.ts b/src/ae-integration/generators/regressions.test.ts new file mode 100644 index 0000000..691292e --- /dev/null +++ b/src/ae-integration/generators/regressions.test.ts @@ -0,0 +1,253 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import vm from 'node:vm'; +import { generateApplyExpressionTemplate, generateBatchSetExpressions, getExpressionTemplates } from './expressionGenerators.js'; +import { generateControlRender } from './renderQueueGenerators.js'; +import { generateScaleKeyframeTiming, generateOffsetKeyframes } from './keyframeGenerators.js'; +import { generateRenderFrame } from './compositionGenerators.js'; +import { validatePathPoints, generateSetPathKeyframes } from './shapeGenerators.js'; +import { generateSetMaskKeyframes, generateSetMaskProperties } from './maskGenerators.js'; + +function runGenerator(script: string, property: object = {}, extra: Record = {}) { + const prop = Object.assign(property, { property: () => prop }); + class CompItem { + duration = 10; + frameRate = 30; + name = 'Test'; + layer() { return prop; } + } + const comp = new CompItem(); + Object.assign(comp, extra.comp); + return vm.runInNewContext(script, { + app: { project: { activeItem: comp } }, CompItem, ...extra + }); +} + +function expression(template: string, params?: Record) { + const prop = { expression: '' }; + runGenerator(generateApplyExpressionTemplate({ + layerIndex: 1, property: 'rotation', template, params + }), prop); + return prop.expression; +} + +type SpeedKey = { time: number; value: number; hold?: boolean }; +function speedAt(keys: SpeedKey[], time: number, inPoint = 0, value: number | number[] = 0) { + const slider = { + numKeys: keys.length, + value: 10, + key: (i: number) => keys[i - 1], + valueAtTime(t: number) { + if (!keys.length) return this.value; + if (t <= keys[0].time) return keys[0].value; + for (let i = 1; i < keys.length; i++) { + const a = keys[i - 1], b = keys[i]; + if (t < b.time) { + return a.hold ? a.value : a.value + (b.value - a.value) * (t - a.time) / (b.time - a.time); + } + } + return keys[keys.length - 1].value; + } + }; + return vm.runInNewContext(expression('speedControl'), { + effect: () => () => slider, time, inPoint, value, Array + }); +} + +describe('speed integration', () => { + it('does not reinterpret a completed HOLD segment as a ramp', () => { + const keys = [{ time: 0, value: 100, hold: true }, { time: 1, value: 0 }]; + assert.equal(speedAt(keys, 0.5), 50); + assert.equal(speedAt(keys, 1), 100); + assert.equal(speedAt(keys, 2), 100); + assert.ok(Math.abs(speedAt(keys, 1 - 1e-8) - speedAt(keys, 1)) < 1e-5); + }); + + it('clips integration to inPoint, including a partially visible key interval', () => { + const keys = [{ time: 0, value: 10 }, { time: 1, value: 20 }, { time: 2, value: 30 }]; + assert.equal(speedAt(keys, 1, 1.5), 0); + assert.equal(speedAt(keys, 1.5, 1.5), 0); + assert.equal(speedAt(keys, 2, 1.5), 13.75); + assert.equal(speedAt(keys, 3, 1.5), 43.75); + assert.equal(speedAt(keys, 4, 3), 30); + }); + + it('integrates linear ramps, including partial intervals and constant tails', () => { + const keys = [{ time: 0, value: 0 }, { time: 2, value: 360 }, { time: 3, value: 0 }]; + assert.equal(speedAt(keys, 1), 90); + assert.equal(speedAt(keys, 2), 360); + assert.equal(speedAt(keys, 2.5), 495); + assert.equal(speedAt(keys, 4), 540); + assert.equal(speedAt([{ time: 2, value: 10 }], 3), 30); + assert.equal(speedAt([], 3, 1), 20); + assert.deepEqual(Array.from(speedAt([], 3, 1, [5, 10])), [25, 30]); + }); +}); + +describe('render bridge contract', () => { + it('returns a native render result only after synchronous render completes', () => { + const rq = { + numItems: 1, rendering: false, completed: false, + render() { this.rendering = true; this.completed = true; this.rendering = false; } + }; + const result = vm.runInNewContext(generateControlRender({ action: 'start' }), { + app: { project: { renderQueue: rq } } + }); + assert.equal(rq.completed, true); + assert.equal(result.rendering, false); + assert.equal(result.action, 'start'); + }); +}); + +describe('expression compatibility and escaping', () => { + it('batch expressions navigate friendly aliases to the actual transform property', () => { + const prop = { expression: '' }; + const transform = { + property(name: string) { assert.equal(name, 'ADBE Position'); return prop; } + }; + class CompItem { + layer() { + return { property(name: string) { assert.equal(name, 'ADBE Transform Group'); return transform; } }; + } + } + const result = vm.runInNewContext(generateBatchSetExpressions({ + layerIndex: 1, expressions: [{ property: 'position', expression: 'value + [10, 20]' }] + }), { app: { project: { activeItem: new CompItem() } }, CompItem }); + assert.equal(result[0].success, true); + assert.equal(prop.expression, 'value + [10, 20]'); + }); + + it('keeps legacy bounce parameters and exposes gravity bounce separately', () => { + const templates = getExpressionTemplates(); + assert.ok(templates.bounce.params?.amplitude); + assert.ok(templates.bounce.params?.frequency); + assert.ok(templates.bounce.params?.decay); + assert.ok(templates.bounceBack.params?.gravity); + const source = expression('bounce', { amplitude: 0, frequency: 4, decay: 6 }); + const result = vm.runInNewContext(source, { + numKeys: 1, key: () => ({ time: 0 }), nearestKey: () => ({ index: 1 }), + time: 0.1, value: 42, velocityAtTime: () => 100, thisComp: { frameDuration: 1 / 30 } + }); + assert.equal(result, 42); + }); + + it('escapes newlines and quotes in expression string parameters', () => { + const name = 'Speed\n"quoted"\\name'; + const source = expression('speedControl', { controlName: name }); + vm.runInNewContext(source, { + effect: (actual: string) => { + assert.equal(actual, name); + return () => ({ numKeys: 0, value: 10, valueAtTime: () => 10 }); + }, + time: 1, inPoint: 0, value: 0 + }); + }); +}); + +function keyframeProperty(times: number[]) { + const keys = times.map((time, i) => ({ time, value: i + 1 })); + const prop = { + removed: 0, + get numKeys() { return keys.length; }, + keyTime: (i: number) => keys[i - 1].time, + keyValue: (i: number) => keys[i - 1].value, + keyInInterpolationType: () => 1, keyOutInterpolationType: () => 1, + keyInTemporalEase: () => [], keyOutTemporalEase: () => [], + removeKey(i: number) { this.removed++; keys.splice(i - 1, 1); }, + addKey(time: number) { keys.push({ time, value: 0 }); return keys.length; }, + setValueAtKey(i: number, value: number) { keys[i - 1].value = value; }, + setInterpolationTypeAtKey() {}, setTemporalEaseAtKey() {} + }; + return { keys, prop }; +} + +describe('keyframe retiming safety', () => { + for (const scale of [1, 0.1]) { + it(`preserves distinct subframe keys when scaling by ${scale}`, () => { + const { prop, keys } = keyframeProperty([0, 0.001]); + runGenerator(generateScaleKeyframeTiming({ layerIndex: 1, property: 'rotation', scale }), prop); + assert.deepEqual(keys, [{ time: 0, value: 1 }, { time: 0.001 * scale, value: 2 }]); + }); + } + + for (const params of [{ scale: 2, anchorTime: 2 }, { scale: 1e308 }, { scale: 1e-300, anchorTime: 1e20 }]) { + it(`rejects invalid computed times before deleting keys: ${JSON.stringify(params)}`, () => { + const { prop } = keyframeProperty([0, 2]); + assert.throws(() => runGenerator(generateScaleKeyframeTiming({ + layerIndex: 1, property: 'rotation', ...params + }), prop), /Nothing was changed/); + assert.equal(prop.removed, 0); + }); + } + + it('rejects overflowing offsets without deleting keys', () => { + const { prop } = keyframeProperty([1e308]); + assert.throws(() => runGenerator(generateOffsetKeyframes({ + layerIndex: 1, property: 'rotation', offset: 1e308 + }), prop), /Nothing was changed/); + assert.equal(prop.removed, 0); + }); +}); + +function render(options: { appearsAt?: number; grows?: boolean; empty?: boolean; stale?: boolean } = {}) { + let elapsed = 0; + class File { + fsName: string; + constructor(path: string) { this.fsName = path; } + get exists() { return options.stale || elapsed >= (options.appearsAt ?? 50); } + get length() { return options.empty ? 0 : options.grows ? elapsed : 100; } + remove() { return false; } + } + class Folder { + exists = true; + fsName: string; + constructor(path: string) { this.fsName = path; } + } + return runGenerator(generateRenderFrame({ time: 0 }), {}, { + File, Folder, $: { sleep: (ms: number) => { elapsed += ms; } }, + comp: { saveFrameToPng() {} } + }); +} + +describe('frame render completion', () => { + it('returns a non-empty stable file', () => { + assert.equal(render().success, true); + assert.equal(render().bytes, 100); + }); + for (const options of [ + { appearsAt: Infinity }, { appearsAt: 14950 }, { grows: true }, { empty: true }, { stale: true } + ]) { + it(`does not report incomplete or stale frames as successful: ${JSON.stringify(options)}`, () => { + assert.throws(() => render(options)); + }); + } +}); + +describe('path and mask safety', () => { + it('validates finite vertices and matching finite tangents', () => { + assert.doesNotThrow(() => validatePathPoints([[0, 0], [1, 1]])); + assert.throws(() => validatePathPoints([[0, 0], [Infinity, 1]])); + assert.throws(() => validatePathPoints([[0, 0], [1, 1]], [[0, 0]])); + assert.throws(() => validatePathPoints([[0, 0], [1, 1]], [[0, 0], [NaN, 0]])); + }); + + for (const generate of [generateSetPathKeyframes, generateSetMaskKeyframes]) { + it(`${generate.name} rejects invalid key times before executing any script`, () => { + assert.throws(() => generate({ + layerIndex: 1, + keyframes: [ + { time: 0, vertices: [[0, 0], [1, 1]] }, + { time: Infinity, vertices: [[0, 0], [1, 1]] } + ] + }), /finite number/); + }); + } + + it('restores a mask lock even if a property update throws', () => { + const mask = { locked: true, numProperties: 1, setValue() { throw new Error('invalid feather'); } }; + assert.throws(() => runGenerator(generateSetMaskProperties({ + layerIndex: 1, maskIndex: 1, feather: -1 + }), mask), /invalid feather/); + assert.equal(mask.locked, true); + }); +}); diff --git a/src/ae-integration/generators/renderQueueGenerators.ts b/src/ae-integration/generators/renderQueueGenerators.ts new file mode 100644 index 0000000..07dac1d --- /dev/null +++ b/src/ae-integration/generators/renderQueueGenerators.ts @@ -0,0 +1,336 @@ +/** + * Render Queue Generators + * + * The server previously had no render queue coverage at all - render_frame + * grabs a single still, but there was no way to actually deliver anything. + * + * Verified against the After Effects scripting guide (renderqueue/*). + */ + +import { + escapeString, + generateProjectCheck, + generateCompAccess, + generateResultObject, + wrapInUndoGroup +} from './helpers.js'; + +/** + * Emit an ES3 helper that maps RQItemStatus to a readable string. + * The enum values are opaque numbers over the bridge, so translate them here. + */ +function emitStatusHelper(): string { + let s = ''; + s += 'function statusName(st) {\n'; + s += ' if (st === RQItemStatus.WILL_CONTINUE) { return "willContinue"; }\n'; + s += ' if (st === RQItemStatus.NEEDS_OUTPUT) { return "needsOutput"; }\n'; + s += ' if (st === RQItemStatus.UNQUEUED) { return "unqueued"; }\n'; + s += ' if (st === RQItemStatus.QUEUED) { return "queued"; }\n'; + s += ' if (st === RQItemStatus.RENDERING) { return "rendering"; }\n'; + s += ' if (st === RQItemStatus.USER_STOPPED) { return "userStopped"; }\n'; + s += ' if (st === RQItemStatus.ERR_STOPPED) { return "errStopped"; }\n'; + s += ' if (st === RQItemStatus.DONE) { return "done"; }\n'; + s += ' return "unknown";\n'; + s += '}\n'; + return s; +} + +/** Emit ES3 resolving `rqItem` from a 1-based render queue index. */ +function emitRQItemAccess(itemIndex: number): string { + let s = ''; + s += 'var rq = app.project.renderQueue;\n'; + s += 'if (' + itemIndex + ' < 1 || ' + itemIndex + ' > rq.numItems) {\n'; + s += ' throw new Error("Render queue index ' + itemIndex + ' out of range; queue has " + rq.numItems + " item(s)");\n'; + s += '}\n'; + s += 'var rqItem = rq.item(' + itemIndex + ');\n'; + return s; +} + +/** + * Add a composition to the render queue. + */ +export function generateAddToRenderQueue(params: { + compId?: number; + compName?: string; + outputPath?: string; + renderSettingsTemplate?: string; + outputModuleTemplate?: string; + timeSpanStart?: number; + timeSpanDuration?: number; + skipFrames?: number; +}): string { + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += emitStatusHelper(); + + script += 'var rqItem = app.project.renderQueue.items.add(comp);\n'; + + // Render settings template must be applied before output module settings, + // because applying a render template can reset the output modules. + if (params.renderSettingsTemplate) { + script += 'rqItem.applyTemplate("' + escapeString(params.renderSettingsTemplate) + '");\n'; + } + if (params.outputModuleTemplate) { + script += 'rqItem.outputModule(1).applyTemplate("' + escapeString(params.outputModuleTemplate) + '");\n'; + } + if (params.outputPath) { + script += 'rqItem.outputModule(1).file = new File("' + escapeString(params.outputPath) + '");\n'; + } + if (params.timeSpanStart !== undefined) { + script += 'rqItem.timeSpanStart = ' + params.timeSpanStart + ';\n'; + } + if (params.timeSpanDuration !== undefined) { + script += 'rqItem.timeSpanDuration = ' + params.timeSpanDuration + ';\n'; + } + if (params.skipFrames !== undefined) { + script += 'rqItem.skipFrames = ' + params.skipFrames + ';\n'; + } + + script += generateResultObject({ + queueIndex: 'app.project.renderQueue.numItems', + compName: 'comp.name', + status: 'statusName(rqItem.status)', + outputPath: 'rqItem.outputModule(1).file ? rqItem.outputModule(1).file.fsName : null', + timeSpanStart: 'rqItem.timeSpanStart', + timeSpanDuration: 'rqItem.timeSpanDuration' + }); + + return wrapInUndoGroup(script, 'Add to Render Queue'); +} + +/** + * List the render queue with per-item status and output paths. + */ +export function generateListRenderQueue(_params: Record = {} as Record): string { + let script = ''; + script += generateProjectCheck(); + script += emitStatusHelper(); + + script += 'var rq = app.project.renderQueue;\n'; + script += 'var items = [];\n'; + script += 'for (var i = 1; i <= rq.numItems; i++) {\n'; + script += ' var it = rq.item(i);\n'; + script += ' var mods = [];\n'; + script += ' for (var m = 1; m <= it.numOutputModules; m++) {\n'; + script += ' var om = it.outputModule(m);\n'; + script += ' mods.push({\n'; + script += ' index: m,\n'; + script += ' name: om.name,\n'; + script += ' file: om.file ? om.file.fsName : null\n'; + script += ' });\n'; + script += ' }\n'; + script += ' items.push({\n'; + script += ' index: i,\n'; + script += ' compName: it.comp.name,\n'; + script += ' status: statusName(it.status),\n'; + script += ' timeSpanStart: it.timeSpanStart,\n'; + script += ' timeSpanDuration: it.timeSpanDuration,\n'; + script += ' skipFrames: it.skipFrames,\n'; + script += ' elapsedSeconds: it.elapsedSeconds,\n'; + script += ' outputModules: mods\n'; + script += ' });\n'; + script += '}\n'; + + script += generateResultObject({ + numItems: 'rq.numItems', + rendering: 'rq.rendering', + canQueueInAME: 'rq.canQueueInAME', + items: 'items' + }); + + return script; +} + +/** + * List the available render settings and output module templates, so callers + * can use the presets already configured in After Effects by name rather than + * trying to reconstruct settings. + */ +export function generateListRenderTemplates(_params: Record = {} as Record): string { + let script = ''; + script += generateProjectCheck(); + + // Templates are read off a queue item, so use a temporary one when the queue + // is empty, then remove it again. + script += 'var rq = app.project.renderQueue;\n'; + script += 'var tempItem = null;\n'; + script += 'var probe = null;\n'; + script += 'if (rq.numItems > 0) {\n'; + script += ' probe = rq.item(1);\n'; + script += '} else {\n'; + script += ' var tempComp = app.project.items.addComp("__rq_template_probe__", 16, 16, 1, 1, 24);\n'; + script += ' tempItem = rq.items.add(tempComp);\n'; + script += ' probe = tempItem;\n'; + script += '}\n'; + script += 'var renderTemplates = probe.templates;\n'; + script += 'var omTemplates = probe.outputModule(1).templates;\n'; + script += 'if (tempItem !== null) {\n'; + script += ' var tc = tempItem.comp;\n'; + script += ' tempItem.remove();\n'; + script += ' tc.remove();\n'; + script += '}\n'; + + script += generateResultObject({ + renderSettingsTemplates: 'renderTemplates', + outputModuleTemplates: 'omTemplates' + }); + + return script; +} + +/** + * Change settings on an existing render queue item. + */ +export function generateSetRenderQueueItem(params: { + itemIndex: number; + outputPath?: string; + renderSettingsTemplate?: string; + outputModuleTemplate?: string; + outputModuleIndex?: number; + timeSpanStart?: number; + timeSpanDuration?: number; + skipFrames?: number; + render?: boolean; +}): string { + if (typeof params.itemIndex !== 'number') { + throw new Error('itemIndex is required (1-based render queue index)'); + } + + const omIndex = params.outputModuleIndex || 1; + + let script = ''; + script += generateProjectCheck(); + script += emitStatusHelper(); + script += emitRQItemAccess(params.itemIndex); + + if (params.renderSettingsTemplate) { + script += 'rqItem.applyTemplate("' + escapeString(params.renderSettingsTemplate) + '");\n'; + } + if (params.outputModuleTemplate) { + script += 'rqItem.outputModule(' + omIndex + ').applyTemplate("' + escapeString(params.outputModuleTemplate) + '");\n'; + } + if (params.outputPath) { + script += 'rqItem.outputModule(' + omIndex + ').file = new File("' + escapeString(params.outputPath) + '");\n'; + } + if (params.timeSpanStart !== undefined) { + script += 'rqItem.timeSpanStart = ' + params.timeSpanStart + ';\n'; + } + if (params.timeSpanDuration !== undefined) { + script += 'rqItem.timeSpanDuration = ' + params.timeSpanDuration + ';\n'; + } + if (params.skipFrames !== undefined) { + script += 'rqItem.skipFrames = ' + params.skipFrames + ';\n'; + } + if (params.render !== undefined) { + // Controls whether this item is checked/queued for the next render pass. + script += 'rqItem.render = ' + (params.render ? 'true' : 'false') + ';\n'; + } + + script += generateResultObject({ + index: String(params.itemIndex), + compName: 'rqItem.comp.name', + status: 'statusName(rqItem.status)', + outputPath: 'rqItem.outputModule(' + omIndex + ').file ? rqItem.outputModule(' + omIndex + ').file.fsName : null' + }); + + return wrapInUndoGroup(script, 'Set Render Queue Item'); +} + +/** + * Remove an item from the render queue, or clear the whole queue. + */ +export function generateRemoveFromRenderQueue(params: { + itemIndex?: number; + all?: boolean; +}): string { + let script = ''; + script += generateProjectCheck(); + script += 'var rq = app.project.renderQueue;\n'; + + if (params.all) { + script += 'var removed = rq.numItems;\n'; + script += 'for (var i = rq.numItems; i >= 1; i--) {\n'; + script += ' rq.item(i).remove();\n'; + script += '}\n'; + } else { + if (typeof params.itemIndex !== 'number') { + throw new Error('Provide itemIndex, or all: true to clear the queue'); + } + script += emitRQItemAccess(params.itemIndex); + script += 'rqItem.remove();\n'; + script += 'var removed = 1;\n'; + } + + script += generateResultObject({ + removed: 'removed', + remaining: 'rq.numItems' + }); + + return wrapInUndoGroup(script, 'Remove from Render Queue'); +} + +/** + * Start synchronous native rendering or show the Render Queue panel. + * RenderQueue.render() blocks CEP until the queue finishes: the bridge cannot + * poll, stop, pause or resume it. A command timeout does NOT cancel the render. + * Use queue_in_ame for long jobs and AE's UI to interrupt native rendering. + */ +export function generateControlRender(params: { + action: 'start' | 'stop' | 'pause' | 'resume' | 'showWindow'; +}): string { + const action = params.action; + if (['stop', 'pause', 'resume'].indexOf(action) !== -1) { + throw new Error('The CEP bridge cannot stop, pause or resume a synchronous native render. Use After Effects UI controls or queue_in_ame for long jobs.'); + } + const valid = ['start', 'showWindow']; + if (valid.indexOf(action) === -1) { + throw new Error('action must be one of: ' + valid.join(', ')); + } + + let script = ''; + script += generateProjectCheck(); + script += emitStatusHelper(); + script += 'var rq = app.project.renderQueue;\n'; + + if (action === 'start') { + script += 'if (rq.numItems === 0) { throw new Error("Render queue is empty"); }\n'; + script += 'rq.render();\n'; + } else { + script += 'rq.showWindow(true);\n'; + } + + script += generateResultObject({ + action: '"' + action + '"', + rendering: 'rq.rendering', + numItems: 'rq.numItems' + }); + + return script; +} + +/** + * Hand the render queue to Adobe Media Encoder. + * + * Unlike render(), this returns immediately - AME does the work in its own + * process - which makes it the better choice for anything long. + */ +export function generateQueueInAME(params: { renderImmediately?: boolean }): string { + const immediate = params.renderImmediately === true; + + let script = ''; + script += generateProjectCheck(); + script += 'var rq = app.project.renderQueue;\n'; + script += 'if (!rq.canQueueInAME) {\n'; + script += ' throw new Error("Cannot queue in AME. Check that Adobe Media Encoder is installed and that the queue has at least one item needing output.");\n'; + script += '}\n'; + script += 'rq.queueInAME(' + (immediate ? 'true' : 'false') + ');\n'; + + script += generateResultObject({ + queuedInAME: 'true', + startedProcessing: immediate ? 'true' : 'false', + numItems: 'rq.numItems' + }); + + return script; +} diff --git a/src/ae-integration/generators/shapeGenerators.ts b/src/ae-integration/generators/shapeGenerators.ts new file mode 100644 index 0000000..6a556bb --- /dev/null +++ b/src/ae-integration/generators/shapeGenerators.ts @@ -0,0 +1,520 @@ +/** + * Shape Layer Generators + * + * Bezier path creation / reading / animation, plus shape operators + * (Trim Paths, Repeater, etc). + * + * IMPORTANT: every property lookup in here uses matchNames, never English + * display names. `rect.property("Size")` returns null on a non-English After + * Effects; `rect.property("ADBE Vector Rect Size")` always works. + * + * Match names verified empirically against After Effects 26.5 and cross-checked + * against docsforadobe/after-effects-scripting-guide. + */ + +import { + escapeString, + arrayToES3, + colorToES3, + positionToES3, + generateCompAccess, + generateLayerAccess, + generateProjectCheck, + generateResultObject, + wrapInUndoGroup +} from './helpers.js'; + +/** + * Shape layer structural match names. + */ +export const SHAPE_MATCH_NAMES = { + rootContents: 'ADBE Root Vectors Group', + group: 'ADBE Vector Group', + groupContents: 'ADBE Vectors Group', + pathGroup: 'ADBE Vector Shape - Group', + path: 'ADBE Vector Shape', + fill: 'ADBE Vector Graphic - Fill', + fillColor: 'ADBE Vector Fill Color', + stroke: 'ADBE Vector Graphic - Stroke', + strokeColor: 'ADBE Vector Stroke Color', + strokeWidth: 'ADBE Vector Stroke Width' +}; + +/** + * Shape operators, all verified against AE 26.5. + * + * NOTE: Pucker & Bloat is "ADBE Vector Filter - PB". The plausible-looking + * "ADBE Vector Filter - PuckerBloat" is NOT a valid match name and passing it + * to addProperty() hard-crashes After Effects with no exception and no dialog. + */ +export const SHAPE_OPERATORS: Record = { + trimPaths: 'ADBE Vector Filter - Trim', + repeater: 'ADBE Vector Filter - Repeater', + roundCorners: 'ADBE Vector Filter - RC', + zigZag: 'ADBE Vector Filter - Zigzag', + twist: 'ADBE Vector Filter - Twist', + puckerBloat: 'ADBE Vector Filter - PB', + offsetPaths: 'ADBE Vector Filter - Offset', + wigglePaths: 'ADBE Vector Filter - Roughen', + wiggleTransform: 'ADBE Vector Filter - Wiggler', + mergePaths: 'ADBE Vector Filter - Merge', + gradientFill: 'ADBE Vector Graphic - G-Fill', + gradientStroke: 'ADBE Vector Graphic - G-Stroke' +}; + +/** + * Per-operator property match names, so callers can set values without + * knowing the raw ADBE strings. + */ +export const SHAPE_OPERATOR_PROPERTIES: Record> = { + trimPaths: { + start: 'ADBE Vector Trim Start', + end: 'ADBE Vector Trim End', + offset: 'ADBE Vector Trim Offset', + trimMultipleShapes: 'ADBE Vector Trim Type' + }, + repeater: { + copies: 'ADBE Vector Repeater Copies', + offset: 'ADBE Vector Repeater Offset', + composite: 'ADBE Vector Repeater Order' + }, + roundCorners: { radius: 'ADBE Vector RoundCorner Radius' }, + zigZag: { + size: 'ADBE Vector Zigzag Size', + ridgesPerSegment: 'ADBE Vector Zigzag Detail', + points: 'ADBE Vector Zigzag Points' + }, + twist: { angle: 'ADBE Vector Twist Angle', center: 'ADBE Vector Twist Center' }, + puckerBloat: { amount: 'ADBE Vector PuckerBloat Amount' }, + offsetPaths: { + amount: 'ADBE Vector Offset Amount', + lineJoin: 'ADBE Vector Offset Line Join', + miterLimit: 'ADBE Vector Offset Miter Limit', + copies: 'ADBE Vector Offset Copies', + copyOffset: 'ADBE Vector Offset Copy Offset' + }, + wigglePaths: { + size: 'ADBE Vector Roughen Size', + detail: 'ADBE Vector Roughen Detail', + points: 'ADBE Vector Roughen Points', + wigglesPerSecond: 'ADBE Vector Temporal Freq', + correlation: 'ADBE Vector Correlation', + temporalPhase: 'ADBE Vector Temporal Phase', + spatialPhase: 'ADBE Vector Spatial Phase', + randomSeed: 'ADBE Vector Random Seed' + }, + wiggleTransform: { + wigglesPerSecond: 'ADBE Vector Xform Temporal Freq', + correlation: 'ADBE Vector Correlation', + temporalPhase: 'ADBE Vector Temporal Phase', + spatialPhase: 'ADBE Vector Spatial Phase', + randomSeed: 'ADBE Vector Random Seed' + }, + mergePaths: { mode: 'ADBE Vector Merge Type' }, + gradientFill: { + type: 'ADBE Vector Grad Type', + startPoint: 'ADBE Vector Grad Start Pt', + endPoint: 'ADBE Vector Grad End Pt', + highlightLength: 'ADBE Vector Grad HiLite Length', + highlightAngle: 'ADBE Vector Grad HiLite Angle', + opacity: 'ADBE Vector Fill Opacity' + }, + gradientStroke: { + type: 'ADBE Vector Grad Type', + startPoint: 'ADBE Vector Grad Start Pt', + endPoint: 'ADBE Vector Grad End Pt', + opacity: 'ADBE Vector Stroke Opacity', + strokeWidth: 'ADBE Vector Stroke Width' + } +}; + +/** + * Validate a vertex list and its tangents at generate time, so bad input fails + * here with a clear message instead of somewhere inside After Effects. + */ +export function validatePathPoints( + vertices: number[][], + inTangents?: number[][], + outTangents?: number[][] +): void { + if (!vertices || !Array.isArray(vertices) || vertices.length < 2) { + throw new Error('vertices must be an array of at least 2 [x, y] points'); + } + for (let i = 0; i < vertices.length; i++) { + const v = vertices[i]; + if (!Array.isArray(v) || v.length !== 2 || typeof v[0] !== 'number' || typeof v[1] !== 'number' || + !Number.isFinite(v[0]) || !Number.isFinite(v[1])) { + throw new Error('vertices[' + i + '] must be a numeric [x, y] pair'); + } + } + const check = (t: number[][] | undefined, label: string) => { + if (t === undefined) return; + if (!Array.isArray(t) || t.length !== vertices.length) { + throw new Error(label + ' must have exactly one [x, y] pair per vertex (' + vertices.length + ')'); + } + for (let i = 0; i < t.length; i++) { + const p = t[i]; + if (!Array.isArray(p) || p.length !== 2 || typeof p[0] !== 'number' || typeof p[1] !== 'number' || + !Number.isFinite(p[0]) || !Number.isFinite(p[1])) { + throw new Error(label + '[' + i + '] must be a numeric [x, y] pair'); + } + } + }; + check(inTangents, 'inTangents'); + check(outTangents, 'outTangents'); +} + +/** Zero tangents (corner points) for a vertex list. */ +export function zeroTangents(count: number): number[][] { + const out: number[][] = []; + for (let i = 0; i < count; i++) { + out.push([0, 0]); + } + return out; +} + +/** + * Emit ES3 that builds a Shape object into the variable `varName`. + * + * Tangents are RELATIVE to their own vertex, not absolute comp coordinates. + * This is the single most common source of mangled curves. + */ +export function emitShapeObject( + varName: string, + vertices: number[][], + inTangents: number[][], + outTangents: number[][], + closed: boolean +): string { + let s = ''; + s += 'var ' + varName + ' = new Shape();\n'; + s += varName + '.vertices = ' + arrayToES3(vertices) + ';\n'; + s += varName + '.inTangents = ' + arrayToES3(inTangents) + ';\n'; + s += varName + '.outTangents = ' + arrayToES3(outTangents) + ';\n'; + s += varName + '.closed = ' + (closed ? 'true' : 'false') + ';\n'; + return s; +} + +/** + * Emit an ES3 helper that finds a path property inside a shape layer by + * walking Contents recursively. Defines findPathProp(layer, pathName). + * + * Passing pathName === null returns the first path found. + */ +function emitFindPathHelper(): string { + let s = ''; + s += 'function isGroup(p) {\n'; + s += ' return p.propertyType === PropertyType.INDEXED_GROUP || p.propertyType === PropertyType.NAMED_GROUP;\n'; + s += '}\n'; + s += 'function searchPath(group, pathName) {\n'; + s += ' for (var i = 1; i <= group.numProperties; i++) {\n'; + s += ' var child = group.property(i);\n'; + s += ' if (child.matchName === "' + SHAPE_MATCH_NAMES.pathGroup + '") {\n'; + s += ' if (pathName === null || child.name === pathName) {\n'; + s += ' return child.property("' + SHAPE_MATCH_NAMES.path + '");\n'; + s += ' }\n'; + s += ' }\n'; + s += ' if (isGroup(child)) {\n'; + s += ' var found = searchPath(child, pathName);\n'; + s += ' if (found !== null) { return found; }\n'; + s += ' }\n'; + s += ' }\n'; + s += ' return null;\n'; + s += '}\n'; + s += 'function findPathProp(layer, pathName) {\n'; + s += ' var root = layer.property("' + SHAPE_MATCH_NAMES.rootContents + '");\n'; + s += ' if (!root) { throw new Error("Layer is not a shape layer: " + layer.name); }\n'; + s += ' var p = searchPath(root, pathName);\n'; + s += ' if (p === null) {\n'; + s += ' throw new Error(pathName === null ? "No bezier path found on layer: " + layer.name : "Path not found: " + pathName);\n'; + s += ' }\n'; + s += ' return p;\n'; + s += '}\n'; + return s; +} + +/** + * Emit ES3 resolving `targetGroupContents` — the Contents of the shape group + * we should add things to. Creates a group if the layer has none. + */ +function emitGroupContents(groupIndex?: number): string { + let s = ''; + s += 'var rootContents = layer.property("' + SHAPE_MATCH_NAMES.rootContents + '");\n'; + s += 'if (!rootContents) { throw new Error("Layer is not a shape layer: " + layer.name); }\n'; + s += 'var vectorGroup = null;\n'; + if (groupIndex !== undefined) { + s += 'if (' + groupIndex + ' > rootContents.numProperties) {\n'; + s += ' throw new Error("Group index ' + groupIndex + ' out of range; layer has " + rootContents.numProperties + " group(s)");\n'; + s += '}\n'; + s += 'vectorGroup = rootContents.property(' + groupIndex + ');\n'; + } else { + s += 'for (var gi = 1; gi <= rootContents.numProperties; gi++) {\n'; + s += ' if (rootContents.property(gi).matchName === "' + SHAPE_MATCH_NAMES.group + '") {\n'; + s += ' vectorGroup = rootContents.property(gi);\n'; + s += ' break;\n'; + s += ' }\n'; + s += '}\n'; + s += 'if (vectorGroup === null) {\n'; + s += ' vectorGroup = rootContents.addProperty("' + SHAPE_MATCH_NAMES.group + '");\n'; + s += '}\n'; + } + s += 'var targetGroupContents = vectorGroup.property("' + SHAPE_MATCH_NAMES.groupContents + '");\n'; + return s; +} + +/** + * Create a bezier path. + * + * With no layerIndex/layerName a new shape layer is created; otherwise the path + * is added to the existing shape layer. + */ +export function generateCreatePath(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + name?: string; + pathName?: string; + groupIndex?: number; + vertices: number[][]; + inTangents?: number[][]; + outTangents?: number[][]; + closed?: boolean; + fillColor?: { r: number; g: number; b: number }; + strokeColor?: { r: number; g: number; b: number }; + strokeWidth?: number; + position?: { x: number; y: number }; +}): string { + validatePathPoints(params.vertices, params.inTangents, params.outTangents); + + const inTangents = params.inTangents || zeroTangents(params.vertices.length); + const outTangents = params.outTangents || zeroTangents(params.vertices.length); + const closed = params.closed !== undefined ? params.closed : true; + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + + const usingExistingLayer = params.layerIndex !== undefined || params.layerName !== undefined; + if (usingExistingLayer) { + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + } else { + script += 'var layer = comp.layers.addShape();\n'; + if (params.name) { + script += 'layer.name = "' + escapeString(params.name) + '";\n'; + } + } + + script += emitGroupContents(params.groupIndex); + + script += 'var pathGroup = targetGroupContents.addProperty("' + SHAPE_MATCH_NAMES.pathGroup + '");\n'; + if (params.pathName) { + script += 'pathGroup.name = "' + escapeString(params.pathName) + '";\n'; + } + script += 'var pathProp = pathGroup.property("' + SHAPE_MATCH_NAMES.path + '");\n'; + script += emitShapeObject('shapeObj', params.vertices, inTangents, outTangents, closed); + script += 'pathProp.setValue(shapeObj);\n'; + // Read the name into a plain string NOW. Calling addProperty() on a group + // invalidates references already held to that group's other children, so + // touching pathGroup after the fill/stroke below throws + // "ReferenceError: Object is invalid". + script += 'var createdPathName = pathGroup.name;\n'; + + if (params.fillColor) { + script += 'var fill = targetGroupContents.addProperty("' + SHAPE_MATCH_NAMES.fill + '");\n'; + script += 'fill.property("' + SHAPE_MATCH_NAMES.fillColor + '").setValue(' + colorToES3(params.fillColor) + ');\n'; + } + if (params.strokeColor) { + script += 'var stroke = targetGroupContents.addProperty("' + SHAPE_MATCH_NAMES.stroke + '");\n'; + script += 'stroke.property("' + SHAPE_MATCH_NAMES.strokeColor + '").setValue(' + colorToES3(params.strokeColor) + ');\n'; + if (params.strokeWidth !== undefined) { + script += 'stroke.property("' + SHAPE_MATCH_NAMES.strokeWidth + '").setValue(' + params.strokeWidth + ');\n'; + } + } + + if (params.position) { + script += 'layer.property("ADBE Transform Group").property("ADBE Position").setValue(' + positionToES3(params.position) + ');\n'; + } else if (!usingExistingLayer) { + script += 'layer.property("ADBE Transform Group").property("ADBE Position").setValue([comp.width / 2, comp.height / 2]);\n'; + } + + script += generateResultObject({ + layerIndex: 'layer.index', + layerName: 'layer.name', + pathName: 'createdPathName', + vertexCount: String(params.vertices.length), + closed: closed ? 'true' : 'false' + }); + + return wrapInUndoGroup(script, 'Create Path'); +} + +/** + * Read a bezier path back, including its keyframes if it is animated. + */ +export function generateGetPath(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + pathName?: string; +}): string { + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + script += emitFindPathHelper(); + + const pathNameArg = params.pathName ? '"' + escapeString(params.pathName) + '"' : 'null'; + script += 'var pathProp = findPathProp(layer, ' + pathNameArg + ');\n'; + script += 'var current = pathProp.value;\n'; + script += 'var keys = [];\n'; + script += 'for (var k = 1; k <= pathProp.numKeys; k++) {\n'; + script += ' var kv = pathProp.keyValue(k);\n'; + script += ' keys.push({\n'; + script += ' time: pathProp.keyTime(k),\n'; + script += ' vertices: kv.vertices,\n'; + script += ' inTangents: kv.inTangents,\n'; + script += ' outTangents: kv.outTangents,\n'; + script += ' closed: kv.closed\n'; + script += ' });\n'; + script += '}\n'; + + script += generateResultObject({ + layerName: 'layer.name', + pathName: 'pathProp.parentProperty.name', + vertices: 'current.vertices', + inTangents: 'current.inTangents', + outTangents: 'current.outTangents', + closed: 'current.closed', + isAnimated: 'pathProp.numKeys > 0', + numKeys: 'pathProp.numKeys', + keyframes: 'keys' + }); + + return script; +} + +/** + * Animate a bezier path. + * + * Shape-valued properties must be keyed ONE AT A TIME with setValueAtTime(). + * The bulk setter setValuesAtTimes() does not work on them — it only works for + * numeric properties. Getting this wrong fails in confusing ways. + */ +export function generateSetPathKeyframes(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + pathName?: string; + keyframes: Array<{ + time: number; + vertices: number[][]; + inTangents?: number[][]; + outTangents?: number[][]; + closed?: boolean; + }>; +}): string { + if (!params.keyframes || !Array.isArray(params.keyframes) || params.keyframes.length === 0) { + throw new Error('keyframes must be a non-empty array'); + } + for (let i = 0; i < params.keyframes.length; i++) { + const kf = params.keyframes[i]; + if (typeof kf.time !== 'number' || !Number.isFinite(kf.time)) { + throw new Error('keyframes[' + i + '].time must be a finite number'); + } + validatePathPoints(kf.vertices, kf.inTangents, kf.outTangents); + } + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + script += emitFindPathHelper(); + + const pathNameArg = params.pathName ? '"' + escapeString(params.pathName) + '"' : 'null'; + script += 'var pathProp = findPathProp(layer, ' + pathNameArg + ');\n'; + + // One setValueAtTime() per key. See the note above. + for (let i = 0; i < params.keyframes.length; i++) { + const kf = params.keyframes[i]; + const inT = kf.inTangents || zeroTangents(kf.vertices.length); + const outT = kf.outTangents || zeroTangents(kf.vertices.length); + const cl = kf.closed !== undefined ? kf.closed : true; + script += emitShapeObject('kfShape' + i, kf.vertices, inT, outT, cl); + script += 'pathProp.setValueAtTime(' + kf.time + ', kfShape' + i + ');\n'; + } + + script += generateResultObject({ + layerName: 'layer.name', + pathName: 'pathProp.parentProperty.name', + keysWritten: String(params.keyframes.length), + numKeys: 'pathProp.numKeys' + }); + + return wrapInUndoGroup(script, 'Set Path Keyframes'); +} + +/** + * Add a shape operator (Trim Paths, Repeater, etc) to a shape layer. + * + * Operators are appended to the end of the group's Contents, which is what AE + * does when you add one from the UI: an operator affects the paths above it in + * the same group. + */ +export function generateAddShapeOperator(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + operator: string; + groupIndex?: number; + name?: string; + properties?: Record; +}): string { + const matchName = SHAPE_OPERATORS[params.operator]; + if (!matchName) { + throw new Error( + 'Unknown shape operator: "' + params.operator + '". Valid operators: ' + + Object.keys(SHAPE_OPERATORS).join(', ') + ); + } + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + script += emitGroupContents(params.groupIndex); + + script += 'var op = targetGroupContents.addProperty("' + matchName + '");\n'; + if (params.name) { + script += 'op.name = "' + escapeString(params.name) + '";\n'; + } + + if (params.properties) { + const propMap = SHAPE_OPERATOR_PROPERTIES[params.operator] || {}; + for (const key in params.properties) { + if (!params.properties.hasOwnProperty(key)) continue; + const propMatchName = propMap[key]; + if (!propMatchName) { + throw new Error( + 'Unknown property "' + key + '" for operator "' + params.operator + '". Valid: ' + + (Object.keys(propMap).join(', ') || '(none settable)') + ); + } + const value = params.properties[key]; + const valueES3 = Array.isArray(value) ? arrayToES3(value) : String(value); + script += 'op.property("' + propMatchName + '").setValue(' + valueES3 + ');\n'; + } + } + + script += generateResultObject({ + layerName: 'layer.name', + operator: '"' + escapeString(params.operator) + '"', + matchName: 'op.matchName', + name: 'op.name' + }); + + return wrapInUndoGroup(script, 'Add Shape Operator'); +} diff --git a/src/ae-integration/generators/templateGenerators.ts b/src/ae-integration/generators/templateGenerators.ts index 09bb3f7..df72e35 100644 --- a/src/ae-integration/generators/templateGenerators.ts +++ b/src/ae-integration/generators/templateGenerators.ts @@ -403,6 +403,11 @@ export function generateCreateLogoReveal(params: { duration?: number; backgroundColor?: { r: number; g: number; b: number }; }): string { + // Without this, neither branch below declares logoItem and the script dies + // with a bare "ReferenceError: logoItem is undefined". + if (params.logoItemId === undefined && !params.logoItemName) { + throw new Error('create_logo_reveal animates an existing logo item: pass logoItemId or logoItemName'); + } let script = ''; script += generateProjectCheck(); script += generateCompAccess(params.compId, params.compName); @@ -477,13 +482,113 @@ export function generateCreateLogoReveal(params: { script += 'logoLayer.property("Opacity").setValueAtTime(0.2, 100);\n'; script += 'logoLayer.property("Opacity").setValueAtTime(0.25, 0);\n'; script += 'logoLayer.property("Opacity").setValueAtTime(0.3, 100);\n'; + } else if (style === 'particle') { + // "particle" used to be accepted with no implementation, so the logo got no + // animation. ExtendScript has no particle API, but the bundled particle + // effects are ordinary effects: add them by match name and set their + // parameters. Two parts: + // 1. CC Scatterize on the logo, Scatter animating to 0, so the logo + // assembles out of particles. + // 2. A CC Particle Systems II burst on its own solid at the moment the + // logo lands. + // Effect parameters are addressed by match name ("CC Scatterize-0001"): + // effect property lists are flat and reuse display names. + const land = Math.round(duration * 0.5 * 100) / 100; + + script += 'var logoFx = logoLayer.property("ADBE Effect Parade");\n'; + script += 'if (!logoFx.canAddProperty("CC Scatterize")) {\n'; + script += ' throw new Error("CC Scatterize is not available in this After Effects install");\n'; + script += '}\n'; + // Effects are clipped to the layer's own bounds, so Scatterize on a tight + // logo just fills that rectangle with noise. Give it room first: + // - precomps (and solids / vectors) can use Collapse Transformations, + // which renders their effects in this comp's space; + // - raster footage such as a PNG can't collapse, so Grow Bounds enlarges + // the layer's buffer instead. It must sit BEFORE Scatterize in the stack. + // canSetCollapseTransformation is the reliable test - layer type isn't. + script += 'var logoExpanded = "none";\n'; + script += 'if (logoLayer.canSetCollapseTransformation) {\n'; + script += ' logoLayer.collapseTransformation = true;\n'; + script += ' logoExpanded = "collapseTransformation";\n'; + script += '} else if (logoFx.canAddProperty("ADBE GROW BOUNDS")) {\n'; + script += ' var grow = logoFx.addProperty("ADBE GROW BOUNDS");\n'; + script += ' grow.property("ADBE GROW BOUNDS-0001").setValue(Math.round(Math.max(comp.width, comp.height) / 3));\n'; + script += ' logoExpanded = "growBounds";\n'; + script += '}\n'; + script += 'var scat = logoFx.addProperty("CC Scatterize");\n'; + script += 'var scatAmount = scat.property("CC Scatterize-0001");\n'; + script += 'var scatTwist = scat.property("CC Scatterize-0002");\n'; + // With room to spread, a bigger scatter reads as a particle cloud. Without + // it, keep the value modest so the clipped rectangle stays faint. + script += 'scatAmount.setValueAtTime(0, logoExpanded === "none" ? 150 : 450);\n'; + script += 'scatAmount.setValueAtTime(' + land + ', 0);\n'; + script += 'scatTwist.setValueAtTime(0, 180);\n'; + script += 'scatTwist.setValueAtTime(' + land + ', 0);\n'; + // Decelerate into the assembled logo. One-element ease: these are 1D. + script += 'var landEase = new KeyframeEase(0, 80);\n'; + script += 'scatAmount.setTemporalEaseAtKey(2, [landEase], [landEase]);\n'; + script += 'scatTwist.setTemporalEaseAtKey(2, [landEase], [landEase]);\n'; + // Fade in across most of the assembly, so the scattered stage stays faint + // and the layer's bounding box never reads as a solid rectangle. + script += 'logoLayer.property("Opacity").setValueAtTime(0, 0);\n'; + script += 'logoLayer.property("Opacity").setValueAtTime(' + Math.round(land * 0.85 * 100) / 100 + ', 100);\n'; + + script += 'var particleFxOk = true;\n'; + script += 'var burst = null;\n'; + script += 'var probeFx = logoFx.canAddProperty("CC Particle Systems II");\n'; + script += 'if (probeFx) {\n'; + script += ' burst = comp.layers.addSolid([1, 1, 1], "Logo Particles", comp.width, comp.height, 1);\n'; + script += ' burst.outPoint = ' + duration + ';\n'; + script += ' burst.blendingMode = BlendingMode.ADD;\n'; + script += ' var ps = burst.property("ADBE Effect Parade").addProperty("CC Particle Systems II");\n'; + script += ' var logoPos = logoLayer.property("ADBE Transform Group").property("ADBE Position").value;\n'; + script += ' ps.property("CC Particle Systems II-0004").setValue([logoPos[0], logoPos[1]]);\n'; + // Spawn across the logo's footprint. Radius X/Y are NOT pixels: measured + // on a 1920-wide layer, one unit is roughly layer width / 200 (~9.6 px), + // so passing the logo's pixel size filled the whole frame. + script += ' var pxPerRadiusUnit = burst.width / 200;\n'; + script += ' ps.property("CC Particle Systems II-0005").setValue(Math.min(1000, (logoLayer.width / 2) / pxPerRadiusUnit * 0.9));\n'; + script += ' ps.property("CC Particle Systems II-0006").setValue(Math.min(1000, (logoLayer.height / 2) / pxPerRadiusUnit * 0.9));\n'; + script += ' ps.property("CC Particle Systems II-0002").setValue(1);\n'; // longevity + // Velocity scales with the layer's size, so keep it low on a comp-sized solid. + script += ' ps.property("CC Particle Systems II-0010").setValue(0.1);\n'; // velocity + script += ' ps.property("CC Particle Systems II-0012").setValue(0.05);\n'; // gravity + script += ' ps.property("CC Particle Systems II-0013").setValue(10);\n'; // resistance + script += ' ps.property("CC Particle Systems II-0019").setValue(0.12);\n'; // birth size + script += ' ps.property("CC Particle Systems II-0020").setValue(0);\n'; // death size + script += ' ps.property("CC Particle Systems II-0023").setValue(1);\n'; // max opacity + // Particle Type popup: 5 = Faded Sphere. The default, 1, draws lines. The + // indices include menu separators (3, 8 and 16 render almost nothing), so + // this was picked from a rendered contact sheet, not the menu order. + script += ' ps.property("CC Particle Systems II-0018").setValue(5);\n'; + script += ' ps.property("CC Particle Systems II-0025").setValue([1, 1, 1, 1]);\n'; // birth colour + script += ' ps.property("CC Particle Systems II-0026").setValue([1, 1, 1, 1]);\n'; // death colour + // Birth rate: off, a short burst as the logo lands, off again. HOLD keys + // so it switches rather than ramping. + script += ' var rate = ps.property("CC Particle Systems II-0001");\n'; + script += ' rate.setValueAtTime(0, 0);\n'; + script += ' rate.setValueAtTime(' + land + ', 6);\n'; + script += ' rate.setValueAtTime(' + Math.round((land + 0.2) * 100) / 100 + ', 0);\n'; + script += ' for (var rk = 1; rk <= rate.numKeys; rk++) {\n'; + script += ' rate.setInterpolationTypeAtKey(rk, KeyframeInterpolationType.HOLD, KeyframeInterpolationType.HOLD);\n'; + script += ' }\n'; + script += '} else {\n'; + // The reveal still works without the burst. + script += ' particleFxOk = false;\n'; + script += '}\n'; } - script += generateResultObject({ + const resultProps: Record = { logoLayerIndex: 'logoLayer.index', style: '"' + escapeString(style) + '"', duration: String(duration) - }); + }; + if (style === 'particle') { + resultProps.particleLayer = 'burst ? burst.name : null'; + resultProps.particleBurstAdded = 'particleFxOk'; + resultProps.logoBoundsExpandedBy = 'logoExpanded'; + } + script += generateResultObject(resultProps); return wrapInUndoGroup(script, 'Create Logo Reveal'); } diff --git a/src/ae-integration/generators/threeDGenerators.ts b/src/ae-integration/generators/threeDGenerators.ts new file mode 100644 index 0000000..6e09153 --- /dev/null +++ b/src/ae-integration/generators/threeDGenerators.ts @@ -0,0 +1,638 @@ +/** + * 3D Generators + * + * Camera, light, material, geometry and 3D transform control. + * + * THE CENTRAL PROBLEM this module works around: on a 3D layer, + * layer.property(matchName) returns a live object even when the property + * cannot actually be set. Setting it then throws + * + * "Can not 'set value' with this property, because the property or a + * parent property is hidden." + * + * and .active / .elided / .enabled all report the SAME values in the working + * and failing cases, so there is no flag to test first. Extrusion on a Classic + * 3D comp is the common case. Every setter here therefore applies properties + * individually and reports which landed and which did not, with a renderer hint + * when the failures look renderer-related, rather than aborting on the first one + * or silently doing nothing. + * + * All match names verified empirically against After Effects 26.5. + */ + +import { + escapeString, + colorToES3, + arrayToES3, + generateCompAccess, + generateLayerAccess, + generateProjectCheck, + generateResultObject, + wrapInUndoGroup +} from './helpers.js'; + +/** + * comp.renderers values, with the capabilities each actually permits. + * + * NOTE: "ADBE Advanced 3d" is NOT the Advanced 3D renderer - it is the legacy + * Classic renderer and the default for new comps. Verified by behaviour: + * extrusion, environment layers, shadow colour and reflections are all refused + * under it. + */ +export const RENDERERS: Record = { + classic: 'ADBE Advanced 3d', + advanced: 'ADBE Calder', + cinema4d: 'ADBE Ernst' +}; + +/** Renderers under which extrusion and bevel can actually be set. */ +export const EXTRUSION_RENDERERS = ['ADBE Calder', 'ADBE Ernst']; + +export const TRANSFORM_3D_PROPERTIES: Record = { + anchorPoint: 'ADBE Anchor Point', + position: 'ADBE Position', + scale: 'ADBE Scale', + orientation: 'ADBE Orientation', + rotationX: 'ADBE Rotate X', + rotationY: 'ADBE Rotate Y', + rotationZ: 'ADBE Rotate Z', + opacity: 'ADBE Opacity' +}; + +export const MATERIAL_PROPERTIES: Record = { + castsShadows: 'ADBE Casts Shadows', + lightTransmission: 'ADBE Light Transmission', + acceptsShadows: 'ADBE Accepts Shadows', + acceptsLights: 'ADBE Accepts Lights', + shadowColor: 'ADBE Shadow Color', + appearsInReflections: 'ADBE Appears in Reflections', + ambient: 'ADBE Ambient Coefficient', + diffuse: 'ADBE Diffuse Coefficient', + specularIntensity: 'ADBE Specular Coefficient', + specularShininess: 'ADBE Shininess Coefficient', + metal: 'ADBE Metal Coefficient', + reflectionIntensity: 'ADBE Reflection Coefficient', + reflectionSharpness: 'ADBE Glossiness Coefficient', + reflectionRolloff: 'ADBE Fresnel Coefficient', + transparency: 'ADBE Transparency Coefficient', + transparencyRolloff: 'ADBE Transp Rolloff', + indexOfRefraction: 'ADBE Index of Refraction' +}; + +export const CAMERA_PROPERTIES: Record = { + zoom: 'ADBE Camera Zoom', + depthOfField: 'ADBE Camera Depth of Field', + focusDistance: 'ADBE Camera Focus Distance', + aperture: 'ADBE Camera Aperture', + blurLevel: 'ADBE Camera Blur Level', + focusAreaWidth: 'ADBE Camera Focus Area Width', + nearFarBlurLevel: 'ADBE Camera Split Blur Level', + irisShape: 'ADBE Iris Shape', + irisRotation: 'ADBE Iris Rotation', + irisRoundness: 'ADBE Iris Roundness', + irisAspectRatio: 'ADBE Iris Aspect Ratio', + irisDiffractionFringe: 'ADBE Iris Diffraction Fringe', + highlightGain: 'ADBE Iris Highlight Gain', + highlightThreshold: 'ADBE Iris Highlight Threshold', + // Adobe's own typo - "Hightlight". Must be spelled wrong to work. + highlightSaturation: 'ADBE Iris Hightlight Saturation' +}; + +export const LIGHT_PROPERTIES: Record = { + intensity: 'ADBE Light Intensity', + color: 'ADBE Light Color', + coneAngle: 'ADBE Light Cone Angle', + // The un-suffixed "ADBE Light Cone Feather" is the deprecated one. + coneFeather: 'ADBE Light Cone Feather 2', + falloff: 'ADBE Light Falloff Type', + radius: 'ADBE Light Falloff Start', + falloffDistance: 'ADBE Light Falloff Distance', + castsShadows: 'ADBE Casts Shadows', + shadowDarkness: 'ADBE Light Shadow Darkness', + shadowDiffusion: 'ADBE Light Shadow Diffusion', + backgroundVisible: 'ADBE Light Backgd Visible', + backgroundOpacity: 'ADBE Light Backgd Opacity', + backgroundBlur: 'ADBE Light Backgd Blur' +}; + +/** + * "Geometry Options" is TWO groups sharing one display name. + */ +export const EXTRUSION_PROPERTIES: Record = { + bevelStyle: 'ADBE Bevel Styles', + bevelDirection: 'ADBE Bevel Direction', + bevelDepth: 'ADBE Bevel Depth', + holeBevelDepth: 'ADBE Hole Bevel Depth', + extrusionDepth: 'ADBE Extrsn Depth' +}; + +export const PLANE_PROPERTIES: Record = { + curvature: 'ADBE Plane Curvature', + segments: 'ADBE Plane Subdivision' +}; + +const GROUP_MATCH_NAMES = { + transform: 'ADBE Transform Group', + material: 'ADBE Material Options Group', + camera: 'ADBE Camera Options Group', + light: 'ADBE Light Options Group', + extrusion: 'ADBE Extrsn Options Group', + plane: 'ADBE Plane Options Group' +}; + +/** + * Emit the shared ES3 helper that applies one property and records the outcome + * instead of throwing. Defines applied/failed arrays and trySet(). + */ +function emitTrySetHelper(): string { + let s = ''; + s += 'var applied = [];\n'; + s += 'var failed = [];\n'; + s += 'function trySet(group, mn, label, val) {\n'; + s += ' if (!group) {\n'; + s += ' failed.push({ property: label, matchName: mn, reason: "property group not present on this layer" });\n'; + s += ' return;\n'; + s += ' }\n'; + s += ' var p = group.property(mn);\n'; + s += ' if (!p) {\n'; + s += ' failed.push({ property: label, matchName: mn, reason: "property not present on this layer" });\n'; + s += ' return;\n'; + s += ' }\n'; + s += ' try {\n'; + s += ' p.setValue(val);\n'; + s += ' applied.push(label);\n'; + s += ' } catch (e) {\n'; + s += ' var msg = e.toString();\n'; + s += ' msg = msg.replace("Error: After Effects error: ", "");\n'; + s += ' failed.push({ property: label, matchName: mn, reason: msg });\n'; + s += ' }\n'; + s += '}\n'; + return s; +} + +/** + * Which renderers actually permit a given property to be set. + * + * Measured directly on AE 26.5 - this is NOT a hierarchy. Each renderer + * exposes a different subset: the classic camera model (blur level, iris, + * highlights) exists only under "classic", focus area width and shadow colour + * only under "advanced", and the reflection properties only under "cinema4d", + * which hides the camera options entirely. + * + * Properties absent from this table are not known to be renderer-gated and get + * the generic hint instead of a specific one. + */ +const PROPERTY_RENDERER_SUPPORT: Record = { + 'ADBE Camera Blur Level': ['classic'], + 'ADBE Iris Shape': ['classic'], + 'ADBE Iris Rotation': ['classic'], + 'ADBE Iris Roundness': ['classic'], + 'ADBE Iris Aspect Ratio': ['classic'], + 'ADBE Iris Diffraction Fringe': ['classic'], + 'ADBE Iris Highlight Gain': ['classic'], + 'ADBE Iris Highlight Threshold': ['classic'], + 'ADBE Iris Hightlight Saturation': ['classic'], + 'ADBE Camera Depth of Field': ['classic', 'advanced'], + 'ADBE Camera Focus Distance': ['classic', 'advanced'], + 'ADBE Camera Aperture': ['classic', 'advanced'], + 'ADBE Camera Focus Area Width': ['advanced'], + 'ADBE Shadow Color': ['advanced'], + 'ADBE Appears in Reflections': ['cinema4d'], + 'ADBE Reflection Coefficient': ['cinema4d'], + 'ADBE Extrsn Depth': ['advanced', 'cinema4d'], + 'ADBE Bevel Styles': ['advanced', 'cinema4d'], + 'ADBE Bevel Direction': ['advanced', 'cinema4d'], + 'ADBE Bevel Depth': ['advanced', 'cinema4d'], + 'ADBE Hole Bevel Depth': ['advanced', 'cinema4d'] +}; + +/** Emit the support table as an ES3 object literal. */ +function emitSupportTable(): string { + let s = 'var rendererSupport = {};\n'; + for (const mn in PROPERTY_RENDERER_SUPPORT) { + if (!Object.prototype.hasOwnProperty.call(PROPERTY_RENDERER_SUPPORT, mn)) continue; + const list = PROPERTY_RENDERER_SUPPORT[mn].map(r => '"' + r + '"').join(', '); + s += 'rendererSupport["' + mn + '"] = [' + list + '];\n'; + } + return s; +} + +/** + * Annotate each hidden failure with the renderers that DO expose it, and emit a + * short summary hint. + * + * The earlier version told callers to switch to "advanced" even when they were + * already on it, which was worse than saying nothing. + */ +function emitRendererHint(): string { + let s = ''; + s += emitSupportTable(); + s += 'var rendererKey = "unknown";\n'; + s += 'if (comp.renderer === "ADBE Advanced 3d") { rendererKey = "classic"; }\n'; + s += 'else if (comp.renderer === "ADBE Calder") { rendererKey = "advanced"; }\n'; + s += 'else if (comp.renderer === "ADBE Ernst") { rendererKey = "cinema4d"; }\n'; + s += 'var hint = null;\n'; + s += 'var hiddenCount = 0;\n'; + s += 'for (var fi = 0; fi < failed.length; fi++) {\n'; + s += ' if (failed[fi].reason.indexOf("hidden") === -1) { continue; }\n'; + s += ' hiddenCount++;\n'; + s += ' var support = rendererSupport[failed[fi].matchName];\n'; + s += ' if (support) {\n'; + s += ' failed[fi].availableUnderRenderers = support;\n'; + s += ' }\n'; + s += '}\n'; + s += 'if (hiddenCount > 0) {\n'; + s += ' hint = hiddenCount + " property(s) refused as hidden. This comp uses the \\"" + rendererKey + "\\" renderer (" + comp.renderer + "). '; + s += 'Each renderer exposes a different subset, so check availableUnderRenderers on each failure and switch with set_comp_renderer if needed. '; + s += 'A property with no availableUnderRenderers is not renderer-gated - it likely needs a prerequisite, such as Depth of Field being enabled, or a layer type that supports it (extrusion is text and shape layers only).";\n'; + s += '}\n'; + return s; +} + +/** Convert a JS value into an ES3 literal suitable for setValue. */ +function valueToES3(value: unknown): string { + if (typeof value === 'boolean') { + // AE checkbox properties take 1/0. + return value ? '1' : '0'; + } + if (typeof value === 'number') { + return String(value); + } + if (Array.isArray(value)) { + return arrayToES3(value as unknown[]); + } + if (value && typeof value === 'object') { + const c = value as { r?: number; g?: number; b?: number }; + if (typeof c.r === 'number' && typeof c.g === 'number' && typeof c.b === 'number') { + return colorToES3(c as { r: number; g: number; b: number }); + } + } + throw new Error('Unsupported value type: ' + JSON.stringify(value)); +} + +/** + * Emit trySet() calls for every provided key in a property map. + * Returns the emitted script plus how many properties were requested. + */ +function emitPropertyApplications( + groupVar: string, + map: Record, + params: Record, + label: string +): { script: string; count: number } { + let s = ''; + let count = 0; + for (const key in params) { + if (!Object.prototype.hasOwnProperty.call(params, key)) continue; + if (!Object.prototype.hasOwnProperty.call(map, key)) continue; + const value = params[key]; + if (value === undefined) continue; + s += 'trySet(' + groupVar + ', "' + map[key] + '", "' + escapeString(key) + '", ' + valueToES3(value) + ');\n'; + count++; + } + if (count === 0) { + throw new Error( + 'No ' + label + ' properties supplied. Valid keys: ' + Object.keys(map).join(', ') + ); + } + return { script: s, count }; +} + +/** Reject unknown keys early with a helpful list. */ +function rejectUnknownKeys( + params: Record, + map: Record, + reserved: string[], + label: string +): void { + for (const key in params) { + if (!Object.prototype.hasOwnProperty.call(params, key)) continue; + if (params[key] === undefined) continue; + if (reserved.indexOf(key) !== -1) continue; + if (!Object.prototype.hasOwnProperty.call(map, key)) { + throw new Error( + 'Unknown ' + label + ' property "' + key + '". Valid: ' + Object.keys(map).join(', ') + ); + } + } +} + +const LAYER_REF_KEYS = ['compId', 'compName', 'layerIndex', 'layerName']; + +/** + * Set the composition's 3D renderer. + * + * This is the unlock for most of the rest of this module - extrusion, bevel, + * environment layers and reflections are all inert under the Classic renderer. + */ +export function generateSetCompRenderer(params: { + compId?: number; + compName?: string; + renderer: string; +}): string { + let target: string; + if (Object.prototype.hasOwnProperty.call(RENDERERS, params.renderer)) { + target = RENDERERS[params.renderer]; + } else if (params.renderer && params.renderer.indexOf('ADBE ') === 0) { + target = params.renderer; + } else { + throw new Error( + 'renderer must be one of: ' + Object.keys(RENDERERS).join(', ') + + ' (or a raw "ADBE ..." string)' + ); + } + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + + script += 'var available = comp.renderers;\n'; + script += 'var found = false;\n'; + script += 'for (var i = 0; i < available.length; i++) {\n'; + script += ' if (available[i] === "' + escapeString(target) + '") { found = true; break; }\n'; + script += '}\n'; + script += 'if (!found) {\n'; + script += ' throw new Error("Renderer \\"' + escapeString(target) + '\\" is not available in this After Effects install. Available: " + available.join(", "));\n'; + script += '}\n'; + script += 'var previous = comp.renderer;\n'; + script += 'comp.renderer = "' + escapeString(target) + '";\n'; + + script += generateResultObject({ + compName: 'comp.name', + previousRenderer: 'previous', + renderer: 'comp.renderer', + availableRenderers: 'available', + supportsExtrusion: 'comp.renderer !== "ADBE Advanced 3d"' + }); + + return wrapInUndoGroup(script, 'Set Comp Renderer'); +} + +/** + * Enable 3D on a layer and set its 3D transform properties. + * + * Note: the separated X/Y/Z Position properties (ADBE Position_0/1/2) are + * read-only unless Separate Dimensions is enabled, so position is set through + * the combined ADBE Position property. + */ +export function generateSet3DLayer(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; + enable3D?: boolean; + anchorPoint?: number[]; + position?: number[]; + scale?: number[]; + orientation?: number[]; + rotationX?: number; + rotationY?: number; + rotationZ?: number; + opacity?: number; +}): string { + rejectUnknownKeys( + params as Record, + TRANSFORM_3D_PROPERTIES, + LAYER_REF_KEYS.concat(['enable3D']), + '3D transform' + ); + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + script += emitTrySetHelper(); + + if (params.enable3D !== undefined) { + script += 'if (typeof layer.threeDLayer === "undefined") {\n'; + script += ' throw new Error("Layer does not support 3D: " + layer.name);\n'; + script += '}\n'; + script += 'layer.threeDLayer = ' + (params.enable3D ? 'true' : 'false') + ';\n'; + } + + script += 'var xform = layer.property("' + GROUP_MATCH_NAMES.transform + '");\n'; + + // Only emit property applications when at least one was supplied; toggling + // 3D alone is a valid call. + const transformKeys = Object.keys(TRANSFORM_3D_PROPERTIES).filter( + k => (params as Record)[k] !== undefined + ); + if (transformKeys.length > 0) { + script += emitPropertyApplications( + 'xform', + TRANSFORM_3D_PROPERTIES, + params as Record, + '3D transform' + ).script; + } + + script += emitRendererHint(); + + script += generateResultObject({ + layerName: 'layer.name', + is3D: 'layer.threeDLayer', + applied: 'applied', + failed: 'failed', + hint: 'hint' + }); + + return wrapInUndoGroup(script, 'Set 3D Layer'); +} + +/** + * Set Material Options on a 3D layer. + */ +export function generateSetMaterialOptions(params: Record): string { + rejectUnknownKeys(params, MATERIAL_PROPERTIES, LAYER_REF_KEYS, 'material'); + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId as number, params.compName as string); + script += generateLayerAccess('comp', params.layerIndex as number, params.layerName as string); + script += emitTrySetHelper(); + + script += 'var matGroup = layer.property("' + GROUP_MATCH_NAMES.material + '");\n'; + script += 'if (!matGroup) {\n'; + script += ' throw new Error("Layer has no Material Options. Enable 3D on it first (set_3d_layer with enable3D: true).");\n'; + script += '}\n'; + script += emitPropertyApplications('matGroup', MATERIAL_PROPERTIES, params, 'material').script; + script += emitRendererHint(); + + script += generateResultObject({ + layerName: 'layer.name', + applied: 'applied', + failed: 'failed', + hint: 'hint' + }); + + return wrapInUndoGroup(script, 'Set Material Options'); +} + +/** + * Set Geometry Options - extrusion/bevel, or plane curvature/segments. + * + * Extrusion is gated on the comp renderer before anything is attempted, because + * the failure mode otherwise is an opaque "property is hidden" error. + */ +export function generateSetGeometryOptions(params: Record): string { + const combined: Record = { ...EXTRUSION_PROPERTIES, ...PLANE_PROPERTIES }; + rejectUnknownKeys(params, combined, LAYER_REF_KEYS, 'geometry'); + + const wantsExtrusion = Object.keys(EXTRUSION_PROPERTIES).some(k => params[k] !== undefined); + const wantsPlane = Object.keys(PLANE_PROPERTIES).some(k => params[k] !== undefined); + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId as number, params.compName as string); + script += generateLayerAccess('comp', params.layerIndex as number, params.layerName as string); + script += emitTrySetHelper(); + + if (wantsExtrusion) { + // Fail fast with an actionable message rather than letting AE say "hidden". + script += 'var rend = comp.renderer;\n'; + script += 'var extrusionOK = false;\n'; + for (let i = 0; i < EXTRUSION_RENDERERS.length; i++) { + script += 'if (rend === "' + EXTRUSION_RENDERERS[i] + '") { extrusionOK = true; }\n'; + } + script += 'if (!extrusionOK) {\n'; + script += ' throw new Error("Extrusion and bevel are unavailable under renderer \\"" + rend + "\\" (the Classic renderer). Call set_comp_renderer with \\"advanced\\" or \\"cinema4d\\" first.");\n'; + script += '}\n'; + script += 'if (!layer.threeDLayer) {\n'; + script += ' throw new Error("Layer is not 3D. Call set_3d_layer with enable3D: true first.");\n'; + script += '}\n'; + script += 'var extrGroup = layer.property("' + GROUP_MATCH_NAMES.extrusion + '");\n'; + script += emitPropertyApplications('extrGroup', EXTRUSION_PROPERTIES, params, 'geometry').script; + } + + if (wantsPlane) { + script += 'var planeGroup = layer.property("' + GROUP_MATCH_NAMES.plane + '");\n'; + script += emitPropertyApplications('planeGroup', PLANE_PROPERTIES, params, 'geometry').script; + } + + script += emitRendererHint(); + + script += generateResultObject({ + layerName: 'layer.name', + renderer: 'comp.renderer', + applied: 'applied', + failed: 'failed', + hint: 'hint' + }); + + return wrapInUndoGroup(script, 'Set Geometry Options'); +} + +/** + * Set Camera Options on a camera layer. + */ +export function generateSetCameraOptions(params: Record): string { + rejectUnknownKeys(params, CAMERA_PROPERTIES, LAYER_REF_KEYS, 'camera'); + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId as number, params.compName as string); + script += generateLayerAccess('comp', params.layerIndex as number, params.layerName as string); + script += emitTrySetHelper(); + + script += 'var camGroup = layer.property("' + GROUP_MATCH_NAMES.camera + '");\n'; + script += 'if (!camGroup) {\n'; + script += ' throw new Error("Layer is not a camera: " + layer.name);\n'; + script += '}\n'; + script += emitPropertyApplications('camGroup', CAMERA_PROPERTIES, params, 'camera').script; + script += emitRendererHint(); + + script += generateResultObject({ + layerName: 'layer.name', + applied: 'applied', + failed: 'failed', + hint: 'hint' + }); + + return wrapInUndoGroup(script, 'Set Camera Options'); +} + +/** + * Set Light Options on a light layer. + */ +export function generateSetLightOptions(params: Record): string { + rejectUnknownKeys(params, LIGHT_PROPERTIES, LAYER_REF_KEYS, 'light'); + + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId as number, params.compName as string); + script += generateLayerAccess('comp', params.layerIndex as number, params.layerName as string); + script += emitTrySetHelper(); + + script += 'var lightGroup = layer.property("' + GROUP_MATCH_NAMES.light + '");\n'; + script += 'if (!lightGroup) {\n'; + script += ' throw new Error("Layer is not a light: " + layer.name);\n'; + script += '}\n'; + script += emitPropertyApplications('lightGroup', LIGHT_PROPERTIES, params, 'light').script; + script += emitRendererHint(); + + script += generateResultObject({ + layerName: 'layer.name', + applied: 'applied', + failed: 'failed', + hint: 'hint' + }); + + return wrapInUndoGroup(script, 'Set Light Options'); +} + +/** + * Read the 3D state of a composition and optionally one layer, so callers can + * see what is actually settable before trying. + */ +export function generateGet3DInfo(params: { + compId?: number; + compName?: string; + layerIndex?: number; + layerName?: string; +}): string { + let script = ''; + script += generateProjectCheck(); + script += generateCompAccess(params.compId, params.compName); + + const hasLayer = params.layerIndex !== undefined || params.layerName !== undefined; + if (hasLayer) { + script += generateLayerAccess('comp', params.layerIndex, params.layerName); + } + + script += 'function readGroup(grp) {\n'; + script += ' if (!grp) { return null; }\n'; + script += ' var vals = {};\n'; + script += ' for (var i = 1; i <= grp.numProperties; i++) {\n'; + script += ' var p = grp.property(i);\n'; + script += ' try { vals[p.matchName] = p.value; } catch (e) { vals[p.matchName] = null; }\n'; + script += ' }\n'; + script += ' return vals;\n'; + script += '}\n'; + + const resultProps: Record = { + compName: 'comp.name', + renderer: 'comp.renderer', + availableRenderers: 'comp.renderers', + supportsExtrusion: 'comp.renderer !== "ADBE Advanced 3d"' + }; + + if (hasLayer) { + script += 'var is3D = layer.threeDLayer ? true : false;\n'; + script += 'var matGroup = layer.property("' + GROUP_MATCH_NAMES.material + '");\n'; + script += 'var camGroup = layer.property("' + GROUP_MATCH_NAMES.camera + '");\n'; + script += 'var lightGroup = layer.property("' + GROUP_MATCH_NAMES.light + '");\n'; + script += 'var extrGroup = layer.property("' + GROUP_MATCH_NAMES.extrusion + '");\n'; + script += 'var planeGroup = layer.property("' + GROUP_MATCH_NAMES.plane + '");\n'; + resultProps.layerName = 'layer.name'; + resultProps.is3D = 'is3D'; + resultProps.materialOptions = 'readGroup(matGroup)'; + resultProps.cameraOptions = 'readGroup(camGroup)'; + resultProps.lightOptions = 'readGroup(lightGroup)'; + resultProps.geometryExtrusion = 'readGroup(extrGroup)'; + resultProps.geometryPlane = 'readGroup(planeGroup)'; + } + + script += generateResultObject(resultProps); + + return script; +} diff --git a/src/ae-integration/scriptGenerator.ts b/src/ae-integration/scriptGenerator.ts index 4795c2c..e1e3e17 100644 --- a/src/ae-integration/scriptGenerator.ts +++ b/src/ae-integration/scriptGenerator.ts @@ -119,6 +119,59 @@ export { generateNavigateMarkers } from './generators/markerGenerators.js'; +// Shape generators (bezier paths + shape operators) +export { + generateCreatePath, + generateGetPath, + generateSetPathKeyframes, + generateAddShapeOperator, + SHAPE_OPERATORS, + SHAPE_OPERATOR_PROPERTIES, + SHAPE_MATCH_NAMES +} from './generators/shapeGenerators.js'; + +// 3D generators +export { + generateSetCompRenderer, + generateSet3DLayer, + generateSetMaterialOptions, + generateSetGeometryOptions, + generateSetCameraOptions, + generateSetLightOptions, + generateGet3DInfo, + RENDERERS, + EXTRUSION_RENDERERS, + MATERIAL_PROPERTIES, + CAMERA_PROPERTIES, + LIGHT_PROPERTIES, + EXTRUSION_PROPERTIES, + PLANE_PROPERTIES, + TRANSFORM_3D_PROPERTIES +} from './generators/threeDGenerators.js'; + +// Mask generators +export { + generateAddMask, + generateListMasks, + generateGetMaskPath, + generateSetMaskPath, + generateSetMaskKeyframes, + generateSetMaskProperties, + generateDeleteMask, + MASK_MATCH_NAMES +} from './generators/maskGenerators.js'; + +// Render queue generators +export { + generateAddToRenderQueue, + generateListRenderQueue, + generateListRenderTemplates, + generateSetRenderQueueItem, + generateRemoveFromRenderQueue, + generateControlRender, + generateQueueInAME +} from './generators/renderQueueGenerators.js'; + // Helpers (for direct use if needed) export { escapeString, diff --git a/src/stdio-server.test.ts b/src/stdio-server.test.ts new file mode 100644 index 0000000..6ae4b68 --- /dev/null +++ b/src/stdio-server.test.ts @@ -0,0 +1,303 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { describe, it } from 'node:test'; +import vm from 'node:vm'; +import ts from 'typescript'; +import * as schemas from '@modelcontextprotocol/sdk/types.js'; +import * as validator from '@modelcontextprotocol/sdk/validation/ajv'; +import * as generators from './ae-integration/scriptGenerator.js'; +import * as errors from './ae-integration/errorHandler.js'; + +type Handler = (request: any) => Promise; +type Tool = { name: string; inputSchema: any; generator: (params: any) => string }; + +// Load the real registration/handler code with only the transport and AE IPC +// mocked. This exercises dispatch without starting AE or creating command files. +function createServer() { + const handlers = new Map(); + const scripts: string[] = []; + class Server { + setRequestHandler(schema: unknown, handler: Handler) { handlers.set(schema, handler); } + async connect() {} + } + class FileCommunicator { + async executeScript(script: string) { + new vm.Script(script); + scripts.push(script); + return { success: true, data: { accepted: true } }; + } + } + const modules: Record = { + '@modelcontextprotocol/sdk/server/index.js': { Server }, + '@modelcontextprotocol/sdk/server/stdio.js': { StdioServerTransport: class {} }, + '@modelcontextprotocol/sdk/types.js': schemas, + '@modelcontextprotocol/sdk/validation/ajv': validator, + './ae-integration/file-communicator.js': { FileCommunicator }, + './ae-integration/scriptGenerator.js': generators, + './ae-integration/errorHandler.js': errors + }; + const source = readFileSync(new URL('./stdio-server.ts', import.meta.url), 'utf8'); + const compiled = ts.transpileModule(source + '\nexport { TOOLS };', { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } + }).outputText; + const exports: { TOOLS?: Tool[] } = {}; + vm.runInNewContext(compiled, { + exports, + require(name: string) { + assert.ok(name in modules, 'Unexpected module dependency: ' + name); + return modules[name]; + }, + console: { error() {} }, + process: { exit(code: number) { throw new Error('Unexpected server exit: ' + code); } } + }); + return { + tools: exports.TOOLS!, + scripts, + list: () => handlers.get(schemas.ListToolsRequestSchema)!({}), + call: (name: string, args: unknown) => handlers.get(schemas.CallToolRequestSchema)!({ + params: { name, arguments: args } + }) + }; +} + +describe('MCP tool registration and dispatch', () => { + it('registers every generator exactly once with a matching name', async () => { + const server = createServer(); + const { tools } = await server.list(); + assert.equal(tools.length, 102); + assert.equal(new Set(tools.map((tool: Tool) => tool.name)).size, tools.length); + const normalized = (name: string) => name.replace(/_/g, '').toLowerCase(); + for (const [name, generate] of Object.entries(generators)) { + if (!name.startsWith('generate')) continue; + const matches = server.tools.filter(tool => tool.generator === generate); + assert.equal(matches.length, 1, name + ' must have exactly one registration'); + assert.equal(normalized('generate' + matches[0].name), normalized(name)); + } + for (const tool of server.tools) { + assert.equal(typeof tool.generator, 'function', tool.name); + for (const required of tool.inputSchema.required ?? []) { + assert.ok(required in tool.inputSchema.properties, tool.name + '.' + required); + } + } + }); + + const vertices = [[0, 0], [100, 0], [50, 100]]; + const cases: Record = { + create_path: { vertices }, + get_path: {}, + set_path_keyframes: { keyframes: [{ time: 0, vertices }] }, + add_shape_operator: { operator: 'trimPaths', properties: { end: 50 } }, + add_mask: { vertices }, + list_masks: {}, + get_mask_path: {}, + set_mask_path: { vertices }, + set_mask_keyframes: { keyframes: [{ time: 0, vertices }] }, + set_mask_properties: { opacity: 50, locked: true }, + delete_mask: {}, + add_to_render_queue: { outputPath: '/tmp/output.mov' }, + list_render_queue: {}, + list_render_templates: {}, + set_render_queue_item: { itemIndex: 1, render: false }, + remove_from_render_queue: { itemIndex: 1 }, + control_render: { action: 'showWindow' }, + queue_in_ame: { renderImmediately: false }, + set_comp_renderer: { renderer: 'classic' }, + get_3d_info: {}, + set_3d_layer: { position: [0, 0, 0] }, + set_material_options: { acceptsLights: true }, + set_geometry_options: { extrusionDepth: 10 }, + set_camera_options: { zoom: 1000 }, + set_light_options: { intensity: 100 }, + list_project_items: {}, + set_active_composition: { compName: 'Test' }, + set_proxy: { itemId: 1, proxyPath: '/tmp/proxy.png' }, + remove_proxy: { itemId: 1 }, + get_current_time: {}, + set_current_time: { time: 1 }, + snap_to_marker: { markerIndex: 1 }, + get_nearest_marker: { time: 1 }, + navigate_markers: { direction: 'next' }, + batch_set_expressions: { expressions: [{ property: 'ADBE Transform Group/ADBE Rotate Z', expression: 'time' }] } + }; + for (const [name, args] of Object.entries(cases)) { + it(`${name} dispatches to a syntactically valid script`, async () => { + const server = createServer(); + const params: Record = { compName: 'Test', layerIndex: 1, ...args }; + const tool = server.tools.find(tool => tool.name === name)!; + for (const required of tool.inputSchema.required ?? []) { + assert.ok(required in params, name + ' fixture missing ' + required); + } + const result = await server.call(name, params); + assert.equal(result.isError, false, JSON.stringify(result)); + assert.equal(server.scripts.length, 1); + assert.equal(JSON.parse(result.content[0].text).data.accepted, true); + }); + } + + const layerTargets = [ + 'get_path', 'set_path_keyframes', 'add_shape_operator', 'set_3d_layer', + 'set_material_options', 'set_geometry_options', 'set_camera_options', 'set_light_options', + 'add_mask', 'list_masks', 'get_mask_path', 'set_mask_path', 'set_mask_keyframes', + 'set_mask_properties', 'delete_mask', 'batch_set_expressions' + ]; + for (const name of layerTargets) { + it(`${name} advertises and validates either layer selector before IPC`, async () => { + const server = createServer(); + const schema = server.tools.find(tool => tool.name === name)!.inputSchema; + assert.deepEqual(JSON.parse(JSON.stringify(schema.anyOf)), [ + { required: ['layerIndex'] }, { required: ['layerName'] } + ]); + const validate = new validator.AjvJsonSchemaValidator().getValidator(schema); + for (const selector of [{ layerIndex: 1 }, { layerName: 'Target' }]) { + const args = { ...cases[name], ...selector }; + assert.equal(validate(args).valid, true); + assert.equal((await server.call(name, args)).isError, false); + } + server.scripts.length = 0; + for (const selector of [{}, { layerIndex: 0 }, { layerIndex: -1 }, { layerIndex: 1.5 }, + { layerIndex: '1' }, { layerName: '' }, { layerName: ' ' }]) { + const args = { ...cases[name], ...selector }; + assert.equal(validate(args).valid, false); + const result = await server.call(name, args); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /Invalid arguments for/); + } + assert.equal(server.scripts.length, 0); + }); + } + + for (const name of ['set_proxy', 'remove_proxy']) { + it(`${name} requires a valid project item selector before IPC`, async () => { + const server = createServer(); + const schema = server.tools.find(tool => tool.name === name)!.inputSchema; + assert.deepEqual(JSON.parse(JSON.stringify(schema.anyOf)), [ + { required: ['itemId'] }, { required: ['itemName'] } + ]); + for (const selector of [{ itemId: 1 }, { itemName: 'Footage' }]) { + assert.equal((await server.call(name, { proxyPath: '/tmp/proxy.png', ...selector })).isError, false); + } + server.scripts.length = 0; + for (const selector of [{}, { itemId: 0 }, { itemId: 1.5 }, { itemName: '' }, { itemName: ' ' }]) { + assert.equal((await server.call(name, { proxyPath: '/tmp/proxy.png', ...selector })).isError, true); + } + assert.equal(server.scripts.length, 0); + }); + } + + const colorCases = [ + ['create_path', 'fillColor'], ['create_path', 'strokeColor'], + ['add_mask', 'color'], ['set_mask_properties', 'color'], + ['set_material_options', 'shadowColor'], ['set_light_options', 'color'] + ]; + for (const [name, field] of colorCases) { + it(`${name}.${field} validates RGB and emits a numeric color array`, async () => { + const server = createServer(); + const base = { ...cases[name], layerIndex: 1 }; + assert.equal((await server.call(name, { ...base, [field]: { r: 0, g: 0.5, b: 1 } })).isError, false); + assert.match(server.scripts[0], /\[0, 0\.5, 1\]/); + server.scripts.length = 0; + for (const color of [{}, { r: 0, g: 1 }, { r: '0', g: 0, b: 0 }, + { r: -0.1, g: 0, b: 0 }, { r: 0, g: 0, b: 1.1 }, { r: NaN, g: 0, b: 0 }, { r: 0, g: 0, b: 0, a: 'bad' }]) { + assert.equal((await server.call(name, { ...base, [field]: color })).isError, true); + } + assert.equal(server.scripts.length, 0); + }); + } + + it('create_path validates XY/XYZ objects without requiring an existing layer', async () => { + const server = createServer(); + for (const [position, expected] of [ + [{ x: 10, y: 20 }, '[10, 20]'], [{ x: 10, y: 20, z: 30 }, '[10, 20, 30]'] + ] as const) { + assert.equal((await server.call('create_path', { vertices, position })).isError, false); + assert.ok(server.scripts[server.scripts.length - 1].includes(expected)); + } + server.scripts.length = 0; + for (const position of [{}, { x: 10 }, { y: 20 }, { x: '10', y: 20 }, { x: 10, y: 20, z: null }]) { + assert.equal((await server.call('create_path', { vertices, position })).isError, true); + } + assert.equal(server.scripts.length, 0); + }); + + for (const name of ['add_mask', 'set_mask_properties']) { + it(`${name} accepts scalar or exactly two numeric feather axes`, async () => { + const server = createServer(); + for (const [feather, expected] of [[10, '[10, 10]'], [[10, 20], '[10, 20]']] as const) { + assert.equal((await server.call(name, { layerIndex: 1, feather })).isError, false); + assert.ok(server.scripts[server.scripts.length - 1].includes(expected)); + } + server.scripts.length = 0; + for (const feather of [[], [10], [10, 20, 30], [10, '20'], '10']) { + assert.equal((await server.call(name, { layerIndex: 1, feather })).isError, true); + } + assert.equal(server.scripts.length, 0); + }); + } + + it('set_comp_renderer schema and dispatch accept aliases or raw ADBE names only', async () => { + const server = createServer(); + const schema = server.tools.find(tool => tool.name === 'set_comp_renderer')!.inputSchema; + const validate = new validator.AjvJsonSchemaValidator().getValidator(schema); + for (const renderer of ['classic', 'advanced', 'cinema4d', 'ADBE Advanced 3d', 'ADBE Future Renderer']) { + assert.equal(validate({ renderer }).valid, true); + assert.equal((await server.call('set_comp_renderer', { renderer })).isError, false); + if (renderer.startsWith('ADBE ')) { + assert.ok(server.scripts[server.scripts.length - 1].includes('comp.renderer = "' + renderer + '"')); + } + } + server.scripts.length = 0; + for (const renderer of ['other', 'ADBE ', '', 1]) { + assert.equal(validate({ renderer }).valid, false); + assert.equal((await server.call('set_comp_renderer', { renderer })).isError, true); + } + assert.equal(server.scripts.length, 0); + }); + + it('control_render advertises only synchronous start/showWindow and rejects interruption before IPC', async () => { + const server = createServer(); + const schema = server.tools.find(tool => tool.name === 'control_render')!.inputSchema; + assert.deepEqual(Array.from(schema.properties.action.enum), ['start', 'showWindow']); + assert.equal((await server.call('control_render', { action: 'start' })).isError, false); + assert.match(server.scripts[0], /rq\.render\(\);/); + server.scripts.length = 0; + for (const action of ['stop', 'pause', 'resume']) { + const result = await server.call('control_render', { action }); + assert.equal(result.isError, true); + assert.match(result.content[0].text, /CEP bridge cannot stop, pause or resume/); + assert.throws(() => generators.generateControlRender({ action: action as 'stop' }), /CEP bridge/); + } + assert.equal(server.scripts.length, 0); + }); + + it('optional layer tools and legacy coercion remain supported', async () => { + const server = createServer(); + for (const name of ['get_3d_info', 'snap_to_marker', 'get_nearest_marker', 'navigate_markers']) { + assert.equal((await server.call(name, cases[name])).isError, false); + } + // The original keyframe generator intentionally parses numeric strings. + assert.equal((await server.call('set_keyframe', { + layerIndex: 1, property: 'position', time: '1', value: '[10,20]' + })).isError, false); + }); + + it('batch_set_expressions resolves friendly aliases while preserving raw paths', async () => { + const server = createServer(); + assert.equal((await server.call('batch_set_expressions', { + layerIndex: 1, expressions: [ + { property: 'position', expression: 'value' }, + { property: 'ADBE Transform Group/ADBE Rotate Z', expression: 'time' } + ] + })).isError, false); + assert.match(server.scripts[0], /prop0\.property\("ADBE Transform Group"\)/); + assert.match(server.scripts[0], /prop0\.property\("ADBE Position"\)/); + assert.match(server.scripts[0], /prop1\.property\("ADBE Rotate Z"\)/); + }); + + it('rejects unknown tools and invalid generated inputs without contacting AE', async () => { + const server = createServer(); + assert.equal((await server.call('unknown_tool', {})).isError, true); + assert.equal((await server.call('add_shape_operator', { operator: 'invalid' })).isError, true); + assert.equal((await server.call('scale_keyframe_timing', { scale: 0 })).isError, true); + assert.equal(server.scripts.length, 0); + }); +}); diff --git a/src/stdio-server.ts b/src/stdio-server.ts index b02596b..8b27e12 100644 --- a/src/stdio-server.ts +++ b/src/stdio-server.ts @@ -7,6 +7,8 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { AjvJsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/ajv'; +import type { Tool } from '@modelcontextprotocol/sdk/types.js'; import { CallToolRequestSchema, ListToolsRequestSchema, @@ -29,8 +31,48 @@ const logger: Logger = { error: (msg, meta) => console.error(`[ERROR] ${msg}`, meta ? JSON.stringify(meta) : '') }; +// Shared schemas for the newly exposed integration tools only. Legacy tools +// retain their generator-level coercion of stringified numbers and arrays. +const POSITIVE_INDEX_SCHEMA = { type: 'integer', minimum: 1 }; +const NONEMPTY_NAME_SCHEMA = { type: 'string', minLength: 1, pattern: '\\S' }; +const RGB_SCHEMA = { + type: 'object', + properties: { + r: { type: 'number', minimum: 0, maximum: 1 }, + g: { type: 'number', minimum: 0, maximum: 1 }, + b: { type: 'number', minimum: 0, maximum: 1 }, + a: { type: 'number', minimum: 0, maximum: 1 } + }, + required: ['r', 'g', 'b'] +}; +const POSITION_SCHEMA = { + type: 'object', + properties: { x: { type: 'number' }, y: { type: 'number' }, z: { type: 'number' } }, + required: ['x', 'y'] +}; +const FEATHER_SCHEMA = { + anyOf: [ + { type: 'number' }, + { type: 'array', items: { type: 'number' }, minItems: 2, maxItems: 2 } + ], + description: 'Feather in pixels: scalar for both axes, or [x, y]' +}; +const LAYER_TARGETED_TOOLS = new Set([ + 'get_path', 'set_path_keyframes', 'add_shape_operator', 'set_3d_layer', + 'set_material_options', 'set_geometry_options', 'set_camera_options', 'set_light_options', + 'add_mask', 'list_masks', 'get_mask_path', 'set_mask_path', 'set_mask_keyframes', + 'set_mask_properties', 'delete_mask', 'batch_set_expressions' +]); +const INTEGRATION_TOOLS = new Set([ + ...LAYER_TARGETED_TOOLS, 'create_path', 'add_to_render_queue', 'list_render_queue', + 'list_render_templates', 'set_render_queue_item', 'remove_from_render_queue', + 'control_render', 'queue_in_ame', 'set_comp_renderer', 'get_3d_info', + 'list_project_items', 'set_active_composition', 'set_proxy', 'remove_proxy', + 'get_current_time', 'set_current_time', 'snap_to_marker', 'get_nearest_marker', 'navigate_markers' +]); + // Tool definitions -const TOOLS = [ +const TOOLS: Array string }> = [ // ============================================ // PROJECT TOOLS // ============================================ @@ -316,11 +358,670 @@ const TOOLS = [ strokeWidth: { type: 'number' }, points: { type: 'number', description: 'Number of points for polygon/star' }, innerRadius: { type: 'number', description: 'Inner radius for star' }, - outerRadius: { type: 'number', description: 'Outer radius for polygon/star' } + outerRadius: { type: 'number', description: 'Outer radius for polygon/star' }, + roundness: { type: 'number', description: 'Corner roundness (rectangle only)' } } }, generator: generators.generateAddShapeLayer }, + { + name: 'create_path', + description: 'Create an editable bezier path on a shape layer. Creates a new shape layer unless layerIndex/layerName is given. Tangents are RELATIVE to their own vertex, not absolute coordinates.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number', description: 'Existing shape layer to add the path to' }, + layerName: { type: 'string', description: 'Existing shape layer to add the path to' }, + name: { type: 'string', description: 'Name for the new layer (when creating one)' }, + pathName: { type: 'string', description: 'Name for the path group' }, + groupIndex: { type: 'number', description: 'Which shape group to add to (default: first, created if none)' }, + vertices: { + type: 'array', + description: 'Array of [x, y] points in layer space. At least 2 required.', + items: { type: 'array', items: { type: 'number' } } + }, + inTangents: { + type: 'array', + description: 'Optional [x, y] per vertex, RELATIVE to that vertex. Defaults to corner points.', + items: { type: 'array', items: { type: 'number' } } + }, + outTangents: { + type: 'array', + description: 'Optional [x, y] per vertex, RELATIVE to that vertex. Defaults to corner points.', + items: { type: 'array', items: { type: 'number' } } + }, + closed: { type: 'boolean', description: 'Close the path (default true)' }, + fillColor: { type: 'object', description: 'Fill color (0-1 range)' }, + strokeColor: { type: 'object', description: 'Stroke color (0-1 range)' }, + strokeWidth: { type: 'number' }, + position: { type: 'object', description: 'Layer position {x, y}' } + }, + required: ['vertices'] + }, + generator: generators.generateCreatePath + }, + { + name: 'get_path', + description: 'Read a bezier path back from a shape layer, including all keyframes if it is animated. Returns vertices, inTangents, outTangents and closed.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + pathName: { type: 'string', description: 'Which path to read; omit for the first path found' } + } + }, + generator: generators.generateGetPath + }, + { + name: 'set_path_keyframes', + description: 'Animate a bezier path by writing shape keyframes. Each keyframe needs its own full vertex list; vertex counts may differ between keys but AE interpolates best when they match.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + pathName: { type: 'string', description: 'Which path to animate; omit for the first path found' }, + keyframes: { + type: 'array', + description: 'Array of { time, vertices, inTangents?, outTangents?, closed? }', + items: { + type: 'object', + properties: { + time: { type: 'number', description: 'Time in seconds' }, + vertices: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, + inTangents: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, + outTangents: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, + closed: { type: 'boolean' } + }, + required: ['time', 'vertices'] + } + } + }, + required: ['keyframes'] + }, + generator: generators.generateSetPathKeyframes + }, + { + name: 'add_shape_operator', + description: 'Add a shape operator (Trim Paths, Repeater, Round Corners, Zig Zag, Twist, Pucker & Bloat, Offset Paths, Wiggle Paths, Wiggle Transform, Merge Paths, Gradient Fill/Stroke) to a shape layer. NOTE: gradient COLOR STOPS cannot be set by scripting - After Effects exposes ADBE Vector Grad Colors as NO_VALUE - so gradients will render with default colors.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + operator: { + type: 'string', + enum: [ + 'trimPaths', 'repeater', 'roundCorners', 'zigZag', 'twist', 'puckerBloat', + 'offsetPaths', 'wigglePaths', 'wiggleTransform', 'mergePaths', + 'gradientFill', 'gradientStroke' + ], + description: 'Which operator to add' + }, + groupIndex: { type: 'number', description: 'Which shape group to add to (default: first)' }, + name: { type: 'string', description: 'Rename the operator' }, + properties: { + type: 'object', + description: 'Operator properties by friendly name, e.g. { start: 0, end: 50 } for trimPaths or { copies: 6 } for repeater' + } + }, + required: ['operator'] + }, + generator: generators.generateAddShapeOperator + }, + { + name: 'set_comp_renderer', + description: 'Set a composition\'s 3D renderer. This is the unlock for extrusion, bevel, environment layers and reflections - all of which are inert under the Classic renderer. NOTE: the internal name "ADBE Advanced 3d" is the CLASSIC renderer, not Advanced 3D, and it is the default for new comps.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + renderer: { + type: 'string', + description: 'classic (no extrusion), advanced (extrusion + shadow colour), cinema4d (extrusion + reflections). A raw "ADBE ..." string is also accepted.', + anyOf: [{ enum: ['classic', 'advanced', 'cinema4d'] }, { pattern: '^ADBE .+' }] + } + }, + required: ['renderer'] + }, + generator: generators.generateSetCompRenderer + }, + { + name: 'get_3d_info', + description: 'Read a composition\'s renderer and available renderers, and optionally a layer\'s 3D state with current Material, Camera, Light and Geometry values. Use this before setting 3D properties to see what is actually available.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' } + } + }, + generator: generators.generateGet3DInfo + }, + { + name: 'set_3d_layer', + description: 'Enable or disable 3D on a layer and set its 3D transform. Position is set through the combined property because the separated X/Y/Z Position properties are read-only unless Separate Dimensions is enabled.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + enable3D: { type: 'boolean', description: 'Turn the layer\'s 3D switch on or off' }, + anchorPoint: { type: 'array', items: { type: 'number' }, description: '[x, y, z]' }, + position: { type: 'array', items: { type: 'number' }, description: '[x, y, z]' }, + scale: { type: 'array', items: { type: 'number' }, description: '[x, y, z] percent' }, + orientation: { type: 'array', items: { type: 'number' }, description: '[x, y, z] degrees' }, + rotationX: { type: 'number', description: 'Degrees' }, + rotationY: { type: 'number', description: 'Degrees' }, + rotationZ: { type: 'number', description: 'Degrees' }, + opacity: { type: 'number', description: 'Percent' } + } + }, + generator: generators.generateSet3DLayer + }, + { + name: 'set_material_options', + description: 'Set Material Options on a 3D layer - shadows, lights, ambient/diffuse/specular, metal, reflection and transparency. Properties refused by the current renderer are reported in "failed" rather than aborting the call.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + castsShadows: { type: 'boolean' }, + lightTransmission: { type: 'number', description: 'Percent' }, + acceptsShadows: { type: 'boolean' }, + acceptsLights: { type: 'boolean' }, + shadowColor: { type: 'object', description: 'Colour (0-1 range). Advanced renderer only.' }, + appearsInReflections: { type: 'boolean', description: 'CINEMA 4D renderer only' }, + ambient: { type: 'number', description: 'Percent' }, + diffuse: { type: 'number', description: 'Percent' }, + specularIntensity: { type: 'number', description: 'Percent' }, + specularShininess: { type: 'number', description: 'Percent' }, + metal: { type: 'number', description: 'Percent' }, + reflectionIntensity: { type: 'number', description: 'Percent. CINEMA 4D renderer only.' }, + reflectionSharpness: { type: 'number', description: 'Percent' }, + reflectionRolloff: { type: 'number', description: 'Percent' }, + transparency: { type: 'number', description: 'Percent' }, + transparencyRolloff: { type: 'number', description: 'Percent' }, + indexOfRefraction: { type: 'number' } + } + }, + generator: generators.generateSetMaterialOptions + }, + { + name: 'set_geometry_options', + description: 'Set Geometry Options on a 3D layer. Extrusion and bevel apply to text and shape layers only and REQUIRE the advanced or cinema4d renderer - the call fails fast with an actionable message otherwise, rather than surfacing After Effects\' opaque "property is hidden" error. Curvature and segments apply to the plane geometry group.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + extrusionDepth: { type: 'number', description: 'Text and shape layers only; needs advanced/cinema4d renderer' }, + bevelStyle: { type: 'number', description: '1 none, 2 angular, 3 concave, 4 convex' }, + bevelDirection: { type: 'number' }, + bevelDepth: { type: 'number' }, + holeBevelDepth: { type: 'number', description: 'Percent' }, + curvature: { type: 'number', description: 'Plane geometry curvature, percent' }, + segments: { type: 'number', description: 'Plane geometry subdivisions' } + } + }, + generator: generators.generateSetGeometryOptions + }, + { + name: 'set_camera_options', + description: 'Set Camera Options on a camera layer - zoom, depth of field, focus, aperture, blur, and the iris/highlight controls', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + zoom: { type: 'number', description: 'Pixels' }, + depthOfField: { type: 'boolean' }, + focusDistance: { type: 'number', description: 'Pixels' }, + aperture: { type: 'number', description: 'Pixels' }, + blurLevel: { type: 'number', description: 'Percent' }, + focusAreaWidth: { type: 'number' }, + nearFarBlurLevel: { type: 'number' }, + irisShape: { type: 'number', description: 'Iris shape index' }, + irisRotation: { type: 'number', description: 'Degrees' }, + irisRoundness: { type: 'number', description: 'Percent' }, + irisAspectRatio: { type: 'number' }, + irisDiffractionFringe: { type: 'number' }, + highlightGain: { type: 'number' }, + highlightThreshold: { type: 'number' }, + highlightSaturation: { type: 'number' } + } + }, + generator: generators.generateSetCameraOptions + }, + { + name: 'set_light_options', + description: 'Set Light Options on a light layer - intensity, colour, cone angle and feather, falloff, shadows, and the environment-light background controls', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + intensity: { type: 'number', description: 'Percent' }, + color: { type: 'object', description: 'Colour (0-1 range)' }, + coneAngle: { type: 'number', description: 'Degrees; spot lights' }, + coneFeather: { type: 'number', description: 'Percent; spot lights' }, + falloff: { type: 'number', description: '1 none, 2 smooth, 3 inverse square clamped' }, + radius: { type: 'number' }, + falloffDistance: { type: 'number' }, + castsShadows: { type: 'boolean' }, + shadowDarkness: { type: 'number', description: 'Percent' }, + shadowDiffusion: { type: 'number', description: 'Pixels' }, + backgroundVisible: { type: 'boolean', description: 'Environment lights' }, + backgroundOpacity: { type: 'number', description: 'Environment lights' }, + backgroundBlur: { type: 'number', description: 'Environment lights' } + } + }, + generator: generators.generateSetLightOptions + }, + { + name: 'add_mask', + description: 'Add a mask to a layer, optionally with a bezier path. Tangents are RELATIVE to their own vertex.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + name: { type: 'string', description: 'Mask name' }, + vertices: { type: 'array', description: 'Array of [x, y] points in layer space', items: { type: 'array', items: { type: 'number' } } }, + inTangents: { type: 'array', description: 'Optional [x, y] per vertex, relative to that vertex', items: { type: 'array', items: { type: 'number' } } }, + outTangents: { type: 'array', description: 'Optional [x, y] per vertex, relative to that vertex', items: { type: 'array', items: { type: 'number' } } }, + closed: { type: 'boolean', description: 'Close the path (default true)' }, + mode: { type: 'string', enum: ['none', 'add', 'subtract', 'intersect', 'lighten', 'darken', 'difference'] }, + inverted: { type: 'boolean' }, + rotoBezier: { type: 'boolean' }, + feather: { type: 'number', description: 'Feather in pixels, applied to both axes' }, + opacity: { type: 'number', description: 'Mask opacity percent' }, + expansion: { type: 'number', description: 'Mask expansion in pixels' }, + color: { type: 'object', description: 'Mask outline color (0-1 range)' } + } + }, + generator: generators.generateAddMask + }, + { + name: 'list_masks', + description: 'List every mask on a layer with its mode, feather, opacity, expansion and whether its path is animated', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' } + } + }, + generator: generators.generateListMasks + }, + { + name: 'get_mask_path', + description: 'Read a mask path back, including every keyframe when the path is animated', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + maskIndex: { type: 'number', description: '1-based mask index' }, + maskName: { type: 'string' } + } + }, + generator: generators.generateGetMaskPath + }, + { + name: 'set_mask_path', + description: 'Replace a mask path with new vertices and tangents', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + maskIndex: { type: 'number' }, + maskName: { type: 'string' }, + vertices: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, + inTangents: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, + outTangents: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, + closed: { type: 'boolean' } + }, + required: ['vertices'] + }, + generator: generators.generateSetMaskPath + }, + { + name: 'set_mask_keyframes', + description: 'Animate a mask path. Each keyframe carries its own full vertex list.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + maskIndex: { type: 'number' }, + maskName: { type: 'string' }, + keyframes: { + type: 'array', + description: 'Array of { time, vertices, inTangents?, outTangents?, closed? }', + items: { + type: 'object', + properties: { + time: { type: 'number', description: 'Time in seconds' }, + vertices: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, + inTangents: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, + outTangents: { type: 'array', items: { type: 'array', items: { type: 'number' } } }, + closed: { type: 'boolean' } + }, + required: ['time', 'vertices'] + } + } + }, + required: ['keyframes'] + }, + generator: generators.generateSetMaskKeyframes + }, + { + name: 'set_mask_properties', + description: 'Change a mask\'s mode, inversion, lock, feather, opacity, expansion or color without touching its path. A locked mask is temporarily unlocked so the changes apply.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + maskIndex: { type: 'number' }, + maskName: { type: 'string' }, + name: { type: 'string', description: 'Rename the mask' }, + mode: { type: 'string', enum: ['none', 'add', 'subtract', 'intersect', 'lighten', 'darken', 'difference'] }, + inverted: { type: 'boolean' }, + locked: { type: 'boolean' }, + rotoBezier: { type: 'boolean' }, + feather: { type: 'number' }, + opacity: { type: 'number' }, + expansion: { type: 'number' }, + color: { type: 'object', description: 'Mask outline color (0-1 range)' } + } + }, + generator: generators.generateSetMaskProperties + }, + { + name: 'delete_mask', + description: 'Delete a mask from a layer', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + maskIndex: { type: 'number' }, + maskName: { type: 'string' } + } + }, + generator: generators.generateDeleteMask + }, + { + name: 'add_to_render_queue', + description: 'Add a composition to the render queue, optionally applying render settings and output module templates and an output path', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + outputPath: { type: 'string', description: 'Absolute output file path. NOTE: the output module template decides the actual container/extension - passing a .mov path while the module is set to H.264 produces a .mp4. Check the returned outputPath.' }, + renderSettingsTemplate: { type: 'string', description: 'Name of an existing render settings template (see list_render_templates)' }, + outputModuleTemplate: { type: 'string', description: 'Name of an existing output module template (see list_render_templates)' }, + timeSpanStart: { type: 'number', description: 'Render start time in seconds' }, + timeSpanDuration: { type: 'number', description: 'Render duration in seconds' }, + skipFrames: { type: 'number', description: 'Frames to skip between rendered frames (0 renders every frame)' } + } + }, + generator: generators.generateAddToRenderQueue + }, + { + name: 'list_render_queue', + description: 'List every render queue item with its status, time span and output modules before or after native rendering. Cannot poll during control_render start because native rendering blocks the CEP bridge.', + inputSchema: { type: 'object', properties: {} }, + generator: generators.generateListRenderQueue + }, + { + name: 'list_render_templates', + description: 'List the render settings and output module templates configured in this After Effects install, so renders can reuse existing presets by name', + inputSchema: { type: 'object', properties: {} }, + generator: generators.generateListRenderTemplates + }, + { + name: 'set_render_queue_item', + description: 'Change an existing render queue item - output path, templates, time span, or whether it is queued to render', + inputSchema: { + type: 'object', + properties: { + itemIndex: { type: 'number', description: '1-based render queue index' }, + outputPath: { type: 'string' }, + renderSettingsTemplate: { type: 'string' }, + outputModuleTemplate: { type: 'string' }, + outputModuleIndex: { type: 'number', description: 'Which output module to change (default 1)' }, + timeSpanStart: { type: 'number' }, + timeSpanDuration: { type: 'number' }, + skipFrames: { type: 'number' }, + render: { type: 'boolean', description: 'Whether this item is checked for the next render pass' } + }, + required: ['itemIndex'] + }, + generator: generators.generateSetRenderQueueItem + }, + { + name: 'remove_from_render_queue', + description: 'Remove one render queue item, or clear the entire queue with all: true', + inputSchema: { + type: 'object', + properties: { + itemIndex: { type: 'number', description: '1-based render queue index' }, + all: { type: 'boolean', description: 'Clear the whole queue' } + } + }, + generator: generators.generateRemoveFromRenderQueue + }, + { + name: 'control_render', + description: 'Start synchronous native rendering or show the Render Queue panel. WARNING: start blocks the CEP bridge until rendering finishes; polling and stop/pause/resume are unavailable. A command timeout does NOT cancel rendering. Use queue_in_ame for long jobs; do not retry or modify the queue after a timeout until rendering finishes in AE.', + inputSchema: { + type: 'object', + properties: { + action: { type: 'string', enum: ['start', 'showWindow'] } + }, + required: ['action'] + }, + generator: generators.generateControlRender + }, + { + name: 'queue_in_ame', + description: 'Send the render queue to Adobe Media Encoder. Returns immediately because AME renders in its own process, which makes it the better choice for long jobs. Set renderImmediately to start AME processing rather than only queueing.', + inputSchema: { + type: 'object', + properties: { + renderImmediately: { type: 'boolean', description: 'Also start AME processing its queue (default false)' } + } + }, + generator: generators.generateQueueInAME + }, + { + name: 'list_project_items', + description: 'List every item in the project, optionally filtered by type', + inputSchema: { + type: 'object', + properties: { + type: { type: 'string', enum: ['composition', 'footage', 'folder'], description: 'Only return items of this type' } + } + }, + generator: generators.generateListProjectItems + }, + { + name: 'set_active_composition', + description: 'Open a composition in the viewer and make it the active item', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' } + } + }, + generator: generators.generateSetActiveComposition + }, + { + name: 'set_proxy', + description: 'Attach a proxy file to a project item', + inputSchema: { + type: 'object', + properties: { + itemId: { type: 'number' }, + itemName: { type: 'string' }, + proxyPath: { type: 'string', description: 'Absolute path to the proxy file' } + }, + required: ['proxyPath'] + }, + generator: generators.generateSetProxy + }, + { + name: 'remove_proxy', + description: 'Detach the proxy from a project item', + inputSchema: { + type: 'object', + properties: { + itemId: { type: 'number' }, + itemName: { type: 'string' } + } + }, + generator: generators.generateRemoveProxy + }, + { + name: 'get_current_time', + description: 'Get the current playhead time of a composition, in seconds and frames', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' } + } + }, + generator: generators.generateGetCurrentTime + }, + { + name: 'set_current_time', + description: 'Move the playhead of a composition. Give either time (seconds) or frame.', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + time: { type: 'number', description: 'Time in seconds' }, + frame: { type: 'number', description: 'Frame number (used when time is omitted)' } + } + }, + generator: generators.generateSetCurrentTime + }, + { + name: 'snap_to_marker', + description: 'Move the playhead to a specific marker, on the composition or on a layer', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + markerIndex: { type: 'number', description: '1-based marker index' }, + layerIndex: { type: 'number', description: 'Use a layer marker instead of a comp marker' }, + layerName: { type: 'string', description: 'Use a layer marker instead of a comp marker' } + }, + required: ['markerIndex'] + }, + generator: generators.generateSnapToMarker + }, + { + name: 'get_nearest_marker', + description: 'Find the marker closest to a given time', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + time: { type: 'number', description: 'Time in seconds; defaults to the current playhead' }, + direction: { type: 'string', enum: ['previous', 'next', 'nearest'], description: 'Which way to look (default nearest)' } + } + }, + generator: generators.generateGetNearestMarker + }, + { + name: 'navigate_markers', + description: 'Jump the playhead to the first, last, previous or next composition marker', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + direction: { type: 'string', enum: ['first', 'last', 'previous', 'next'] } + }, + required: ['direction'] + }, + generator: generators.generateNavigateMarkers + }, + { + name: 'batch_set_expressions', + description: 'Set several expressions on one layer in a single operation', + inputSchema: { + type: 'object', + properties: { + compId: { type: 'number' }, + compName: { type: 'string' }, + layerIndex: { type: 'number' }, + layerName: { type: 'string' }, + expressions: { + type: 'array', + description: 'Array of { property, expression }', + items: { + type: 'object', + properties: { + property: { type: 'string', description: 'Friendly property alias, e.g. "position", or a raw slash-separated match-name path' }, + expression: { type: 'string' } + }, + required: ['property', 'expression'] + } + } + }, + required: ['expressions'] + }, + generator: generators.generateBatchSetExpressions + }, { name: 'add_null_layer', description: 'Add a null object layer', @@ -431,6 +1132,7 @@ const TOOLS = [ blendMode: { type: 'string' }, parent: { type: 'number' }, is3D: { type: 'boolean' }, + collapseTransformation: { type: 'boolean', description: 'Collapse Transformations / Continuously Rasterize. On a precomp layer, effects then render in this comp\'s space instead of being clipped to the precomp\'s bounds. Not available on raster footage.' }, position: { type: 'object' }, scale: { type: 'array', items: { type: 'number' } }, rotation: { type: 'number' }, @@ -771,7 +1473,7 @@ const TOOLS = [ }, { name: 'apply_expression_template', - description: 'Apply a pre-built expression template', + description: 'Apply a pre-built expression template. The physics templates (overshoot, bounce, inertia, springy) act after every keyframe on the property. speedControl needs a Slider Control on the layer (see add_expression_control).', inputSchema: { type: 'object', properties: { @@ -784,13 +1486,18 @@ const TOOLS = [ type: 'string', enum: [ 'wiggle', 'wiggleSmooth', 'wiggleFadeIn', 'wiggleFadeOut', + 'loopingWiggle', 'wiggleOneAxis', 'loopCycle', 'loopPingpong', 'loopOffset', 'loopContinue', 'time', 'clock', 'countdown', 'frameNumber', 'matchPosition', 'offsetPosition', 'inverseRotation', 'followPath', - 'bounce', 'inertia', 'overshoot', 'springy' + 'overshoot', 'bounce', 'inertia', 'springy', + 'speedControl' ] }, - params: { type: 'object', description: 'Template parameters' } + params: { + type: 'object', + description: 'Template parameters. overshoot: frequency, decay. bounce: elasticity (0-1), gravity, maxBounces. inertia: friction. springy: mass, stiffness, damping. speedControl: controlName (slider name, units per second), multiplier. loopingWiggle: frequency, amplitude, loopTime. wiggleOneAxis: axis (0 x, 1 y, 2 z), frequency, amplitude. Numeric parameters must be numbers.' + } }, required: ['property', 'template'] }, @@ -1215,6 +1922,38 @@ const TOOLS = [ ]; // Create tool lookup map +// Complete the advertised schemas before compiling the very same objects for +// runtime validation. Do not apply strict validation to the 67 legacy tools. +const schemaValidator = new AjvJsonSchemaValidator(); +const inputValidators = new Map>(); +for (const tool of TOOLS) { + if (!INTEGRATION_TOOLS.has(tool.name)) continue; + const schema = tool.inputSchema; + const properties = schema.properties!; + if ('layerIndex' in properties) properties.layerIndex = POSITIVE_INDEX_SCHEMA; + if ('layerName' in properties) properties.layerName = NONEMPTY_NAME_SCHEMA; + if (LAYER_TARGETED_TOOLS.has(tool.name)) { + schema.anyOf = [{ required: ['layerIndex'] }, { required: ['layerName'] }]; + } + if (tool.name === 'set_proxy' || tool.name === 'remove_proxy') { + properties.itemId = POSITIVE_INDEX_SCHEMA; + properties.itemName = NONEMPTY_NAME_SCHEMA; + schema.anyOf = [{ required: ['itemId'] }, { required: ['itemName'] }]; + } + if (tool.name === 'create_path') { + properties.fillColor = RGB_SCHEMA; + properties.strokeColor = RGB_SCHEMA; + properties.position = POSITION_SCHEMA; + } + if (tool.name === 'add_mask' || tool.name === 'set_mask_properties') { + properties.color = RGB_SCHEMA; + properties.feather = FEATHER_SCHEMA; + } + if (tool.name === 'set_material_options') properties.shadowColor = RGB_SCHEMA; + if (tool.name === 'set_light_options') properties.color = RGB_SCHEMA; + inputValidators.set(tool.name, schemaValidator.getValidator(schema)); +} + const toolMap = new Map(); TOOLS.forEach(tool => toolMap.set(tool.name, tool)); @@ -1265,8 +2004,19 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { } try { - // Generate the script - const script = tool.generator(args as any || {}); + // Native render blocks CEP's single command channel; these operations + // cannot interrupt it. Reject even stale clients before generating/IPC. + if (name === 'control_render' && ['stop', 'pause', 'resume'].includes((args as any)?.action)) { + throw new Error('The CEP bridge cannot stop, pause or resume a synchronous native render, or poll it while it runs. Use After Effects UI controls or queue_in_ame for long jobs.'); + } + const params = args ?? {}; + const validate = inputValidators.get(name); + if (validate) { + const result = validate(params); + if (!result.valid) throw new Error(`Invalid arguments for ${name}: ${result.errorMessage}`); + } + // Generate the script only after input validation. + const script = tool.generator(params); // Execute in After Effects const result = await communicator.executeScript(script); diff --git a/src/types/aeTypes.ts b/src/types/aeTypes.ts index 55adf4e..ac35448 100644 --- a/src/types/aeTypes.ts +++ b/src/types/aeTypes.ts @@ -282,7 +282,9 @@ export const AE_PROPERTY_PATHS: Record = { 'audioLevels': 'ADBE Audio Group/ADBE Audio Levels', // Camera - 'pointOfInterest': 'ADBE Camera Options Group/ADBE Camera Point of Interest', + // A camera's Point of Interest is its anchor point - AE only relabels it. + // There is no "ADBE Camera Point of Interest" property. + 'pointOfInterest': 'ADBE Transform Group/ADBE Anchor Point', 'zoom': 'ADBE Camera Options Group/ADBE Camera Zoom', 'depthOfField': 'ADBE Camera Options Group/ADBE Camera Depth of Field', 'focusDistance': 'ADBE Camera Options Group/ADBE Camera Focus Distance', @@ -293,8 +295,10 @@ export const AE_PROPERTY_PATHS: Record = { 'lightIntensity': 'ADBE Light Options Group/ADBE Light Intensity', 'lightColor': 'ADBE Light Options Group/ADBE Light Color', 'coneAngle': 'ADBE Light Options Group/ADBE Light Cone Angle', - 'coneFeather': 'ADBE Light Options Group/ADBE Light Cone Feather', - 'castsShadows': 'ADBE Light Options Group/ADBE Light Casts Shadows', + // "ADBE Light Cone Feather" (no suffix) is not on current lights. + 'coneFeather': 'ADBE Light Options Group/ADBE Light Cone Feather 2', + // Lights reuse the material match name; "ADBE Light Casts Shadows" doesn't exist. + 'castsShadows': 'ADBE Light Options Group/ADBE Casts Shadows', 'shadowDarkness': 'ADBE Light Options Group/ADBE Light Shadow Darkness', 'shadowDiffusion': 'ADBE Light Options Group/ADBE Light Shadow Diffusion',