Nubu
  • Features
  • How it works
  • Pricing
  • Integrations
  • FAQ
  • Contact
  • Docs
  • Blog
  • Log in
  • Log inSign up
    Nubu

    Creative automation for teams that ship campaigns, not busywork.

    Try for free
    Product
    FeaturesNubu MotionHow it worksPricingIntegrationsFAQ
    Company
    AboutContactDocsBlogLog in
    Legal
    Privacy policyTerms of serviceData deletion
    © 2026 Bear Studios · Nubu
    Introduction
    Templates overviewInstalling Nubu BuilderAdding template compsAdding propertiesAdding footageSetting defaultsExporting your templateUploading a template
    Flows overviewAvailable nodesAdding a nodeConnecting nodesWorking with dataTemplates and outputsUsing AI nodesBuilding a creative
    CampaignsTemplatesAssetsGlossariesRendersSettings
    Connecting Meta AdsConnecting Google AdsConnecting OpenAIConnecting Gemini AI StudioConnecting Anthropic
    AI Assistant overviewPrompt examplesAssistant settings and controls
    Nubu Motion scripts: the reference
    OverviewQuick startDifferences from After EffectsDeterminism
    JavaScript supportReferences and idsProject scriptsState and timeBudgets and errorsFormatting values
    GlobalTime conversionInterpolationVector mathsRandom and noiseColour conversionOther maths
    LayerCompPropertyKeyframePathGeometryProjectComponentConsoleComponentControlExpressionControl
    The property vocabularyAnimators propertiesAudio propertiesContent propertiesContents propertiesEffects propertiesExpression controls propertiesFills propertiesInstance controls propertiesLayer propertiesLayout propertiesMasks propertiesStrokes propertiesText style propertiesTransform propertiesTrim path properties
    Error codesBudgets
    Changelog
    All docs
    Motion Scripting
    / General

    Global

    The names every script can use without importing anything: the clock, the scene lookups, the maths helpers and the writing tools.

    The environment every script wakes up in: the clock, the scene handles, the lookups, the writing tools and the per-frame services. Everything here is available without importing anything.

    Table of contentsvalueWorked example: Dim from the base valuetimeWorked example: Slide with the clockframeWorked example: A countdown built from five layersWorked example: A countdownfpsWorked example: Blink once a secondframeDurationWorked example: A one frame flashthisCompWorked example: Pin to the comp's geometrythisLayerWorked example: Lean by your own placethisComponentWorked example: Read the component's own controlprojectWorked example: Grow with the documentoutputsWorked example: Gravity over gathered crateslayerWorked example: A layer in handcompWorked example: The evaluating comp in handpropWorked example: Borrow another propertycontrolWorked example: A slider in chargesetWorked example: One script, two compsstateWorked example: A square with gravityWorked example: Count the frames yourselfmemoWorked example: Compute once, use every frameconsoleWorked example: Watch a value while you work

    value#

    Type: T (accessor).

    The pre-expression (static or keyframed) value of the script's first output at the current frame - the AE value.

    Determinism: pure.

    value is always the value the property would have WITHOUT this script: the panel's base number, or the keyframed sample at the current frame. Scripts that start from value layer politely on top of hand animation - disable the script and the base picture is still there.

    Worked example: Dim from the base value#

    The scene. One orange square resting on a dark 640 by 360 stage.

    • The comp Drift: 640 by 360 at 25 fps, 100 frames (4 seconds).
    • Square: a solid layer, at [320, 180], 60 by 60, filled #f2a833.

    The script. Add a project script named Dim and paste:

    export const outputs = ["layer_1:transform.opacity"];
    
    export function frame() {
      return value - 55;
    }
    

    Line by line:

    • Line 1: The script drives one property: the Square's opacity.
    • Line 3: frame() runs once per frame; its return value becomes the opacity for that frame.
    • Line 4: value is the opacity the Square would have without the script - the 100 shown in the panel. Subtracting 55 leaves 45, so nudging the panel value later shifts the result with it.

    What you see. The square renders at 45 percent opacity. Change the layer's opacity in the panel and the script keeps subtracting 55 from whatever you set.

    The square renders at 45 percent opacity. Change the layer's opacity in the panel and the script keeps subtracting 55 from whatever you set.

    time#

    Type: number (accessor).

    The current comp time in seconds (frame / fps).

    Determinism: pure.

    Worked example: Slide with the clock#

    The scene. One orange square resting on a dark 640 by 360 stage.

    • The comp Drift: 640 by 360 at 25 fps, 100 frames (4 seconds).
    • Square: a solid layer, at [320, 180], 60 by 60, filled #f2a833.

    The script. Add a project script named Slide and paste:

    export const outputs = ["layer_1:transform.position"];
    
    export function frame() {
      return [value[0] + time * 60, value[1]];
    }
    

    Line by line:

    • Line 3: No keyframes anywhere - the motion comes entirely from the clock.
    • Line 4: time is the comp's current time in seconds. Sixty pixels per second, added to the resting x, slides the Square rightwards; the y stays put.

    What you see. The square glides right at a steady 60 pixels per second - one second in, it has moved exactly 60 pixels.

    The square glides right at a steady 60 pixels per second - one second in, it has moved exactly 60 pixels.

    frame#

    Type: number (accessor).

    The current frame on the owning comp's grid (integer, canonical).

    Determinism: pure.

    One caution: inside export function frame() { ... } the bare word frame names the function itself, not the frame number. Destructure the argument - export function frame({ frame, fps }) - whenever you need the frame count inside the function, exactly as this example does.

    Worked example: A countdown built from five layers#

    The scene. Five text layers stacked near the centre, reading 5, 4, 3, 2 and 1, on a five second comp.

    • The comp Countdown: 640 by 360 at 25 fps, 125 frames (5 seconds).
    • Five: a text layer, at [465, 239], reading "5".
    • Four: a text layer, at [465, 239], reading "4".
    • Three: a text layer, at [465, 239], reading "3".
    • Two: a text layer, at [465, 239], reading "2".
    • One: a text layer, at [465, 239], reading "1".

    The script. Add a project script named Countdown and paste:

    export const outputs = [
      "layer_1:transform.opacity",
      "layer_2:transform.opacity",
      "layer_3:transform.opacity",
      "layer_4:transform.opacity",
      "layer_5:transform.opacity",
    ];
    
    export function frame({ frame, fps }) {
      const seconds_gone = Math.floor(frame / fps);
      const showing = 5 - Math.min(seconds_gone, 4);
      for (let n = 1; n <= 5; n += 1) {
        set("layer_" + (6 - n) + ":transform.opacity", n === showing ? 100 : 0);
      }
    }
    

    Line by line:

    • Lines 1-7: Five outputs, one per digit layer. A script may only drive what it declares.
    • Line 9: The frame count is taken from the function's argument. Inside frame() the bare word frame would name the function itself, so destructuring { frame, fps } is the way to read the clock.
    • Line 10: Whole seconds elapsed: frames divided by frames-per-second, rounded down.
    • Line 11: Second 0 shows 5, second 1 shows 4, and so on; Math.min holds the last digit on screen at the end.
    • Lines 12-14: Every digit gets an opacity each frame: 100 for the one being shown, 0 for the rest. set() is how a script with several outputs writes each one.

    What you see. The numbers 5, 4, 3, 2, 1 take the stage one per second - a countdown made of ordinary text layers, driven by one script.

    The numbers 5, 4, 3, 2, 1 take the stage one per second - a countdown made of ordinary text layers, driven by one script.

    Worked example: A countdown#

    The scene. One text layer near the centre of a dark 640 by 360 stage, reading 5, on a five second comp.

    • The comp Counter: 640 by 360 at 25 fps, 125 frames (5 seconds).
    • Counter: a text layer, at [465, 239], reading "5".

    The script. Add a project script named Countdown and paste:

    export const outputs = ["layer_1:content.text"];
    
    export function frame({ frame, fps }) {
      const seconds_gone = Math.floor(frame / fps);
      return String(5 - Math.min(seconds_gone, 4));
    }
    

    Line by line:

    • Line 1: One output: the Counter layer's TEXT. Since the drive set widened to every animatable property, a script may write the source text of a text layer - a per-frame HOLD, exactly like a text keyframe.
    • Line 3: The frame count is taken from the function's argument. Inside frame() the bare word frame would name the function itself, so destructuring { frame, fps } is the way to read the clock.
    • Line 4: Whole seconds elapsed: frames divided by frames-per-second, rounded down.
    • Line 5: Second 0 shows 5, second 1 shows 4, and so on; Math.min holds the last digit on screen at the end. A script with exactly one output may simply RETURN the value - strings write text holds, so the layer re-shapes with the new digit every second.

    What you see. The one text layer counts 5, 4, 3, 2, 1 - one digit per second - driven straight into its source text by the script.

    The one text layer counts 5, 4, 3, 2, 1 - one digit per second - driven straight into its source text by the script.

    fps#

    Type: number (accessor).

    The comp's frames per second (the exact rational as a number).

    Determinism: pure.

    Worked example: Blink once a second#

    The scene. One orange square resting on a dark 640 by 360 stage.

    • The comp Drift: 640 by 360 at 25 fps, 100 frames (4 seconds).
    • Square: a solid layer, at [320, 180], 60 by 60, filled #f2a833.

    The script. Add a project script named Blink and paste:

    export const outputs = ["layer_1:transform.opacity"];
    
    export function frame({ frame, fps }) {
      return frame % fps < fps / 2 ? 100 : 30;
    }
    

    Line by line:

    • Line 3: frame and fps come from the function's argument (inside frame() the bare word frame is the function itself).
    • Line 4: frame % fps counts 0 up to fps - 1 and wraps every second. For the first half of each second the Square is solid; for the second half it dims to 30.

    What you see. The square blinks with a steady one second heartbeat: solid for half a second, dimmed for half a second - at any comp frame rate.

    The square blinks with a steady one second heartbeat: solid for half a second, dimmed for half a second - at any comp frame rate.

    frameDuration#

    Type: number (accessor).

    One frame in seconds (1 / fps).

    Determinism: pure.

    Worked example: A one frame flash#

    The scene. One orange square resting on a dark 640 by 360 stage.

    • The comp Drift: 640 by 360 at 25 fps, 100 frames (4 seconds).
    • Square: a solid layer, at [320, 180], 60 by 60, filled #f2a833.

    The script. Add a project script named Flash and paste:

    export const outputs = ["layer_1:transform.opacity"];
    
    export function frame() {
      return time < frameDuration ? 100 : 20;
    }
    

    Line by line:

    • Line 4: frameDuration is the length of one frame in seconds (1 / fps). Only the very first frame satisfies time < frameDuration, so the Square flashes fully visible for exactly one frame and rests at 20 after that - whatever the frame rate.

    What you see. A single full-brightness frame at the start, then the square rests dimmed at 20 percent for the remainder of the comp.

    A single full-brightness frame at the start, then the square rests dimmed at 20 percent for the remainder of the comp.

    thisComp#

    Type: Comp (accessor).

    The composition being evaluated.

    Determinism: pure.

    Worked example: Pin to the comp's geometry#

    The scene. One orange square resting on a dark 640 by 360 stage.

    • The comp Drift: 640 by 360 at 25 fps, 100 frames (4 seconds).
    • Square: a solid layer, at [320, 180], 60 by 60, filled #f2a833.

    The script. Add a project script named Pin low and paste:

    export const outputs = ["layer_1:transform.position"];
    
    export function frame() {
      return [thisComp.width / 2, thisComp.height - 40];
    }
    

    Line by line:

    • Line 4: thisComp is the composition being evaluated. Half its width centres the Square's position horizontally; height - 40 rests it 40 pixels above the bottom edge. Resize the comp later and the Square re-pins itself - no numbers to update.

    What you see. The square snaps its position to the bottom centre of the stage, 40 pixels above the edge, and stays pinned there even if the comp is resized.

    The square snaps its position to the bottom centre of the stage, 40 pixels above the edge, and stays pinned there even if the comp is resized.

    thisLayer#

    Type: Layer (accessor).

    The layer owning the script's first output (a per-property expression's layer); null for an output-less script.

    Determinism: pure.

    Worked example: Lean by your own place#

    The scene. One pale 160 by 100 card near the centre of the stage, for anchor, scale and skew reads.

    • The comp Tilt: 640 by 360 at 25 fps, 100 frames (4 seconds).
    • Card: a solid layer, at [320, 180], 160 by 100, filled #e8e6e3.

    The script. Add a project script named Lean and paste:

    export const outputs = ["layer_1:transform.rotation"];
    
    export function frame() {
      return thisLayer.position.value[1] / 10;
    }
    

    Line by line:

    • Line 4: thisLayer is the layer that owns the script's first output - here the Card. Its position member is a Property handle; .value reads the live [x, y]. A y of 180 becomes an 18 degree lean, so dragging the card down leans it further.

    What you see. The card leans 18 degrees. Drag it lower on the stage and it leans more; drag it up and it straightens.

    The card leans 18 degrees. Drag it lower on the stage and it leans more; drag it up and it straightens.

    thisComponent#

    Type: Component | null (accessor).

    The component a COMPONENT script belongs to, with THIS instance's effective control values (the definition's defaults when the internal comp evaluates directly); null for a project script.

    Determinism: pure.

    Worked example: Read the component's own control#

    The scene. A Badge component whose internal comp holds one blue face layer, exposed with a scalar Amount control, plus one Badge instance placed in the Host comp with Amount overridden to 65.

    • The comp Host: 640 by 360 at 25 fps, 100 frames (4 seconds).
    • The comp Badge internals: 200 by 200 at 25 fps, 100 frames (4 seconds).
    • Badge face in Badge internals: a solid layer, at [100, 100], 120 by 120, filled #4d9de0.
    • Badge in Host: a component instance, at [320, 180].
      • The instance overrides the component's c_amount control to 65.
    • The component Badge wraps Badge internals and exposes 1 control: Amount (scalar, default 20).

    The script is EMBEDDED in the Badge component - add it from the component's own Scripts group, and it travels with the component wherever it is placed.

    The script. Add a component script named Slide by Amount and paste:

    export const outputs = ["layer_1:transform.position"];
    
    export function frame() {
      const amount = thisComponent.control("c_amount").value;
      return [40 + amount, value[1]];
    }
    

    Line by line:

    • Line 3: This script is embedded in the Badge component, so it travels with the component wherever it is placed.
    • Line 4: thisComponent is the component being evaluated, carrying the control values that apply RIGHT HERE: the definition's defaults when you edit the component itself, and each instance's own values when it is placed in a comp.
    • Line 5: The face slides right by the Amount. Editing the definition (Amount 20) parks it at x 60; the instance in the Host comp overrides Amount to 65, so that copy sits at x 105.

    What you see. Inside the component editor the face sits at x 60 (the default Amount of 20); the instance placed in the Host comp shows it at x 105 because its Amount is overridden to 65.

    Inside the component editor the face sits at x 60 (the default Amount of 20); the instance placed in the Host comp shows it at x 105 because its Amount is overridden to 65.

    project#

    Type: Project (accessor).

    The document (the multi-comp envelope).

    Determinism: pure.

    Worked example: Grow with the document#

    The scene. A Main comp holding a wide banner, and a second Card comp holding a small badge, so one project script can drive layers in both.

    • The comp Main: 640 by 360 at 25 fps, 100 frames (4 seconds).
    • The comp Card: 320 by 180 at 25 fps, 100 frames (4 seconds).
    • Banner in Main: a solid layer, at [320, 300], 400 by 40, filled #2ec4b6.
    • Badge in Card: a solid layer, at [160, 90], 60 by 60, filled #ff5d73.

    The script. Add a project script named Grow and paste:

    export const outputs = ["layer_1:transform.scale"];
    
    export function frame() {
      const boost = project.numComps * 10;
      return [100 + boost, 100 + boost];
    }
    

    Line by line:

    • Line 4: project is the whole document - the envelope holding every comp. This document has two comps, so the boost is 20.
    • Line 5: Scale is a percentage pair; [120, 120] renders the Banner a fifth larger. Add a third comp and the Banner grows again.

    What you see. The banner renders at 120 percent scale because the document holds two comps; adding comps enlarges it further.

    The banner renders at 120 percent scale because the document holds two comps; adding comps enlarges it further.

    outputs#

    Type: Array<Property> (accessor).

    M10 DOM2-1: the script's declared outputs as Property handles (the FLATTENED list, document order). Each authored output GROUP rides as a named member on the array (outputs.<group> = that group's own Array) - a JS array with named extras, pinned. A missing property's handle reads null (the never-brick rule); set() onto it reports reference_missing at evaluation.

    Determinism: pure.

    The outputs list can gather targets into NAMED GROUPS: an entry {"group": "fades", "targets": [...]} flattens into the one canonical list, and the outputs global carries the group as a named member, so outputs.fades is that group's own array of Property handles. Group names follow the JS identifier rule and stay unique per script.

    One shadowing rule to know: a module-level export const outputs = [...] declaration shadows this global inside the script (the classic authoring convention keeps working). A script that wants outputs.<group> leaves the declaration out and lets the stored outputs list speak, exactly like the example below. The same trap exists for the clock: export function frame() shadows the frame global inside its own body, so take the clock from the parameter object instead.

    A target whose property no longer exists still hands out a handle whose value reads null (the never-brick rule); set() onto it refuses reference_missing at evaluation, and every element of a fan-out write meters the budgets as its own write.

    Worked example: Gravity over gathered crates#

    The scene. Four crates hang near the top of one Playground comp, each carrying its own Weight slider, so one script can gather every layer's controller and drive them all.

    • The comp Playground: 640 by 360 at 25 fps, 100 frames (4 seconds).
    • Crate A: a solid layer, at [120, 40], 56 by 56, filled #2ec4b6.
      • An expression control: Weight, a slider, set to 20.
    • Crate B: a solid layer, at [260, 40], 56 by 56, filled #ffd166.
      • An expression control: Weight, a slider, set to 45.
    • Crate C: a solid layer, at [400, 40], 56 by 56, filled #ff5d73.
      • An expression control: Weight, a slider, set to 70.
    • Crate D: a solid layer, at [540, 40], 56 by 56, filled #9b5de5.
      • An expression control: Weight, a slider, set to 95.

    The script. Add a project script named Gravity and paste:

    export function frame({ frame: now }) {
      for (let i = 0; i < thisComp.numLayers; i += 1) {
        const crate = comp().layer(i);
        if (!crate || crate.numControls === 0) continue;
        const weight = crate.controls[0].value;
        const x = 120 + i * 140;
        set(crate.position, [x, 40 + weight * (now * 0.1)]);
      }
      set(outputs.fades, 65);
    }
    

    Line by line:

    • Line 1: No module-level outputs declaration here: the stored outputs list (two named groups, positions and fades) is the declaration, which keeps the outputs DOM global visible. The clock arrives as the parameter because a function named frame shadows the frame global inside its own body.
    • Lines 2-3: The gather loop the feedback asked for: thisComp.numLayers bounds a 0-based walk and comp().layer(i) answers each layer handle, null on a miss, so the loop never throws.
    • Lines 4-5: Each crate carries its own Weight slider; crate.controls[0].value reads it live. Layers without controllers simply skip, so the script keeps working when the scene grows.
    • Lines 6-7: Every crate falls at its own rate: heavier crates drop further by the same frame. The write goes through the crate's own position handle, one of the declared positions group.
    • Line 9: The fan-out: set() accepts an ARRAY of targets, and outputs.fades is exactly that, so one call dims all four crates to 65 percent. Each element meters the budgets as its own write and the first offender fails the script typed.

    What you see. All four crates sink at their own weights while dimming together: by frame 10 Crate A sits 20 px lower and Crate D a full 95 px lower, every crate at 65 percent opacity.

    All four crates sink at their own weights while dimming together: by frame 10 Crate A sits 20 px lower and Crate D a full 95 px lower, every crate at 65 percent opacity.

    layer#

    layer(id: string): Layer
    

    A layer handle by id in the evaluated comp. A missing id is reference_missing.

    Determinism: pure.

    Worked example: A layer in hand#

    The scene. A teal leader square keyframed to cross the stage left to right, with an orange follower resting below its start point.

    • The comp Leaders: 640 by 360 at 25 fps, 100 frames (4 seconds).
    • Leader: a solid layer, at [80, 140], 40 by 40, filled #2ec4b6.
      • Keyframe transform.position at frame 0: [80, 140] (linear).
      • Keyframe transform.position at frame 96: [560, 140] (linear).
    • Follower: a solid layer, at [80, 240], 40 by 40, filled #f2a833.

    The script. Add a project script named Shadow the leader and paste:

    export const outputs = ["layer_2:transform.position"];
    
    export function frame() {
      const leader = layer("Leader");
      return [leader.position.value[0], value[1]];
    }
    

    Line by line:

    • Line 4: layer() answers a Layer handle. In the editor you type the layer's name inside the quotes (or drag the @ pick whip onto it); the stored script keeps the layer's id, which is why renames never break references.
    • Line 5: The handle's transform members are Property objects - .position.value is the live [x, y] at this frame, keyframes and all.

    What you see. The follower tracks the leader across the stage, locked to its x position while keeping its own height.

    The follower tracks the leader across the stage, locked to its x position while keeping its own height.

    comp#

    comp(id?: string): Comp
    

    A comp handle (the evaluated comp by default).

    Determinism: pure.

    comp() is closed over the comp being evaluated: with no argument it answers that comp (the same handle as thisComp), and asking for any OTHER comp's id raises the typed reference_missing. A project script whose outputs span several comps runs once per comp, and each run sees only its own - cross-comp influence flows through the values you write, never through reads.

    Worked example: The evaluating comp in hand#

    The scene. A Main comp holding a wide banner, and a second Card comp holding a small badge, so one project script can drive layers in both.

    • The comp Main: 640 by 360 at 25 fps, 100 frames (4 seconds).
    • The comp Card: 320 by 180 at 25 fps, 100 frames (4 seconds).
    • Banner in Main: a solid layer, at [320, 300], 400 by 40, filled #2ec4b6.
    • Badge in Card: a solid layer, at [160, 90], 60 by 60, filled #ff5d73.

    The script. Add a project script named Skirting line and paste:

    export const outputs = ["layer_1:transform.position"];
    
    export function frame() {
      const here = comp();
      return [here.width / 2, here.height - 80];
    }
    

    Line by line:

    • Line 4: comp() with no argument answers the comp this run is evaluating - for the Banner's script, Main. Passing another comp's id raises the typed reference_missing: every run is closed over its own comp, so scripts stay per-comp deterministic.
    • Line 5: The Banner pins itself to the bottom band from the comp's own dimensions - 80 pixels above the lower edge, centred.

    What you see. The banner pins itself 80 pixels above the bottom of the Main comp, centred - derived from the comp's own dimensions, with no literal sizes in the script.

    The banner pins itself 80 pixels above the bottom of the Main comp, centred - derived from the comp's own dimensions, with no literal sizes in the script.

    prop#

    prop(id: string): Property
    

    A property handle by id (layer_id:key). Its .value resolves live through the overlay (a driven value) or the keyframed base.

    Determinism: pure.

    Worked example: Borrow another property#

    The scene. A teal leader square keyframed to cross the stage left to right, with an orange follower resting below its start point.

    • The comp Leaders: 640 by 360 at 25 fps, 100 frames (4 seconds).
    • Leader: a solid layer, at [80, 140], 40 by 40, filled #2ec4b6.
      • Keyframe transform.position at frame 0: [80, 140] (linear).
      • Keyframe transform.position at frame 96: [560, 140] (linear).
    • Follower: a solid layer, at [80, 240], 40 by 40, filled #f2a833.

    The script. Add a project script named Match x and paste:

    export const outputs = ["layer_2:transform.position"];
    
    export function frame() {
      const leader_x = prop("Leader:transform.position").value[0];
      return [leader_x, value[1]];
    }
    

    Line by line:

    • Line 4: prop() takes a property id - a layer id, a colon, then the property key. The editor shows the layer's NAME in that string and completes names as you type, while the saved script keeps the id, so renaming the Leader never breaks this line. .value reads the property at the current frame, keyframes included.
    • Line 5: The Follower borrows the Leader's x but keeps its own resting y - it tracks the march without leaving its lane.

    What you see. The follower square mirrors the leader's march horizontally while staying in its own lower lane, as though tied to it by a vertical rod.

    The follower square mirrors the leader's march horizontally while staying in its own lower lane, as though tied to it by a vertical rod.

    control#

    control(id: string): ExpressionControl
    

    An expression control by id within THIS comp's layers (ids are document-unique); a missing id throws reference_missing (the closure rule: never another comp's control). The handle's value is LIVE - a module-level handle reads the fold frame's sampled value on every access.

    Worked example: A slider in charge#

    The scene. A gold sun near the centre, a small blue planet above it, and a hidden Rig null carrying the expression controls that steer the scene.

    • The comp Orbit: 640 by 360 at 25 fps, 200 frames (8 seconds).
    • Sun: a solid layer, at [320, 180], 80 by 80, filled #f2a833.
    • Planet: a solid layer, at [320, 60], 30 by 30, filled #4d9de0.
    • Rig: a null (an invisible controller host), at [320, 180].
      • An expression control: Speed, a slider (0 to 100, step 1, unit %), set to 40.
      • An expression control: Show planet, a checkbox, set to true.
      • An expression control: Start angle, a angle, set to 90.
      • An expression control: Direction, a dropdown (items Clockwise / Anticlockwise), set to 0.
      • An expression control: Centre, a point, set to [320, 180].
      • An expression control: Target, a layer, set to layer_2.

    The script. Add a project script named Planet size and paste:

    export const outputs = ["layer_2:transform.scale"];
    
    export function frame() {
      const speed = control("Speed");
      return [speed.value, speed.value];
    }
    

    Line by line:

    • Line 4: control() resolves an expression control anywhere in this comp by id - the editor shows and completes the control's NAME (Speed), and the whip inserts it for you. A missing id raises the typed reference_missing rather than returning undefined.
    • Line 5: The handle's .value is live: it reads the control at the current frame, so keyframing or scrubbing the Speed slider resizes the Planet immediately. The chain control, then script, then property is the controller pattern the templates surface builds on.

    What you see. The planet's scale equals the Speed slider - 40 percent at rest. Scrub the slider on the Rig layer and the planet grows and shrinks with it.

    The planet's scale equals the Speed slider - 40 percent at rest. Scrub the slider on the Rig layer and the planet grows and shrinks with it.

    set#

    set(target: Property | string | Array<Property | string>, value: T): void
    

    Drive declared outputs. The target is a property handle, a layer_id:key id, or an ARRAY of either (M10 DOM2-1 fan-out: the one value applies to each element in order; every element meters the budgets as its own write; the first offender fails the script typed). Refuses a target outside the script's outputs (undeclared_output) and a non-drivable kind (type_mismatch).

    Determinism: pure.

    Worked example: One script, two comps#

    The scene. A Main comp holding a wide banner, and a second Card comp holding a small badge, so one project script can drive layers in both.

    • The comp Main: 640 by 360 at 25 fps, 100 frames (4 seconds).
    • The comp Card: 320 by 180 at 25 fps, 100 frames (4 seconds).
    • Banner in Main: a solid layer, at [320, 300], 400 by 40, filled #2ec4b6.
    • Badge in Card: a solid layer, at [160, 90], 60 by 60, filled #ff5d73.

    The script. Add a project script named House lights and paste:

    export const outputs = [
      "layer_1:transform.opacity",
      "layer_2:transform.opacity",
    ];
    
    export function frame() {
      set("layer_1:transform.opacity", linear(time, 0, 2, 100, 40));
      set("layer_2:transform.opacity", linear(time, 1, 3, 100, 80));
    }
    

    Line by line:

    • Lines 1-4: The Banner lives in the Main comp and the Badge lives in the Card comp - one script may declare outputs across any number of comps.
    • Lines 7-8: set(target, value) writes one declared output. With more than one output there is no single return value, so set() is the writing tool. Here each write carries its own linear(time, ...) ramp - the banner fades to 40 percent across the first two seconds, the badge to 80 on a later curve - one script animating layers in two comps at once. Targets outside the outputs list refuse with undeclared_output, and a value of the wrong shape refuses with type_mismatch - typed errors, never silent surprises.

    What you see. The banner in Main fades down to 40 percent across the first two seconds while the badge over in Card eases to 80 percent on its own later ramp - both driven each frame by the same script.

    The banner in Main fades down to 40 percent across the first two seconds while the badge over in Card eases to 80 percent on its own later ramp - both driven each frame by the same script.

    state#

    state(initial: object, options?: {key?: string}): object
    

    A per-script store folded over the comp's frame grid from an epoch (scrub-equals-sequential). Mutate it freely each frame; it holds PLAIN DATA (JSON round-trip; a BigInt/cycle is state_unserialisable, over 256 KB state_too_large). options.key names multiple stores. MODULE-LEVEL (1.3.0): const s = state({...}) at the top level is one realm-owned store the fold REFILLS at every fold frame's start (the previous frame's value, else the initial) and serialises at frame end - it folds exactly like an in-frame call, and state() inside frame() for the same key returns the SAME object. Module-level handles from layer() / control() / prop() are LIVE (their value reads the fold frame); set() outside frame() is discarded.

    Determinism: stateful.

    During a fold your script's clock (time, frame, dt) walks the frame grid step by step, but PROPERTY and CONTROL reads always see the frame being rendered - fold from your own store and the clock, never from a property you expect to change per step.

    Stores hold plain data only (things JSON can carry). A store above 256 KB is the typed state_too_large; a BigInt or a circular object is state_unserialisable.

    Worked example: A square with gravity#

    The scene. A keyframed leader square crossing the stage diagonally, and a small echo square that a script will teach to remember where the leader has been.

    • The comp Trail: 640 by 360 at 25 fps, 100 frames (4 seconds).
    • Leader: a solid layer, at [100, 80], 36 by 36, filled #2ec4b6.
      • Keyframe transform.position at frame 0: [100, 80] (linear).
      • Keyframe transform.position at frame 96: [540, 280] (linear).
    • Echo: a solid layer, at [100, 80], 24 by 24, filled #ff5d73.

    The script. Add a project script named Gravity and paste:

    export const outputs = ["layer_2:transform.position"];
    
    export function frame({ dt }) {
      const store = state({ y: 60, vy: 0 });
      store.vy += 900 * dt;
      store.y += store.vy * dt;
      if (store.y > 300) {
        store.y = 300;
        store.vy = -store.vy * 0.75;
      }
      return [value[0] + time * 40, store.y];
    }
    

    Line by line:

    • Line 4: state() answers a store that SURVIVES from frame to frame - the one thing ordinary expressions cannot do. The first frame sees the initial object; every later frame sees what the previous frame left behind.
    • Lines 5-6: Plain physics: gravity accelerates the fall speed, and the fall speed moves the square. dt is the length of this frame in seconds.
    • Lines 7-10: Hitting the floor at y 300 bounces the square up again with three quarters of its speed - each bounce a little lower than the last.
    • Line 11: The x drifts steadily so the bounces trace across the stage. Scrubbing anywhere in the timeline lands on exactly the frame a straight playthrough would show - state folds deterministically from the first frame.

    What you see. The echo square falls, bounces, and settles in ever-smaller hops while drifting rightwards - and scrubbing backwards or jumping ahead always shows the same trajectory.

    The echo square falls, bounces, and settles in ever-smaller hops while drifting rightwards - and scrubbing backwards or jumping ahead always shows the same trajectory.

    Worked example: Count the frames yourself#

    The scene. A keyframed leader square crossing the stage diagonally, and a small echo square that a script will teach to remember where the leader has been.

    • The comp Trail: 640 by 360 at 25 fps, 100 frames (4 seconds).
    • Leader: a solid layer, at [100, 80], 36 by 36, filled #2ec4b6.
      • Keyframe transform.position at frame 0: [100, 80] (linear).
      • Keyframe transform.position at frame 96: [540, 280] (linear).
    • Echo: a solid layer, at [100, 80], 24 by 24, filled #ff5d73.

    The script. Add a project script named Counter and paste:

    export const outputs = ["layer_2:transform.position"];
    
    export function frame() {
      const store = state({ n: 0 });
      store.n += 1;
      return [100 + store.n * 2, 80];
    }
    

    Line by line:

    • Lines 4-5: The store starts at zero and gains one per evaluated frame. At frame 30 the counter reads 31 - frames 0 through 30 inclusive have each run once, whether you played into the frame or jumped straight to it.
    • Line 6: Two pixels per counted frame moves the echo right - a position that proves the fold: scrubbing equals playing, always.

    What you see. The echo square creeps rightwards two pixels per frame; jump anywhere in the timeline and it is exactly where continuous playback would have put it.

    The echo square creeps rightwards two pixels per frame; jump anywhere in the timeline and it is exactly where continuous playback would have put it.

    memo#

    memo(key: string, fn: () => T): T
    

    Compute fn() once per document revision and cache it. The body may not read time/frame (memo_time_access).

    Determinism: pure.

    Worked example: Compute once, use every frame#

    The scene. One orange square resting on a dark 640 by 360 stage.

    • The comp Drift: 640 by 360 at 25 fps, 100 frames (4 seconds).
    • Square: a solid layer, at [320, 180], 60 by 60, filled #f2a833.

    The script. Add a project script named Anchor line and paste:

    export const outputs = ["layer_1:transform.position"];
    
    export function frame() {
      const half_width = memo("half_width", () => thisComp.width / 2);
      return [half_width + 100, value[1]];
    }
    

    Line by line:

    • Line 4: memo(key, fn) runs the function ONCE per document revision and caches the answer - ideal for derived constants and lookup tables. The body must not read time or frame (that raises the typed memo_time_access): memo is for things that only change when the document changes.
    • Line 5: The Square parks its position 100 pixels right of centre; edit the comp's size and the memo recomputes on the next change.

    What you see. The square parks its position 100 pixels right of the comp's centre line, the centre computed once and remembered until the document changes.

    The square parks its position 100 pixels right of the comp's centre line, the centre computed once and remembered until the document changes.

    console#

    Type: Console (accessor).

    Diagnostics only - entries echo on evaluate_script_preview and the farm/status surfaces (100 entries per evaluation, then console_dropped counts the rest); output never affects pixels.

    Worked example: Watch a value while you work#

    The scene. One orange square resting on a dark 640 by 360 stage.

    • The comp Drift: 640 by 360 at 25 fps, 100 frames (4 seconds).
    • Square: a solid layer, at [320, 180], 60 by 60, filled #f2a833.

    The script. Add a project script named Heartbeat log and paste:

    export const outputs = ["layer_1:transform.opacity"];
    
    export function frame({ frame }) {
      console.log("frame", frame, "opacity", 90);
      return 90;
    }
    

    Line by line:

    • Line 4: console is for your eyes only: entries appear in the IDE's live preview strip and on the farm's status surfaces, and never affect pixels. Up to 100 entries per evaluation are kept; anything more is counted as dropped.
    • Line 5: The visible half of the example - the Square rests at 90 while the log narrates each frame.

    What you see. The square rests at 90 percent opacity while the IDE's console strip narrates every evaluated frame.

    The square rests at 90 percent opacity while the IDE's console strip narrates every evaluated frame.