diff --git a/Sequence/0.2.0/Sequence.js b/Sequence/0.2.0/Sequence.js new file mode 100644 index 000000000..effa2f3ef --- /dev/null +++ b/Sequence/0.2.0/Sequence.js @@ -0,0 +1,5443 @@ +// ============================================================================= +// Sequence v0.2.0 +// Last Updated: 2026-06-01 +// Author: Kenan Millet +// +// Description: +// General-purpose keyframe recording and playback engine for Roll20 objects. +// Records changes to object attributes as timestamped delta keyframes, stores +// them in handouts, and plays them back on any arbitrary object. +// +// Recordings are stored in handouts named "[Sequence] " and are fully +// portable — copy a handout to another campaign to transfer a recording. +// +// Dependencies: none +// +// Commands: +// !sequence record [name] [flags] [ignore-selected] [obj_id...] +// Start recording on selected/listed objects. +// Flags: +// --attrs Only record the listed attributes (default: all) +// +// !sequence stop [ignore-selected] [obj_id...] +// Stop recording and save automatically if a name was given. +// +// !sequence pause [ignore-selected] [obj_id...] +// !sequence resume [ignore-selected] [obj_id...] +// Pause/resume recording without discarding captured keyframes. +// +// !sequence save [--force] [ignore-selected] [obj_id...] +// Save the active recording under . Creates/updates a handout. +// +// !sequence list +// List all saved recordings (scans for [Sequence] handouts). +// +// !sequence edit +// Open the handout editor for a recording (whispers a link). +// +// !sequence delete [--force] +// Delete a recording (deletes the handout). +// +// !sequence play [flags] [ignore-selected] [obj_id...] +// Play a recording on selected/listed objects. +// Flags: +// --loop Loop indefinitely +// --loops Loop n times +// --speed Playback speed multiplier (default: 1.0) +// --reverse Play in reverse +// --offset Start at time offset +// --only Only apply listed attributes +// --exclude Exclude listed attributes +// +// !sequence stop-play [ignore-selected] [obj_id...] +// Stop playback on selected/listed objects. +// +// !sequence pause-play [ignore-selected] [obj_id...] +// !sequence resume-play [ignore-selected] [obj_id...] +// Pause/resume playback. +// +// !sequence playback-menu [ignore-selected] [obj_id...] +// Show the playback menu for selected/listed objects. +// +// !sequence add-attribute +// Add an attribute column to a recording's handout. +// +// !sequence remove-attribute +// Remove an attribute column from a recording's handout. +// +// !sequence refresh +// Regenerate a recording's handout from the parsed in-memory cache. +// Use if the handout has been accidentally corrupted. +// +// !sequence --help +// Show command reference. +// ============================================================================= + +/* global state, on, sendChat, getObj, createObj, findObjs, Campaign, + playerIsGM, log, _, setInterval, clearInterval, Date */ + +var Sequence = Sequence || (() => { + 'use strict'; + + const SCRIPT_NAME = 'Sequence'; + const SCRIPT_VERSION = '0.2.0'; + const CMD_TOKEN = '!sequence'; + const HANDOUT_PREFIX = '[Sequence] '; + const HANDOUT_SCHED_PREFIX = '[Schedule] '; + + // ========================================================================= + // Attribute Registry Infrastructure + // ========================================================================= + + // { '/': registrationObject } + const ATTR_REGISTRY = {}; + // Quick lookup: { '': registrationObject } — last-registered wins on collision + const ATTR_BY_NAME = {}; + + /** + * Register an attribute for recording/playback. + * + * @param {object} reg + * @param {string} reg.name Attribute name (used as column header in handout) + * @param {string} reg.namespace 'core' or external script name + * @param {string} reg.objectType Roll20 object type ('graphic', 'text', etc.) + * @param {function} reg.get (obj) => current value + * @param {function} reg.set (obj, val) => void — for absolute (= prefix) + * @param {function} reg.diff (prev, curr) => delta | null (null = no change) + * @param {function} reg.apply (obj, delta) => void — for relative (+/- prefix) + * @param {function|null} reg.lerp (a, b, t) => interpolated value, or null if not interpolatable + * @param {function} reg.format (delta) => string for handout display + * @param {function} reg.parse (str) => delta/value from handout string + * @param {function|null} reg.startWatch (obj, notify) => void + * Called when Sequence starts recording obj. subscribe to changes for + * this attribute on obj and call notify(currVal) — just the current value, + * no attribute name needed — whenever the attribute changes. + * null for core attributes (covered by Roll20's change:graphic: events). + * @param {function|null} reg.stopWatch (obj) => void + * Called when Sequence stops recording obj. Clean up any subscriptions. + * null for core attributes. + */ + // Validate an identifier segment — no dots, must be valid JS identifier + const validIdent = (s) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(s); + + // Validate a namespace string — each dot-separated segment must be a valid identifier + const validNamespace = (ns) => ns === 'core' || ns.split('.').every(validIdent); + + // Get the qualified name for an attribute: + // core attributes use bare name, others use namespace.name + const qualifiedAttrName = (reg) => + reg.namespace === 'core' ? reg.name : `${reg.namespace}.${reg.name}`; + + const registerAttribute = (sourceId, reg) => { + const src = sourceId || SCRIPT_NAME; + + // Validate name and namespace + if (!validIdent(reg.name)) { + log(`${SCRIPT_NAME}: [${src}] registerAttribute — invalid name "${reg.name}" (must be a valid identifier with no dots)`); + return false; + } + if (!validNamespace(reg.namespace || 'core')) { + log(`${SCRIPT_NAME}: [${src}] registerAttribute — invalid namespace "${reg.namespace}"`); + return false; + } + + reg.namespace = reg.namespace || 'core'; + reg.description = reg.description || ''; + reg.valueType = reg.valueType || 'number'; + reg.enumValues = reg.enumValues || null; + reg.examples = reg.examples || []; + // watchProp: Roll20 property to watch for change events. + // Core attributes watch their own property; external attributes are virtual + // and must call Sequence.notifyChange() explicitly — no auto-watch. + reg.watchProp = reg.namespace === 'core' ? reg.name : null; + + const key = `${reg.namespace}/${reg.name}`; + const lookupKey = qualifiedAttrName(reg); + + if (ATTR_REGISTRY[key]) { + const existing = ATTR_REGISTRY[key].source || SCRIPT_NAME; + if (existing !== src) + log(`${SCRIPT_NAME}: [${src}] registerAttribute — "${lookupKey}" is already registered by [${existing}]`); + return false; + } + + reg.source = src; + ATTR_REGISTRY[key] = reg; + + // Core attributes use bare name lookup; external use qualified name + if (reg.namespace === 'core') { + ATTR_BY_NAME[reg.name] = reg; + } else { + ATTR_BY_NAME[lookupKey] = reg; + } + return true; + }; + + // Look up an attribute registration by name. + // Accepts bare names (core) or dotted qualified names (external). + const getAttrReg = (name) => { + if (ATTR_BY_NAME[name]) return ATTR_BY_NAME[name]; + // Try parsing as namespace.name — last segment is name, rest is namespace + const parts = name.split('.'); + if (parts.length > 1) { + const attrName = parts[parts.length - 1]; + const ns = parts.slice(0, -1).join('.'); + return ATTR_REGISTRY[`${ns}/${attrName}`] || null; + } + return null; + }; + + const getAllAttrNames = (objectType = 'graphic') => + Object.values(ATTR_REGISTRY) + .filter(r => r.objectType === objectType) + .map(r => qualifiedAttrName(r)); + + const getInterpolatable = (objectType = 'graphic') => + Object.values(ATTR_REGISTRY) + .filter(r => r.objectType === objectType && r.lerp !== null) + .map(r => qualifiedAttrName(r)); + + // ========================================================================= + // Core Attribute Registration Helpers + // ========================================================================= + + // Round to at most N decimal places, trimming trailing zeros + const roundVal = (v, places = 4) => { + const factor = Math.pow(10, places); + return Math.round(v * factor) / factor; + }; + + const registerScale = (name, objectType = 'graphic', description = '', examples = []) => registerAttribute(SCRIPT_NAME, { + name, namespace: 'core', objectType, + description: description || `Token ${name} (multiplicative — ×2 doubles, ×0.5 halves).`, + valueType: 'scale', + examples: examples.length ? examples : [ + `×2 double ${name}`, + `×0.5 halve ${name}`, + `×rand(0.8,1.2) random resize`, + ], + startWatch: null, + stopWatch: null, + get: (obj) => obj.get(name), + set: (obj, val) => obj.set(name, val), + diff: (prev, curr) => { + if (!prev || prev === 0 || curr === prev) return null; + const ratio = roundVal(curr / prev); + return ratio === 1 ? null : ratio; + }, + apply: (obj, ratio) => { + const cur = obj.get(name); + if (cur !== undefined && cur !== null) obj.set(name, roundVal(cur * ratio)); + }, + lerp: (a, b, t) => a + (b - a) * t, + identity: () => ({ delta: 1 }), + format: (ratio) => `×${ratio}`, + parse: (str) => { + const s = String(str).trim(); + if (s.startsWith('=')) { + const inner = s.slice(1).trim(); + if (/[A-Za-z(]/.test(inner)) return { expr: inner, mode: 'abs' }; + return { abs: parseFloat(inner) }; + } + if ((s.startsWith('×') || s.startsWith('*')) && /[A-Za-z(]/.test(s)) + return { expr: s.slice(1).trim(), mode: 'mul' }; + if (s.startsWith('×') || s.startsWith('*')) return { delta: parseFloat(s.slice(1)) }; + if (s.startsWith('x') && /^x[\d.]/.test(s)) return { delta: parseFloat(s.slice(1)) }; + return { delta: parseFloat(s) }; + }, + }); + + const registerNumeric = (name, objectType = 'graphic', description = '', examples = []) => registerAttribute(SCRIPT_NAME, { + name, namespace: 'core', objectType, + description: description || `Token ${name} (additive numeric).`, + valueType: 'number', + examples: examples.length ? examples : [ + `+70 increase ${name} by 70`, + `-70 decrease ${name} by 70`, + `=500 set ${name} to 500 (absolute)`, + `+rand(-70,70) random delta`, + ], + startWatch: null, + stopWatch: null, + get: (obj) => obj.get(name), + set: (obj, val) => obj.set(name, val), + diff: (prev, curr) => { + if (curr === prev || curr === null || curr === undefined) return null; + const d = roundVal(curr - prev); + return d === 0 ? null : d; + }, + apply: (obj, delta) => obj.set(name, roundVal((obj.get(name) || 0) + delta)), + lerp: (a, b, t) => a + (b - a) * t, + identity: () => ({ delta: 0 }), + format: (d) => d >= 0 ? `+${d}` : `${d}`, + parse: (str) => { + const s = String(str).trim(); + if (s.startsWith('=')) { + const inner = s.slice(1).trim(); + if (/[A-Za-z(]/.test(inner)) return { expr: inner, mode: 'abs' }; + return { abs: parseFloat(inner) }; + } + if (/^[+\-]/.test(s) && /[A-Za-z(]/.test(s)) { + const sign = s[0] === '-' ? -1 : 1; + return { expr: s.slice(1).trim(), mode: 'add', sign }; + } + return { delta: parseFloat(s) }; + }, + }); + + const registerBoolean = (name, objectType = 'graphic', description = '', examples = []) => registerAttribute(SCRIPT_NAME, { + name, namespace: 'core', objectType, + description: description || `Token ${name} (boolean — true/false).`, + valueType: 'boolean', + examples: examples.length ? examples : [ + `=true enable ${name}`, + `=false disable ${name}`, + ], + startWatch: null, + stopWatch: null, + get: (obj) => obj.get(name), + set: (obj, val) => obj.set(name, val), + diff: (prev, curr) => curr === prev ? null : curr, + apply: (obj, val) => obj.set(name, val), + lerp: null, + format: (val) => `=${val}`, + parse: (str) => { + const s = String(str).trim(); + const v = s.startsWith('=') ? s.slice(1) : s; + return { abs: v === 'true' || v === '1' }; + }, + }); + + const registerEnum = (name, objectType = 'graphic', description = '', enumValues = [], examples = []) => registerAttribute(SCRIPT_NAME, { + name, namespace: 'core', objectType, + description: description || `Token ${name} (enumerated value).`, + valueType: 'enum', + enumValues: enumValues.length ? enumValues : null, + examples: examples.length ? examples : (enumValues.length ? + enumValues.slice(0, 3).map(v => `=${v}`) : [`=value`]), + startWatch: null, + stopWatch: null, + get: (obj) => obj.get(name), + set: (obj, val) => obj.set(name, val), + diff: (prev, curr) => { + if (curr === prev || curr === null || curr === undefined) return null; + return String(curr); + }, + apply: (obj, val) => obj.set(name, val), + lerp: null, + format: (val) => `=${val}`, + parse: (str) => { + const s = String(str).trim(); + return { abs: s.startsWith('=') ? s.slice(1) : s }; + }, + }); + + // Color lerp in RGB space + const hexToRgb = (hex) => { + const h = hex.replace(/^#/, ''); + const n = parseInt(h, 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + }; + const rgbToHex = ([r, g, b]) => + '#' + [r, g, b].map(v => Math.round(Math.max(0, Math.min(255, v))) + .toString(16).padStart(2, '0')).join(''); + const lerpColor = (a, b, t) => { + if (!a || !b) return b || a; + // Handle 'transparent' special value + const aStr = a instanceof Color ? a.toString() : a; + const bStr = b instanceof Color ? b.toString() : b; + if (aStr === 'transparent' || bStr === 'transparent') return t < 0.5 ? aStr : bStr; + const [ar, ag, ab2] = hexToRgb(aStr); + const [br, bg, bb] = hexToRgb(bStr); + return rgbToHex([ar + (br - ar) * t, ag + (bg - ag) * t, ab2 + (bb - ab2) * t]); + }; + + const registerColor = (name, objectType = 'graphic', description = '', examples = []) => registerAttribute(SCRIPT_NAME, { + name, namespace: 'core', objectType, + description: description || `Token ${name} (RGB hex color, lerps smoothly).`, + valueType: 'color', + examples: examples.length ? examples : [ + `=#ff0000 set ${name} to red`, + `=#000000 set ${name} to black`, + `=transparent clear ${name}`, + ], + startWatch: null, + stopWatch: null, + get: (obj) => obj.get(name), + set: (obj, val) => obj.set(name, val), + diff: (prev, curr) => { + if (curr === prev || curr === null || curr === undefined) return null; + return String(curr); + }, + apply: (obj, val) => obj.set(name, val), + lerp: (a, b, t) => lerpColor(a, b, t), + identity: (obj) => ({ abs: obj.get(name) }), + format: (val) => `=${val}`, + parse: (str) => { + const s = String(str).trim(); + return { abs: s.startsWith('=') ? s.slice(1) : s }; + }, + }); + + // ========================================================================= + // Core Attribute Registrations + // ========================================================================= + + // Scale (multiplicative ratio delta) + registerScale('width', 'graphic', 'Token width in pixels.'); + registerScale('height', 'graphic', 'Token height in pixels.'); + + // Numeric — position + registerNumeric('left', 'graphic', 'Horizontal position of token center in pixels (right = positive).', + ['+70 move right 70px', '-70 move left 70px', '+rand(-140,140) random horizontal wander']); + registerNumeric('top', 'graphic', 'Vertical position of token center in pixels (down = positive).', + ['+70 move down 70px', '-70 move up 70px', '+rand(-140,140) random vertical wander']); + + // Numeric — bars + ['bar1_value','bar2_value','bar3_value'].forEach(n => registerNumeric(n, 'graphic', `Value for ${n.replace('_value','')}.`)); + ['bar1_max','bar2_max','bar3_max'].forEach(n => registerNumeric(n, 'graphic', `Max value for ${n.replace('_max','')}.`)); + + // Numeric — auras and lighting + registerNumeric('aura1_radius', 'graphic', 'Radius of aura 1 in pixels.'); + registerNumeric('aura2_radius', 'graphic', 'Radius of aura 2 in pixels.'); + registerNumeric('light_radius', 'graphic', 'Bright light emission radius.'); + registerNumeric('light_dimradius', 'graphic', 'Dim light emission radius (negative = start of dim within bright radius).'); + registerNumeric('light_angle', 'graphic', 'Angle of emitted light cone in degrees.'); + registerNumeric('light_losangle', 'graphic', 'Angle of token line-of-sight cone in degrees.'); + registerNumeric('adv_fow_view_distance', 'graphic', 'Advanced fog of war view distance.'); + + // Rotation — special shortest-path delta logic + registerAttribute(SCRIPT_NAME, { + name: 'rotation', namespace: 'core', objectType: 'graphic', + description: 'Token rotation in degrees. Uses shortest-path interpolation — always takes the shorter arc.', + valueType: 'number', + examples: ['+90 rotate 90\u00b0 clockwise', '-90 rotate 90\u00b0 counter-clockwise', '+rand(-45,45) random wobble', '+360 full spin'], + startWatch: null, + stopWatch: null, + get: (obj) => obj.get('rotation'), + set: (obj, val) => obj.set('rotation', ((val % 360) + 360) % 360), + diff: (prev, curr) => { + const d = roundVal(((curr - prev) % 360 + 360) % 360); + if (d === 0) return null; + return d > 180 ? d - 360 : d; + }, + apply: (obj, delta) => { + const cur = obj.get('rotation') || 0; + obj.set('rotation', ((cur + delta) % 360 + 360) % 360); + }, + lerp: (a, b, t) => { + // Shortest-path lerp + let diff = ((b - a) % 360 + 360) % 360; + if (diff > 180) diff -= 360; + return ((a + diff * t) % 360 + 360) % 360; + }, + identity: () => ({ delta: 0 }), + format: (d) => d >= 0 ? `+${d}` : `${d}`, + parse: (str) => { + const s = String(str).trim(); + if (s.startsWith('=')) return { abs: parseFloat(s.slice(1)) }; + return { delta: parseFloat(s) }; + }, + }); + + // Boolean — visibility, flips, lighting flags + registerBoolean('flipv', 'graphic', 'Flip token vertically.'); + registerBoolean('fliph', 'graphic', 'Flip token horizontally.'); + registerBoolean('showname', 'graphic', 'Show token nameplate to all.'); + registerBoolean('showplayers_name', 'graphic', 'Show token name to players.'); + registerBoolean('playersedit_name', 'graphic', 'Allow players to edit token name.'); + registerBoolean('showplayers_bar1', 'graphic', 'Show bar 1 to players.'); + registerBoolean('playersedit_bar1', 'graphic', 'Allow players to edit bar 1.'); + registerBoolean('showplayers_bar2', 'graphic', 'Show bar 2 to players.'); + registerBoolean('playersedit_bar2', 'graphic', 'Allow players to edit bar 2.'); + registerBoolean('showplayers_bar3', 'graphic', 'Show bar 3 to players.'); + registerBoolean('playersedit_bar3', 'graphic', 'Allow players to edit bar 3.'); + registerBoolean('showplayers_aura1', 'graphic', 'Show aura 1 to players.'); + registerBoolean('playersedit_aura1', 'graphic', 'Allow players to edit aura 1.'); + registerBoolean('showplayers_aura2', 'graphic', 'Show aura 2 to players.'); + registerBoolean('playersedit_aura2', 'graphic', 'Allow players to edit aura 2.'); + registerBoolean('light_hassight', 'graphic', 'Token has line-of-sight.'); + registerBoolean('light_otherplayers', 'graphic', 'Token emits light visible to other players.'); + registerBoolean('light_followtoken', 'graphic', 'Dynamic lighting follows this token.'); + registerBoolean('isdrawing', 'graphic', 'Treat token as a drawing (not selectable by players).'); + registerBoolean('aura1_square', 'graphic', 'Display aura 1 as a square instead of circle.'); + registerBoolean('aura2_square', 'graphic', 'Display aura 2 as a square instead of circle.'); + + // Color + registerColor('tint_color', 'graphic', 'Tint color overlay on the token image.'); + registerColor('aura1_color', 'graphic', 'Color of aura 1.'); + registerColor('aura2_color', 'graphic', 'Color of aura 2.'); + registerColor('light_color', 'graphic', 'Color of emitted light.'); + + // Enum / String + registerEnum('layer', 'graphic', 'Map layer the token is on.', + ['objects','map','gm','walls'], ['=objects', '=map', '=gm', '=walls']); + registerEnum('name', 'graphic', 'Token display name.'); + registerEnum('gmnotes', 'graphic', 'GM-only notes on the token.'); + registerEnum('tooltip', 'graphic', 'Tooltip text shown on hover.'); + registerEnum('bar1_link', 'graphic', 'Character attribute linked to bar 1.'); + registerEnum('bar2_link', 'graphic', 'Character attribute linked to bar 2.'); + registerEnum('bar3_link', 'graphic', 'Character attribute linked to bar 3.'); + registerEnum('represents', 'graphic', 'Character ID this token represents.'); + registerEnum('controlledby','graphic', 'Comma-separated player IDs who control this token.'); + registerEnum('sides', 'graphic', 'Number of sides for a multi-sided token.'); + registerEnum('currentSide', 'graphic', 'Current visible side index for a multi-sided token.'); + + // imgsrc — special: must be a valid thumb URL from user's Roll20 library + registerAttribute(SCRIPT_NAME, { + name: 'imgsrc', namespace: 'core', objectType: 'graphic', + description: "Token image URL. Must be a thumb URL from the user's own Roll20 library.", + valueType: 'string', + examples: ['=https://s3.amazonaws.com/files.d20.io/images/.../thumb.png?...'], + startWatch: null, + stopWatch: null, + get: (obj) => obj.get('imgsrc'), + set: (obj, val) => obj.set('imgsrc', val), + diff: (prev, curr) => curr === prev ? null : String(curr), + apply: (obj, val) => obj.set('imgsrc', val), + lerp: null, + format: (val) => `=${val}`, + parse: (str) => { + const s = String(str).trim(); + return { abs: s.startsWith('=') ? s.slice(1) : s }; + }, + }); + + // ========================================================================= + // Easing Functions + // ========================================================================= + + const EASING = {}; + // EASING_NAMES is a getter so it always reflects newly registered easings + const EASING_NAMES = () => Object.keys(EASING); + // Easing registry — stores metadata for registered easings + const EASING_REGISTRY = {}; + + /** + * Register a custom easing function. + * Easing names may contain hyphens since they are handout cell values, + * not JS identifiers. + * + * Struct fields: + * name {string} - identifier (may contain hyphens, no dots) + * fn {Function} - (t, ...args) => value where t is 0-1 + * description {string} - one-line description + * args {Array} - [{ name, type, description, optional }] for params + * examples {string[]} - example usage strings + */ + const registerEasing = (sourceId, struct) => { + const src = sourceId || SCRIPT_NAME; + const { name, fn, description = '', examples = [], args = [], label = null } = struct; + + if (!name || !/^[A-Za-z][A-Za-z0-9_-]*$/.test(name)) { + log(`${SCRIPT_NAME}: [${src}] registerEasing — invalid name "${name}"`); + return false; + } + if (EASING_REGISTRY[name]) { + const existing = EASING_REGISTRY[name].source || SCRIPT_NAME; + if (existing !== src) + log(`${SCRIPT_NAME}: [${src}] registerEasing — "${name}" is already registered by [${existing}]`); + return false; + } + if (typeof fn !== 'function') { + log(`${SCRIPT_NAME}: [${src}] registerEasing — "${name}" missing fn`); + return false; + } + + // Warn if promptDefault args appear after non-promptDefault args that + // also lack a default value — the prompt pre-fill would be silently skipped + if (args.length > 0) { + const firstNonPrompt = args.findIndex(a => a.promptDefault === undefined); + if (firstNonPrompt !== -1) { + const hasPromptAfter = args.slice(firstNonPrompt).some(a => a.promptDefault !== undefined); + if (hasPromptAfter) { + log(`${SCRIPT_NAME}: [${src}] registerEasing — "${name}" has promptDefault args after non-promptDefault args; prompt pre-fill will be disabled`); + } + const nonPromptWithoutDefault = args.slice(firstNonPrompt).filter(a => a.promptDefault === undefined && a.default === undefined); + if (nonPromptWithoutDefault.length > 0 && args.some(a => a.promptDefault !== undefined)) { + log(`${SCRIPT_NAME}: [${src}] registerEasing — "${name}" has promptDefault args but trailing args [${nonPromptWithoutDefault.map(a => a.name).join(', ')}] have no default; prompt pre-fill will be disabled`); + } + } + } + + EASING[name] = fn; + EASING_REGISTRY[name] = { name, namespace: 'core', source: src, description, args, examples, label, fn }; + return true; + }; + + // ── Register built-in easing curves (bare names, naturally accelerating) ─── + // Core curves clamp output to [0,1]. External curves may overshoot/undershoot. + (() => { + const clamp01 = (fn) => (x) => Math.max(0, Math.min(1, fn(x))); + const p = (x, n) => Math.pow(x, n); + registerEasing(SCRIPT_NAME, { name: 'linear', fn: clamp01((x) => x), description: 'Constant rate — no easing.' }); + registerEasing(SCRIPT_NAME, { name: 'step', fn: (x) => x < 1 ? 0 : 1, description: 'Instant jump at the end of the segment. Only core curve with intentional discontinuity.' }); + registerEasing(SCRIPT_NAME, { name: 'sine', fn: clamp01((x) => 1 - Math.cos((x * Math.PI) / 2)), description: 'Sinusoidal acceleration. Use ~sine for ease-out.' }); + registerEasing(SCRIPT_NAME, { name: 'quad', fn: clamp01((x) => p(x, 2)), description: 'Quadratic acceleration (x²). Use ~quad for ease-out.' }); + registerEasing(SCRIPT_NAME, { name: 'cubic', fn: clamp01((x) => p(x, 3)), description: 'Cubic acceleration (x³). Use ~cubic for ease-out.' }); + registerEasing(SCRIPT_NAME, { name: 'quart', fn: clamp01((x) => p(x, 4)), description: 'Quartic acceleration (x⁴). Use ~quart for ease-out.' }); + registerEasing(SCRIPT_NAME, { name: 'quint', fn: clamp01((x) => p(x, 5)), description: 'Quintic acceleration (x⁵). Use ~quint for ease-out.' }); + registerEasing(SCRIPT_NAME, { name: 'expo', fn: clamp01((x) => x === 0 ? 0 : Math.pow(2, 10 * x - 10)), description: 'Exponential acceleration. Use ~expo for ease-out.' }); + registerEasing(SCRIPT_NAME, { name: 'circle', fn: clamp01((x) => 1 - Math.sqrt(1 - p(x, 2))), description: 'Circular acceleration. Use ~circle for ease-out.' }); + + // ── Parametric built-ins ────────────────────────────────────────────── + registerEasing(SCRIPT_NAME, { + name: 'power', + fn: clamp01((t, n = 2) => Math.pow(t, n)), + description: 'Generalized power curve. power(2) = quad, power(3) = cubic, etc.', + args: [{ name: 'n', type: 'number', description: 'Exponent', default: 2, promptDefault: 2 }], + examples: ['power(2)', 'power(5)', '~power(3)', '~power(0.5)'], + }); + + // Cubic bezier — matches CSS cubic-bezier(x1,y1,x2,y2) + registerEasing(SCRIPT_NAME, { + name: 'bezier', + fn: (t, x1 = 0.25, y1 = 0.1, x2 = 0.25, y2 = 1.0) => { + // Solve for t parameter via Newton's method, then evaluate y + const cx = 3 * x1, bx = 3 * (x2 - x1) - cx, ax = 1 - cx - bx; + const cy = 3 * y1, by = 3 * (y2 - y1) - cy, ay = 1 - cy - by; + const sampleX = (t) => ((ax * t + bx) * t + cx) * t; + const sampleY = (t) => ((ay * t + by) * t + cy) * t; + const sampleDerivX = (t) => (3 * ax * t + 2 * bx) * t + cx; + // Newton's method to find parameter u where sampleX(u) = t + let u = t; + for (let i = 0; i < 8; i++) { + const err = sampleX(u) - t; + if (Math.abs(err) < 1e-7) break; + const d = sampleDerivX(u); + if (Math.abs(d) < 1e-6) break; + u -= err / d; + } + return sampleY(Math.max(0, Math.min(1, u))); + }, + description: 'CSS cubic-bezier curve. bezier(x1,y1,x2,y2) matches CSS cubic-bezier().', + args: [ + { name: 'x1', type: 'number', description: 'Control point 1 x (0-1)', default: 0.25, promptDefault: 0.25 }, + { name: 'y1', type: 'number', description: 'Control point 1 y', default: 0.1, promptDefault: 0.1 }, + { name: 'x2', type: 'number', description: 'Control point 2 x (0-1)', default: 0.25, promptDefault: 0.25 }, + { name: 'y2', type: 'number', description: 'Control point 2 y', default: 1.0, promptDefault: 1.0 }, + ], + examples: [ + 'bezier(0.25,0.1,0.25,1) — CSS ease', + 'bezier(0.42,0,1,1) — CSS ease-in', + 'bezier(0,0,0.58,1) — CSS ease-out', + 'bezier(0.42,0,0.58,1) — CSS ease-in-out', + ], + }); + })(); + + // Wrap any easing fn to guarantee f(0)=0 and f(1)=1 — endpoints always hit exactly + const pin01 = (fn) => (t) => t === 0 ? 0 : t === 1 ? 1 : fn(t); + + /** + * Parse an easing expression: bare name or ~name (reversed). + * + * name — bare curve + * ~name — reversed curve: 1 - f(1-t), ease-out transform + * + * Returns a function (t: 0-1) => value, or EASING['linear'] if invalid/empty. + */ + // Parse an easing cell value: name, ~name, name(args), ~name(args) + // Returns { name, reversed, args } or null if unparseable + const parseEasingToken = (s) => { + s = s.trim(); + const reversed = s.startsWith('~'); + const rest = reversed ? s.slice(1).trim() : s; + const parenIdx = rest.indexOf('('); + if (parenIdx === -1) { + // bare name + return { name: rest, reversed, args: [] }; + } + const name = rest.slice(0, parenIdx).trim(); + const argStr = rest.slice(parenIdx + 1); + if (!argStr.endsWith(')')) return null; + const inner = argStr.slice(0, -1).trim(); + const args = inner === '' ? [] : inner.split(',').map(a => { + const v = parseFloat(a.trim()); + return isNaN(v) ? null : v; + }); + if (args.some(a => a === null)) return null; + return { name, reversed, args }; + }; + + const parseEasingExpr = (expr) => { + if (!expr || !expr.trim()) return EASING['linear']; + const token = parseEasingToken(expr.trim()); + if (!token) return EASING['linear']; + const fn = EASING[token.name]; + if (!fn) return EASING['linear']; + // Incomplete parametric (no args yet) — fall back to linear + const reg = EASING_REGISTRY[token.name]; + if (reg && reg.args && reg.args.length > 0 && token.args.length === 0) return EASING['linear']; + const base = token.args.length > 0 + ? (t) => fn(t, ...token.args) + : fn; + const withReverse = token.reversed ? (t) => 1 - base(1 - t) : base; + return pin01(withReverse); + }; + + const validateEasingExpr = (expr) => { + if (!expr || !expr.trim()) return null; + if (expr.trim() === 'continuous') return null; // special keyword — not a curve + const token = parseEasingToken(expr.trim()); + if (!token) return `Could not parse easing expression: "${expr}"`; + if (!EASING[token.name]) return `Unknown easing curve: "${token.name}"`; + const reg = EASING_REGISTRY[token.name]; + if (reg && reg.args && reg.args.length > 0) { + if (token.args.length === 0) return null; // incomplete but valid — shown as name([...]) + const required = reg.args.filter(a => !a.optional && a.default === undefined).length; + if (token.args.length < required) { + return `${token.name} expects at least ${required} argument(s), got ${token.args.length}`; + } + } + return null; + }; + + const isIncompleteParametric = (expr) => { + if (!expr) return false; + const token = parseEasingToken(expr.trim()); + if (!token) return false; + const reg = EASING_REGISTRY[token.name]; + return !!(reg && reg.args && reg.args.length > 0 && token.args.length === 0); + }; + + const getEasing = (expr) => parseEasingExpr(expr) || EASING['linear']; + + // ========================================================================= + // State helpers + // ========================================================================= + + const s = () => state[SCRIPT_NAME]; + + // In-memory cache of parsed recordings (populated on first play/edit access) + // { '': { name, duration, objectType, tracks: {...} } } + const recordingCache = {}; + + // In-memory active recording sessions + // { '': sessionObject } + const activeSessions = {}; + + // In-memory active playback state + // { '': playbackObject } + const activePlayback = {}; + + // In-memory interval IDs for playback + // { '': intervalId } + const playbackIntervals = {}; + + // ========================================================================= + // Handout helpers + // ========================================================================= + + const HANDOUT_NAME = (name) => `${HANDOUT_PREFIX}${name}`; + + const findHandout = (name) => { + const results = findObjs({ _type: 'handout', name: HANDOUT_NAME(name) }); + return results.length > 0 ? results[0] : undefined; + }; + + const findAllRecordingHandouts = () => + findObjs({ _type: 'handout' }) + .filter(h => h.get('name').startsWith(HANDOUT_PREFIX)); + + const getOrCreateHandout = (name) => { + const existing = findHandout(name); + if (existing) return existing; + return createObj('handout', { + name: HANDOUT_NAME(name), + inplayerjournals: '', // GM only + archived: false, + }); + }; + + // Roll20 handout notes are async — use a callback pattern + const getHandoutNotes = (handout, callback) => { + handout.get('notes', (notes) => callback(notes || '')); + }; + + const setHandoutNotes = (handout, html, recName) => { + if (recName) { + handoutWriting.add(recName); + setTimeout(() => handoutWriting.delete(recName), 500); + } + handout.set('notes', html); + }; + + // ========================================================================= + // Handout HTML generation + // ========================================================================= + + const STYLE = { + table: 'border-collapse:collapse;width:100%;font-size:12px;', + th: 'background:#222;color:#fff;padding:3px 6px;border:1px solid #555;white-space:nowrap;', + td: 'padding:2px 5px;border:1px solid #ccc;', + tdAlt: 'padding:2px 5px;border:1px solid #ccc;background:#f9f9f9;', + meta: 'font-family:monospace;font-size:12px;margin-bottom:8px;', + btn: 'display:inline-block;margin:2px;padding:2px 8px;background:#444;color:#fff;' + + 'border-radius:3px;text-decoration:none;font-size:11px;', + warn: 'color:#c00;font-size:11px;', + }; + + /** + * Generate the full handout HTML for a recording. + * @param {string} name Recording name + * @param {object} recording Parsed recording object + * @param {string[]} attrCols Ordered list of attribute column names to show + */ + // ========================================================================= + // Expression Evaluator + // ========================================================================= + + // Allowed identifiers in expressions — anything else is rejected at parse time + // Function registry — keyed by namespace/name + const FN_REGISTRY = {}; // value expression functions + const TIME_FN_REGISTRY = {}; // time expression functions + + // Value expression scope — full context (orig, prev, curr, obj, cumulative) + const EXPR_SCOPE = {}; + // Time expression scope — only prev and cumulative meaningful + const TIME_EXPR_SCOPE = {}; + + // Top-level identifiers allowed in value expressions + const EXPR_ALLOWED_VARS = new Set(['orig', 'original', 'prev', 'previous', 'curr', 'current', 't']); + const EXPR_ALLOWED_ROOTS = new Set([...EXPR_ALLOWED_VARS]); + // Top-level identifiers allowed in time expressions (only prev) + const TIME_ALLOWED_ROOTS = new Set(['prev', 'previous']); + + // ========================================================================= + // Color Class + // ========================================================================= + + class Color { + constructor(r, g, b) { + this.r = Math.round(Math.max(0, Math.min(255, r))); + this.g = Math.round(Math.max(0, Math.min(255, g))); + this.b = Math.round(Math.max(0, Math.min(255, b))); + } + + toHex() { + return '#' + [this.r, this.g, this.b] + .map(v => v.toString(16).padStart(2, '0')).join(''); + } + + toHsl() { + const r = this.r / 255, g = this.g / 255, b = this.b / 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + const l = (max + min) / 2; + if (max === min) return { h: 0, s: 0, l: l * 100 }; + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h; + if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6; + else if (max === g) h = ((b - r) / d + 2) / 6; + else h = ((r - g) / d + 4) / 6; + return { h: h * 360, s: s * 100, l: l * 100 }; + } + + // Roll20 stores colors as hex strings — toString enables automatic coercion + toString() { return this._transparent ? 'transparent' : this.toHex(); } + + get isTransparent() { return this._transparent; } + + static fromHex(hex) { + if (!hex || hex === 'transparent') return null; + const h = hex.replace(/^#/, ''); + const n = parseInt(h.length === 3 + ? h.split('').map(c => c + c).join('') : h, 16); + return new Color((n >> 16) & 255, (n >> 8) & 255, n & 255); + } + + static fromHsl(h, s, l) { + h = ((h % 360) + 360) % 360; + s = Math.max(0, Math.min(100, s)) / 100; + l = Math.max(0, Math.min(100, l)) / 100; + const c = (1 - Math.abs(2 * l - 1)) * s; + const x = c * (1 - Math.abs((h / 60) % 2 - 1)); + const m = l - c / 2; + let r = 0, g = 0, b = 0; + if (h < 60) { r = c; g = x; } + else if (h < 120) { r = x; g = c; } + else if (h < 180) { g = c; b = x; } + else if (h < 240) { g = x; b = c; } + else if (h < 300) { r = x; b = c; } + else { r = c; b = x; } + return new Color((r + m) * 255, (g + m) * 255, (b + m) * 255); + } + } + + // Sentinel transparent color instance + Color.transparent = Object.assign(new Color(0, 0, 0), { _transparent: true }); + + // Coerce a value to Color — accepts Color instance, hex string, or null + const toColor = (v) => { + if (v instanceof Color) return v; + if (v === 'transparent') return Color.transparent; + if (typeof v === 'string') return Color.fromHex(v); + return null; + }; + + /** + * Validate an expression string. Returns null if valid, error string if not. + * Strips known-safe tokens and rejects anything left over. + */ + const validateExpr = (expr) => { + // Replace numbers and operators with spaces so identifiers don't + // concatenate — e.g. 'rand(orig-50,orig+50)' → 'rand orig orig ' + let spaced = expr + // Full numeric literal pattern: + // optional sign, then: int, float, .float, or scientific (1e5, 2.5e-3, .5e+10) + .replace(/[+\-]?(\d+\.?\d*|\.\d+)([eE][+\-]?\d+)?/g, ' ') + .replace(/[+\-*/%,()<>=?:\s]+/g, ' ') // operators/punct → space + .replace(/\.\s/g, ' ') // orphaned dots → space + .trim(); + + // Extract dotted identifier chains and validate root is known + const identChainRe = /[A-Za-z_][A-Za-z0-9_.]*/g; + const unknowns = []; + let m; + while ((m = identChainRe.exec(spaced)) !== null) { + // Strip any trailing dot from the match + const chain = m[0].replace(/\.$/, ''); + const root = chain.split('.')[0]; + if (!EXPR_ALLOWED_ROOTS.has(root)) unknowns.push(chain); + } + return unknowns.length > 0 + ? `Unknown identifier(s): ${unknowns.join(', ')}` + : null; + }; + + /** + * Built-in expression functions available at eval time. + */ + // EXPR_SCOPE — nested object tree used by evalExpr; populated by registerPlaybackFunction + + // Insert a function into the nested scope tree + const insertIntoScope = (scope, namespace, name, fn) => { + const parts = namespace === 'core' ? [] : namespace.split('.'); + let node = scope; + parts.forEach(p => { node[p] = node[p] || {}; node = node[p]; }); + node[name] = fn; + }; + + const evalExpr = (expr, origVal, prevVal, currVal, context) => { + // For color attributes, wrap string values as Color instances so + // color functions can operate on them directly + const _wrapVal = (v) => { + if (v instanceof Color) return v; + if (v === 'transparent') return Color.transparent; + if (typeof v === 'string' && v.startsWith('#')) return Color.fromHex(v); + return v; + }; + const _orig = _wrapVal(origVal), _prev = _wrapVal(prevVal); + + // Lazy curr — fetched at most once, only if actually referenced. + // Both the _curr variable and ctx.curr share the same fetcher state + // so they always return the same value and we never fetch twice. + let _currFetched = false; + let _currCache; + const _getCurr = () => { + if (!_currFetched) { + _currFetched = true; + _currCache = (currVal !== undefined) ? currVal + : (context && context.obj && context.reg) + ? (context.reg.get(context.obj) || 0) + : prevVal; + } + return _currCache; + }; + + const _t = context && context.t !== undefined ? context.t : 0; + + const body = expr + .replace(/\boriginal\b/g, '_orig') + .replace(/\bprevious\b/g, '_prev') + .replace(/\bcurrent\b/g, '_getCurr()') + .replace(/\borig\b/g, '_orig') + .replace(/\bprev\b/g, '_prev') + .replace(/\bcurr\b/g, '_getCurr()') + .replace(/\bt\b/g, '_t'); + + const _ctx = { + obj: context ? context.obj : null, + orig: origVal, + prev: prevVal, + get curr() { return _getCurr(); }, // shares lazy state + cumulative: context ? (context.cumulative || {}) : {}, + }; + + // Build a context-injecting wrapper around the scope so every function + // receives ctx as its final argument regardless of how it's called. + // Core functions live as flat properties; namespaced functions live + // under their namespace chain for natural dot-access in eval. + // + // Memoization: discrete functions are memoized per call-site within a + // segment. In a non-continuous segment, ALL functions are memoized. + // In a continuous segment, only discrete functions are memoized. + const _memoCache = context && context.memo ? context.memo : null; + const _isContinuousSeg = !!(context && context.isContinuousSegment); + var _callCounter = 0; + + const _wrapNode = function(node, regKey) { + if (typeof node === 'function') { + var reg = FN_REGISTRY[regKey] || null; + var isImpure = !!(reg && !reg.pure); + var isFreeze = regKey === 'core/freeze'; + // Memoize if: freeze (always), OR impure function in non-continuous segment + var shouldMemo = _memoCache && (isFreeze || (isImpure && !_isContinuousSeg)); + + if (shouldMemo) { + return function() { + var idx = _callCounter++; + var key = (regKey || 'fn') + ':' + idx; + if (key in _memoCache) return _memoCache[key]; + var args = Array.prototype.slice.call(arguments); + var result = node.apply(null, [_ctx].concat(args)); + _memoCache[key] = result; + return result; + }; + } + return function() { + _callCounter++; + var args = Array.prototype.slice.call(arguments); + return node.apply(null, [_ctx].concat(args)); + }; + } + if (node === null || typeof node !== 'object') return node; + var wrapped = {}; + Object.keys(node).forEach(function(k) { + var childKey = regKey ? regKey + '/' + k : 'core/' + k; + wrapped[k] = _wrapNode(node[k], childKey); + }); + return wrapped; + }; + var _wrapped = _wrapNode(EXPR_SCOPE, ''); + + // Declare each top-level name as a local var so eval can access it. + // Core functions become flat locals (rand, clamp, etc.). + // Namespace roots become objects (anchor, mymod, etc.). + var _scopeDecls = Object.keys(_wrapped).map(function(k) { + return 'var ' + k + ' = _wrapped["' + k + '"];'; + }).join(' '); + + const _result = eval(_scopeDecls + '(' + body + ')'); + // Coerce Color instances to strings so Roll20 attribute setters work + return _result instanceof Color ? _result.toString() : _result; + }; + + /** + * Evaluate a time expression — like evalExpr but uses TIME_EXPR_SCOPE. + * Only prev is available as a variable; result must be a number (ms). + */ + const evalTimeExpr = (expr, prevVal) => { + const _prev = prevVal; + + // Normalise prev aliases + const body = expr + .replace(/\bprevious\b/g, '_prev') + .replace(/\bprev\b/g, '_prev'); + + const _ctx = { prev: prevVal, cumulative: {} }; + + const _wrapNode = function(node) { + if (typeof node === 'function') { + return function() { + var args = Array.prototype.slice.call(arguments); + return node.apply(null, [_ctx].concat(args)); + }; + } + if (typeof node === 'number') return node; // constants + var wrapped = {}; + Object.keys(node).forEach(function(k) { + wrapped[k] = _wrapNode(node[k]); + }); + return wrapped; + }; + var _wrapped = _wrapNode(TIME_EXPR_SCOPE); + + var _scopeDecls = Object.keys(_wrapped).map(function(k) { + return 'var ' + k + ' = _wrapped["' + k + '"];'; + }).join(' '); + + const _result = eval(_scopeDecls + '(' + body + ')'); + if (typeof _result !== 'number' || !isFinite(_result)) { + throw new Error(`time expression must return a finite number, got: ${_result}`); + } + return _result; + }; + + /** + * Validate a time expression string. + * Returns null if valid, error string if not. + */ + const validateTimeExpr = (expr) => { + if (!expr || !expr.trim()) return null; + let spaced = expr + .replace(/[+\-]?(\d+\.?\d*|\.\d+)([eE][+\-]?\d+)?/g, ' ') + .replace(/[+\-*/%,()<>=?:\s]+/g, ' ') + .replace(/\.\s/g, ' ') + .trim(); + const identChainRe = /[A-Za-z_][A-Za-z0-9_.]*/g; + const unknowns = []; + let m; + while ((m = identChainRe.exec(spaced)) !== null) { + const chain = m[0].replace(/\.$/, ''); + const root = chain.split('.')[0]; + if (!TIME_ALLOWED_ROOTS.has(root)) unknowns.push(chain); + } + return unknowns.length > 0 + ? `Unknown identifier(s) in time expression: ${unknowns.join(', ')}` + : null; + }; + + /** + * Register a playback function for use in keyframe expressions. + * Accepts either a struct or (name, fn) shorthand for quick registration. + * + * Struct fields: + * name {string} - identifier (required) + * namespace {string} - 'core' or external namespace (default: 'core') + * fn {Function} - implementation (required) + * description {string} - one-line description + * args {Array} - [{ name, type, description, optional }] + * returns {string} - return type description + * examples {Array} - example usage strings + * + * External functions use namespace.name() syntax in expressions. + * Core functions can be called without namespace prefix. + */ + // Shared validation and registration logic for both value and timing functions + const _registerFn = (sourceId, structOrFn, fn, scope, registry, allowedRoots, label) => { + const src = sourceId || SCRIPT_NAME; + const reg = typeof structOrFn === 'string' + ? { name: structOrFn, fn, namespace: 'core' } + : structOrFn; + + const { name, namespace = 'core' } = reg; + + if (!validIdent(name)) { + log(`${SCRIPT_NAME}: [${src}] ${label} — invalid name "${name}"`); + return false; + } + if (!validNamespace(namespace)) { + log(`${SCRIPT_NAME}: [${src}] ${label} — invalid namespace "${namespace}"`); + return false; + } + const qualifiedName = namespace === 'core' ? name : `${namespace}.${name}`; + const nsParts = namespace === 'core' ? [] : namespace.split('.'); + + // Check for duplicate + let checkNode = scope; + nsParts.forEach(p => { checkNode = checkNode && checkNode[p]; }); + if (checkNode && checkNode[name]) { + const existing = (registry[`${namespace}/${name}`] || {}).source || SCRIPT_NAME; + if (existing !== src) + log(`${SCRIPT_NAME}: [${src}] ${label} — "${qualifiedName}" already registered by [${existing}]`); + return false; + } + + reg.description = reg.description || ''; + reg.args = reg.args || []; + reg.returns = reg.returns || 'number'; + reg.examples = reg.examples || []; + reg.pure = reg.pure !== undefined ? reg.pure : true; + reg.source = src; + + insertIntoScope(scope, namespace, name, reg.fn); + const rootName = nsParts.length > 0 ? nsParts[0] : name; + allowedRoots.add(rootName); + + registry[`${namespace}/${name}`] = reg; + return true; + }; + + /** + * Register a function for use in value/delta expressions. + * Context: (ctx, ...args) where ctx has orig, prev, curr, obj, cumulative. + */ + const registerValueFunction = (sourceId, structOrFn, fn) => + _registerFn(sourceId, structOrFn, fn, EXPR_SCOPE, FN_REGISTRY, EXPR_ALLOWED_ROOTS, 'registerValueFunction'); + + /** + * Register a function for use in time expressions. + * Context: (ctx, ...args) where ctx has only prev and cumulative. + * Must return a number (milliseconds). + */ + const registerTimingFunction = (sourceId, structOrFn, fn) => + _registerFn(sourceId, structOrFn, fn, TIME_EXPR_SCOPE, TIME_FN_REGISTRY, TIME_ALLOWED_ROOTS, 'registerTimingFunction'); + + // ========================================================================= + // Constant Registry + // ========================================================================= + + const CONST_REGISTRY = {}; + + /** + * Register a named constant for use in expressions. + * Constants live in EXPR_SCOPE under their namespace like functions. + * + * Struct fields: + * name {string} - identifier (required) + * namespace {string} - namespace (default: 'core') + * value {any} - the constant value (required) + * description {string} - one-line description + * type {string} - value type name for display + */ + const registerPlaybackConstant = (sourceId, reg) => { + const src = sourceId || SCRIPT_NAME; + + if (typeof reg === 'object' && !reg.name) { + log(`${SCRIPT_NAME}: [${src}] registerPlaybackConstant — missing name`); + return false; + } + const namespace = reg.namespace || 'core'; + const { name, value, description = '', type = typeof value } = reg; + const contexts = reg.contexts || ['value', 'time']; + + if (!validIdent(name)) { + log(`${SCRIPT_NAME}: [${src}] registerPlaybackConstant — invalid name "${name}" (must be a valid identifier with no dots)`); + return false; + } + if (!validNamespace(namespace)) { + log(`${SCRIPT_NAME}: [${src}] registerPlaybackConstant — invalid namespace "${namespace}"`); + return false; + } + + const key = `${namespace}/${name}`; + if (CONST_REGISTRY[key]) { + const existing = (CONST_REGISTRY[key] || {}).source || SCRIPT_NAME; + if (existing !== src) + log(`${SCRIPT_NAME}: [${src}] registerPlaybackConstant — "${namespace}.${name}" is already registered by [${existing}]`); + return false; + } + + // Insert into value scope + insertIntoScope(EXPR_SCOPE, namespace, name, value); + const nsParts2 = namespace === 'core' ? [] : namespace.split('.'); + const rootName = nsParts2.length > 0 ? nsParts2[0] : name; + EXPR_ALLOWED_ROOTS.add(rootName); + + // Insert numeric constants into time scope too + if (typeof value === 'number') { + insertIntoScope(TIME_EXPR_SCOPE, namespace, name, value); + TIME_ALLOWED_ROOTS.add(rootName); + } + + CONST_REGISTRY[key] = { name, namespace, value, description, type, contexts, source: src }; + return true; + }; + + // Register all core built-in functions with full struct + [ + { + name: 'rand', namespace: 'core', + description: 'Returns a uniformly distributed random number between min (inclusive) and max (exclusive).', + pure: false, + args: [ + { name: 'min', type: 'number', description: 'Lower bound (inclusive)' }, + { name: 'max', type: 'number', description: 'Upper bound (exclusive)' }, + ], + returns: 'number', + examples: ['+rand(-140,140) random delta ±140', '=rand(orig-50,orig+50) random near origin'], + fn: (ctx, min, max) => min + Math.random() * (max - min), + }, + { + name: 'randInt', namespace: 'core', + description: 'Returns a random integer between min and max (both inclusive).', + pure: false, + args: [ + { name: 'min', type: 'number', description: 'Lower bound (inclusive)' }, + { name: 'max', type: 'number', description: 'Upper bound (inclusive)' }, + ], + returns: 'integer', + examples: ['+randInt(-3,3) random integer step'], + fn: (ctx, min, max) => Math.floor(min + Math.random() * (max - min + 1)), + }, + { + name: 'pick', namespace: 'core', + description: 'Returns one of the provided values chosen uniformly at random.', + pure: false, + args: [{ name: '...values', type: 'any', description: 'Values to pick from' }], + returns: 'any', + examples: ['=pick(0,90,180,270) random cardinal rotation'], + fn: (ctx, ...args) => args[Math.floor(Math.random() * args.length)], + }, + { + name: 'freeze', namespace: 'core', + description: 'Memoizes its argument — evaluates once per segment, returns the cached value on subsequent ticks. Use in continuous easing to stabilize non-deterministic values.', + args: [{ name: 'value', type: 'any', description: 'Value to freeze' }], + returns: 'any', + examples: ['=orig + freeze(rand(-50,50)) + cos(t * TAU) * 140 stable random offset with continuous orbit'], + fn: (ctx, val) => val, + }, + { + name: 'clamp', namespace: 'core', + description: 'Clamps value to the range [lo, hi].', + args: [ + { name: 'value', type: 'number', description: 'Value to clamp' }, + { name: 'lo', type: 'number', description: 'Minimum' }, + { name: 'hi', type: 'number', description: 'Maximum' }, + ], + returns: 'number', + examples: ['=clamp(prev+rand(-50,50),orig-200,orig+200) clamped wander'], + fn: (ctx, v, lo, hi) => Math.min(Math.max(v, lo), hi), + }, + { name:'abs', namespace:'core', description:'Absolute value.', args:[{name:'x',type:'number'}], returns:'number', examples:['=abs(prev)'], fn: (ctx, x) => Math.abs(x) }, + { name:'round', namespace:'core', description:'Round to nearest integer.',args:[{name:'x',type:'number'}], returns:'integer',examples:['=round(prev+0.5)'], fn: (ctx, x) => Math.round(x) }, + { name:'floor', namespace:'core', description:'Round down to integer.', args:[{name:'x',type:'number'}], returns:'integer',examples:['=floor(rand(0,4))'], fn: (ctx, x) => Math.floor(x) }, + { name:'ceil', namespace:'core', description:'Round up to integer.', args:[{name:'x',type:'number'}], returns:'integer',examples:['=ceil(rand(0,4))'], fn: (ctx, x) => Math.ceil(x) }, + { name:'min', namespace:'core', description:'Minimum of two or more values.', args:[{name:'...values',type:'number'}], returns:'number', examples:['=min(prev+10,orig+100)'], fn: (ctx, ...args) => Math.min(...args) }, + { name:'max', namespace:'core', description:'Maximum of two or more values.', args:[{name:'...values',type:'number'}], returns:'number', examples:['=max(prev-10,orig-100)'], fn: (ctx, ...args) => Math.max(...args) }, + { name:'sqrt', namespace:'core', description:'Square root.', args:[{name:'x',type:'number'}], returns:'number', examples:['=sqrt(prev)'], fn: (ctx, x) => Math.sqrt(x) }, + { name:'pow', namespace:'core', description:'x raised to the power y.', args:[{name:'x',type:'number'},{name:'y',type:'number'}], returns:'number', examples:['=pow(2,3)'], fn: (ctx, x, y) => Math.pow(x, y) }, + { name:'sin', namespace:'core', description:'Sine of x (radians).', args:[{name:'x',type:'number'}], returns:'number', examples:['=sin(orig)*50 oscillate ±50 from origin'], fn: (ctx, x) => Math.sin(x) }, + { name:'cos', namespace:'core', description:'Cosine of x (radians).', args:[{name:'x',type:'number'}], returns:'number', examples:['=cos(orig)*50 oscillate ±50 from origin'], fn: (ctx, x) => Math.cos(x) }, + { name:'tan', namespace:'core', description:'Tangent of x (radians).', args:[{name:'x',type:'number'}], returns:'number', examples:[], fn: (ctx, x) => Math.tan(x) }, + { name:'log', namespace:'core', description:'Natural logarithm.', args:[{name:'x',type:'number'}], returns:'number', examples:[], fn: (ctx, x) => Math.log(x) }, + { name:'exp', namespace:'core', description:'e raised to the power x.',args:[{name:'x',type:'number'}], returns:'number', examples:[], fn: (ctx, x) => Math.exp(x) }, + ].forEach(reg => { + registerValueFunction(SCRIPT_NAME, reg); + registerTimingFunction(SCRIPT_NAME, reg); + }); + + // ── Color functions ─────────────────────────────────────────────────────── + [ + { + name: 'rgb', namespace: 'color', contexts: ['value'], + description: 'Creates a Color from red, green, blue components (0-255 each).', + args: [ + { name: 'r', type: 'number', description: 'Red (0-255)' }, + { name: 'g', type: 'number', description: 'Green (0-255)' }, + { name: 'b', type: 'number', description: 'Blue (0-255)' }, + ], + returns: 'Color', + examples: ['=color.rgb(255,0,0) red', '=color.rgb(rand(0,255),rand(0,255),rand(0,255)) random color'], + fn: (ctx, r, g, b) => new Color(r, g, b), + }, + { + name: 'hsl', namespace: 'color', contexts: ['value'], + description: 'Creates a Color from hue (0-360°), saturation (0-100%), lightness (0-100%).', + args: [ + { name: 'h', type: 'number', description: 'Hue in degrees (0-360, wraps)' }, + { name: 's', type: 'number', description: 'Saturation percent (0-100)' }, + { name: 'l', type: 'number', description: 'Lightness percent (0-100)' }, + ], + returns: 'Color', + examples: ['=color.hsl(rand(0,360),100,50) random vivid color', '=color.hsl(0,100,50) red'], + fn: (ctx, h, s, l) => Color.fromHsl(h, s, l), + }, + { + name: 'rotateHue', namespace: 'color', contexts: ['value'], + description: 'Rotates the hue of a color by the given degrees.', + args: [ + { name: 'color', type: 'Color', description: 'Base color' }, + { name: 'degrees', type: 'number', description: 'Degrees to rotate hue' }, + ], + returns: 'Color', + examples: ['=color.rotateHue(orig,30) shift hue 30°', '=color.rotateHue(orig,rand(-60,60)) random hue shift'], + fn: (ctx, c, degrees) => { + const col = toColor(c); + if (!col) return c; + const { h, s, l } = col.toHsl(); + return Color.fromHsl(h + degrees, s, l); + }, + }, + { + name: 'darken', namespace: 'color', contexts: ['value'], + description: 'Darkens a color by reducing lightness by the given percentage points.', + args: [ + { name: 'color', type: 'Color', description: 'Base color' }, + { name: 'amount', type: 'number', description: 'Lightness reduction (0-100)' }, + ], + returns: 'Color', + examples: ['=color.darken(orig,20) 20% darker'], + fn: (ctx, c, amount) => { + const col = toColor(c); + if (!col) return c; + const { h, s, l } = col.toHsl(); + return Color.fromHsl(h, s, l - amount); + }, + }, + { + name: 'lighten', namespace: 'color', contexts: ['value'], + description: 'Lightens a color by increasing lightness by the given percentage points.', + args: [ + { name: 'color', type: 'Color', description: 'Base color' }, + { name: 'amount', type: 'number', description: 'Lightness increase (0-100)' }, + ], + returns: 'Color', + examples: ['=color.lighten(orig,20) 20% lighter'], + fn: (ctx, c, amount) => { + const col = toColor(c); + if (!col) return c; + const { h, s, l } = col.toHsl(); + return Color.fromHsl(h, s, l + amount); + }, + }, + { + name: 'saturate', namespace: 'color', contexts: ['value'], + description: 'Increases saturation by the given percentage points.', + args: [ + { name: 'color', type: 'Color', description: 'Base color' }, + { name: 'amount', type: 'number', description: 'Saturation increase (0-100)' }, + ], + returns: 'Color', + examples: ['=color.saturate(orig,30) more vivid'], + fn: (ctx, c, amount) => { + const col = toColor(c); + if (!col) return c; + const { h, s, l } = col.toHsl(); + return Color.fromHsl(h, s + amount, l); + }, + }, + { + name: 'mix', namespace: 'color', contexts: ['value'], + description: 'Blends two colors. t=0 returns a, t=1 returns b.', + args: [ + { name: 'a', type: 'Color', description: 'First color' }, + { name: 'b', type: 'Color', description: 'Second color' }, + { name: 't', type: 'number', description: 'Blend factor (0-1)' }, + ], + returns: 'Color', + examples: ['=color.mix(orig,color.red,0.5) blend with red', '=color.mix(orig,color.red,rand(0,1)) random blend'], + fn: (ctx, a, b, t) => { + const ca = toColor(a), cb = toColor(b); + if (!ca || !cb) return a; + t = Math.max(0, Math.min(1, t)); + return new Color( + ca.r + (cb.r - ca.r) * t, + ca.g + (cb.g - ca.g) * t, + ca.b + (cb.b - ca.b) * t + ); + }, + }, + { + name: 'getHue', namespace: 'color', contexts: ['value'], + description: 'Returns the hue (0-360) of a color.', + args: [{ name: 'color', type: 'Color', description: 'Input color' }], + returns: 'number', + examples: ['=color.getHue(orig) get current hue'], + fn: (ctx, c) => { const col = toColor(c); return col ? col.toHsl().h : 0; }, + }, + { + name: 'getSat', namespace: 'color', contexts: ['value'], + description: 'Returns the saturation (0-100) of a color.', + args: [{ name: 'color', type: 'Color', description: 'Input color' }], + returns: 'number', + examples: ['=color.getSat(orig)'], + fn: (ctx, c) => { const col = toColor(c); return col ? col.toHsl().s : 0; }, + }, + { + name: 'getLightness', namespace: 'color', contexts: ['value'], + description: 'Returns the lightness (0-100) of a color.', + args: [{ name: 'color', type: 'Color', description: 'Input color' }], + returns: 'number', + examples: ['=color.getLightness(orig)'], + fn: (ctx, c) => { const col = toColor(c); return col ? col.toHsl().l : 0; }, + }, + ].forEach(reg => registerValueFunction(SCRIPT_NAME, reg)); + + // ── Color constants — Roll20 palette ───────────────────────────────────── + // Colors are listed in Roll20 color picker order (left-to-right, top-to-bottom) + const roll20Colors = [ + // Row 1 — Grayscale + { name: 'black', hex: '#000000', description: 'Black' }, + { name: 'charcoal', hex: '#434343', description: 'Charcoal' }, + { name: 'gray', hex: '#666666', description: 'Gray' }, + { name: 'silver', hex: '#c0c0c0', description: 'Silver' }, + { name: 'lightGray', hex: '#d9d9d9', description: 'Light gray' }, + { name: 'white', hex: '#ffffff', description: 'White' }, + // Row 2 — Pure/vivid + { name: 'darkRed', hex: '#980000', description: 'Dark red' }, + { name: 'red', hex: '#ff0000', description: 'Red' }, + { name: 'orange', hex: '#ff9900', description: 'Orange' }, + { name: 'yellow', hex: '#ffff00', description: 'Yellow' }, + { name: 'lime', hex: '#00ff00', description: 'Lime green' }, + { name: 'cyan', hex: '#00ffff', description: 'Cyan' }, + // Row 3 — Blues/purples/pinks + { name: 'cornflowerBlue', hex: '#4a86e8', description: 'Cornflower blue' }, + { name: 'blue', hex: '#0000ff', description: 'Blue' }, + { name: 'violet', hex: '#9900ff', description: 'Violet' }, + { name: 'magenta', hex: '#ff00ff', description: 'Magenta' }, + { name: 'roseDust', hex: '#e6b8af', description: 'Rose dust' }, + { name: 'lightPink', hex: '#f4cccc', description: 'Light pink' }, + // Row 4 — Light pastels + { name: 'peach', hex: '#fce5cd', description: 'Peach' }, + { name: 'cream', hex: '#fff2cc', description: 'Cream' }, + { name: 'mintCream', hex: '#d9ead3', description: 'Mint cream' }, + { name: 'powderBlue', hex: '#d0e0e3', description: 'Powder blue' }, + { name: 'lavenderBlue', hex: '#c9daf8', description: 'Lavender blue' }, + { name: 'aliceBlue', hex: '#cfe2f3', description: 'Alice blue' }, + // Row 5 — Medium pastels/light warm + { name: 'lavender', hex: '#d9d2e9', description: 'Lavender' }, + { name: 'blushPink', hex: '#ead1dc', description: 'Blush pink' }, + { name: 'terracotta', hex: '#dd7e6b', description: 'Terracotta' }, + { name: 'lightRed', hex: '#ea9999', description: 'Light red' }, + { name: 'lightOrange', hex: '#f9cb9c', description: 'Light orange' }, + { name: 'lightYellow', hex: '#ffe599', description: 'Light yellow' }, + // Row 6 — Medium pastels/cool + { name: 'lightGreen', hex: '#b6d7a8', description: 'Light green' }, + { name: 'cadetBlue', hex: '#a2c4c9', description: 'Cadet blue' }, + { name: 'periwinkle', hex: '#a4c2f4', description: 'Periwinkle' }, + { name: 'skyBlue', hex: '#9fc5e8', description: 'Sky blue' }, + { name: 'lilac', hex: '#b4a7d6', description: 'Lilac' }, + { name: 'pinkLavender', hex: '#d5a6bd', description: 'Pink lavender' }, + // Row 7 — Medium saturated + { name: 'burntOrange', hex: '#cc4125', description: 'Burnt orange' }, + { name: 'salmon', hex: '#e06666', description: 'Salmon' }, + { name: 'sandyBrown', hex: '#f6b26b', description: 'Sandy brown' }, + { name: 'goldenYellow', hex: '#ffd966', description: 'Golden yellow' }, + { name: 'sage', hex: '#93c47d', description: 'Sage' }, + { name: 'steelBlue', hex: '#76a5af', description: 'Steel blue' }, + // Row 8 — Medium blues/purples/deep reds + { name: 'cornflower', hex: '#6d9eeb', description: 'Cornflower' }, + { name: 'carolina', hex: '#6fa8dc', description: 'Carolina blue' }, + { name: 'amethyst', hex: '#8e7cc3', description: 'Amethyst' }, + { name: 'mauve', hex: '#c27ba0', description: 'Mauve' }, + { name: 'crimson', hex: '#a61c00', description: 'Crimson' }, + { name: 'scarlet', hex: '#cc0000', description: 'Scarlet' }, + // Row 9 — Medium saturated warm/cool + { name: 'amber', hex: '#e69138', description: 'Amber' }, + { name: 'gold', hex: '#f1c232', description: 'Gold' }, + { name: 'fern', hex: '#6aa84f', description: 'Fern' }, + { name: 'teal', hex: '#45818e', description: 'Teal' }, + { name: 'royalBlue', hex: '#3c78d8', description: 'Royal blue' }, + { name: 'cerulean', hex: '#3d85c6', description: 'Cerulean' }, + // Row 10 — Deep purples/dark warm + { name: 'purple', hex: '#674ea7', description: 'Purple' }, + { name: 'raspberry', hex: '#a64d79', description: 'Raspberry' }, + { name: 'darkBrown', hex: '#5b0f00', description: 'Dark brown' }, + { name: 'darkMaroon', hex: '#660000', description: 'Dark maroon' }, + { name: 'chocolate', hex: '#783f04', description: 'Chocolate' }, + { name: 'bronze', hex: '#7f6000', description: 'Bronze' }, + // Row 11 — Deep dark cool + { name: 'darkForest', hex: '#274e13', description: 'Dark forest' }, + { name: 'darkTeal', hex: '#0c343d', description: 'Dark teal' }, + { name: 'navy', hex: '#1c4587', description: 'Navy' }, + { name: 'midnight', hex: '#073763', description: 'Midnight' }, + { name: 'darkPurple', hex: '#20124d', description: 'Dark purple' }, + ]; + roll20Colors.forEach(c => { + const h = c.hex.replace(/^#/, ''); + const n = parseInt(h, 16); + registerPlaybackConstant(SCRIPT_NAME, { + name: c.name, namespace: 'color', type: 'Color', contexts: ['value'], + value: new Color((n >> 16) & 255, (n >> 8) & 255, n & 255), + description: `${c.description} (${c.hex})`, + }); + }); + registerPlaybackConstant(SCRIPT_NAME, { + name: 'transparent', namespace: 'color', type: 'Color', contexts: ['value'], + value: Color.transparent, + description: 'Transparent (no tint)', + }); + + // ── Math constants ──────────────────────────────────────────────────────── + registerPlaybackConstant(SCRIPT_NAME, { + name: 'PI', namespace: 'core', type: 'number', + value: Math.PI, + description: 'π (3.14159…)', + }); + registerPlaybackConstant(SCRIPT_NAME, { + name: 'TAU', namespace: 'core', type: 'number', + value: Math.PI * 2, + description: '2π (6.28318…) — one full rotation in radians.', + }); + + // Pre-built query strings for handout dropdown buttons + // Escape a string for use inside a nested Roll20 ?{} query that is itself + const EASING_QUERY = () => { + const makeEntry = (name, reversed) => { + const reg = EASING_REGISTRY[name]; + const args = reg && reg.args && reg.args.length > 0 ? reg.args : null; + const prefix = reversed ? '~' : ''; + + if (!args) return `${prefix}${name}`; + + // Parametric — label shows arg names, value is a sentinel name() + // set-easing detects the empty parens and whispers a follow-up args prompt + const rawLabel = reg.label || `${prefix}${name}(${args.map(a => a.optional ? `[${a.name}]` : a.name).join(', ')})`; + // In href context, & must be & so browser decodes &#44; → , → , + const safeLabel = rawLabel.replace(/\(/g, '(').replace(/\)/g, ')').replace(/,/g, '&#44;'); + return `${safeLabel},${prefix}${name}()`; + }; + + const curves = EASING_NAMES().filter(n => n !== 'linear' && n !== 'step'); + const entries = [ + 'linear', + ...curves.flatMap(n => [makeEntry(n, false), makeEntry(n, true)]), + 'step', + ]; + return `?{Easing|${entries.join('|')}}`; + }; + + const TYPE_QUERY = '?{Type|change|command}'; + + const generateHandoutHtml = (name, recording, attrCols) => { + const { objectType = 'graphic', duration = 0, notes = '' } = recording; + const tracks = recording.tracks || {}; + const trackIds = Object.keys(tracks); + + // All registered interpolatable attribute names for the dropdown + const allAttrs = getAllAttrNames(objectType); + const addableAttrs = allAttrs.filter(a => !attrCols.includes(a)); + const addDropdown = addableAttrs.length > 0 + ? `?{Add Attribute|${addableAttrs.join('|')}}` + : null; + + let html = ''; + + // ---- Metadata block ---- + html += `
`; + html += `Recording: ${escHtml(name)}
`; + html += `Object type: ${escHtml(objectType)}
`; + html += `Notes: ${escHtml(notes)}
`; + html += '
'; + + // ---- Action buttons ---- + html += `
`; + html += btnHtml('👁 Preview', `${CMD_TOKEN} playback-menu ${escArg(name)}`); + html += btnHtml('+ Row', `${CMD_TOKEN} add-row ${escArg(name)} ?{Time (ms)|=0}`); + html += btnHtml('⇅ Sort', `${CMD_TOKEN} sort ${escArg(name)}`); + html += btnHtml('Refresh', `${CMD_TOKEN} refresh ${escArg(name)}`); + html += btnHtml('🔍 Dump', `${CMD_TOKEN} dump-html ${escArg(name)}`); + if (addDropdown) { + html += btnHtml('+ Add Attribute', + `${CMD_TOKEN} add-attribute ${escArg(name)} ${addDropdown}`); + } + html += btnHtml('⚠ Delete', `${CMD_TOKEN} delete ${escArg(name)}`); + html += `
`; + + // ---- One table per track ---- + trackIds.forEach((trackId, ti) => { + const track = tracks[trackId]; + const label = track.label || (trackIds.length > 1 ? `Track ${ti + 1}` : ''); + + if (label) html += `${escHtml(label)}
`; + + html += `
`; + html += ``; + + // Determine if this track has any command keyframes + const hasCommands = (track.keyframes || []).some(kf => kf.type === 'command'); + + // Header row + html += ''; + html += ``; + html += ``; + if (hasCommands) { + html += ``; + // Note: no remove button on command column — it's a system column + // that disappears automatically when no command keyframes remain + } + attrCols.forEach(attr => { + const reg = getAttrReg(attr); + const canLerp = reg && reg.lerp !== null; + // Wrap attr name in span with data-attr marker so the parser + // can extract just the name without grabbing button text + // The remove button must be outside the span so Roll20's editor + // doesn't concatenate the ✕ text into the data-attr value on save + html += ``; + if (canLerp) { + html += ``; + } + }); + html += ''; + + // Keyframe rows + // Track which attrs have already had a showEasingBtn row + const hadEasingAttr = new Set(); + + (track.keyframes || []).forEach((kf, ki) => { + const bg = ki % 2 === 0 ? STYLE.td : STYLE.tdAlt; + const bgErr = 'padding:2px 5px;border:1px solid #c00;background:#fee;'; + + // Render parse-error rows specially + if (kf.type === 'parse-error') { + const colSpan = 2 + attrCols.reduce((n, a) => { + const reg = getAttrReg(a); + return n + 1 + (reg && reg.lerp !== null ? 1 : 0); + }, 0) + (hasCommands ? 1 : 0); + html += ''; + html += ``; + html += ``; + return; + } + + const cell = (colKey, content) => { + const hasErr = kf.cellErrors && kf.cellErrors[colKey]; + const style = hasErr ? bgErr : bg; + const title = hasErr ? ` title="${escHtml(kf.cellErrors[colKey])}"` : ''; + return ``; + }; + + const kfType = kf.type || 'change'; + const kfTimeStr = escArg(fmtTime(kf.time)); + const typeBtn = ``; + const typeCellErrors = kf.cellErrors && kf.cellErrors['type']; + const typeBg = typeCellErrors + ? 'padding:2px 5px;border:1px solid #c00;background:#fee;' + : bg; + const typeTitle = typeCellErrors ? ` title="${escHtml(typeCellErrors)}"` : ''; + + html += ''; + html += ``; + html += ``; + if (hasCommands) { + const cmd = kf.type === 'command' ? (kf.command || '') : ''; + const setCmdBtn = kf.type === 'command' + ? `` + : ''; + // Encode leading slash so Roll20's editor doesn't strip it + const cmdDisplay = cmd.startsWith('/') + ? '/' + escHtml(cmd.slice(1)) + : escHtml(cmd); + html += cell('__command', cmdDisplay + setCmdBtn); + } + attrCols.forEach(attr => { + const reg = getAttrReg(attr); + const canLerp = reg && reg.lerp !== null; + const parsed = kf.deltas && attr in kf.deltas ? kf.deltas[attr] : null; + // Only use easing if it's a recognised name — discard corrupted values + const rawEasing = kf.easings && kf.easings[attr] ? kf.easings[attr] : ''; + const easing = (rawEasing && !validateEasingExpr(rawEasing)) ? rawEasing : ''; + // Clean invalid value from cache + if (rawEasing && !easing && kf.easings) delete kf.easings[attr]; + // parsed is { delta: val }, { abs: val }, or { expr, mode } — extract display value + let cellVal = ''; + if (parsed !== null && parsed !== undefined) { + if ('expr' in parsed) { + // Expression — display with mode prefix + if (parsed.mode === 'abs') cellVal = `=${parsed.expr}`; + else if (parsed.mode === 'mul') cellVal = `×${parsed.expr}`; + else if (parsed.sign === -1) cellVal = `-${parsed.expr}`; + else cellVal = `+${parsed.expr}`; + } else if (reg) { + if ('abs' in parsed) { + cellVal = reg.format(parsed.abs); + // Ensure abs values always show = prefix + if (!String(cellVal).startsWith('=')) cellVal = `=${cellVal}`; + } else if ('delta' in parsed) { + cellVal = reg.format(parsed.delta); + } + } else { + cellVal = 'abs' in parsed ? `=${parsed.abs}` : `+${parsed.delta}`; + } + } + html += cell(attr, escHtml(cellVal)); + if (canLerp) { + // Show easing button only if this cell would NOT be stripped + // by stripRedundantEasings — i.e. it's not in a dead zone + // between a non-identity value and the next identity/end. + // + // A row is in a dead zone if: + // scanning forward from it, the next delta for this attr + // is an identity delta (or there is no next delta at all) + // EXCEPT: identity-delta rows themselves are NOT in a dead zone + // (they are the easing switch point for the next segment). + const isIdentity = isIdentityParsed(parsed, reg); + const futureKfs = (track.keyframes || []).slice(ki + 1); + const nextDeltaKf = futureKfs.find(fkf => + fkf.deltas && attr in fkf.deltas && + fkf.deltas[attr] !== null && fkf.deltas[attr] !== undefined + ); + const nextDeltaIsIdentity = nextDeltaKf + ? isIdentityParsed(nextDeltaKf.deltas[attr], reg) + : true; // no next delta = treat as identity (end of table) + + // Show ⚙ if: + // - this row is an identity delta (easing switch point), OR + // - this row is not identity AND the next delta is not identity + const showEasingBtn = isIdentity + ? !nextDeltaIsIdentity // identity row: show only if next segment is real + : !nextDeltaIsIdentity; // normal row: show only if not heading into identity/end + + if (showEasingBtn) { + const easingBtn = ``; + const hasDelta = parsed !== null && parsed !== undefined; + const isFirstEasingRow = !hadEasingAttr.has(attr); + hadEasingAttr.add(attr); + const displayEasing = easing || (hasDelta || isFirstEasingRow ? 'linear' : ''); + + // Check if easing is parametric + const token = displayEasing ? parseEasingToken(displayEasing) : null; + const easingReg = token ? EASING_REGISTRY[token.name] : null; + const isParametric = easingReg && easingReg.args && easingReg.args.length > 0; + + let easingContent; + if (isParametric) { + const currentArgs = token.args.length > 0 ? token.args.join(',') : null; + const argNames = easingReg.args.map(a => a.optional ? `[${a.name}]` : a.name).join(', '); + const firstNonPrompt = easingReg.args.findIndex(a => a.promptDefault === undefined); + const trailingOK = firstNonPrompt === -1 || easingReg.args.slice(firstNonPrompt).every(a => a.default !== undefined); + const hasAny = easingReg.args.some(a => a.promptDefault !== undefined); + const promptArgs = firstNonPrompt === -1 ? easingReg.args : easingReg.args.slice(0, firstNonPrompt); + const argDefault = (hasAny && trailingOK) ? promptArgs.map(a => a.promptDefault).join(',') : (currentArgs || ''); + const argsDisplay = currentArgs || `…`; + const prefix = token.reversed ? '~' : ''; + // Use current args as prompt default if set, else registry promptDefault + const promptDefault = currentArgs || argDefault; + // Standalone ?{} in href — fires directly, no nesting issues + const argsHref = `${CMD_TOKEN} set-easing ${escArg(name)} ${kfTimeStr} ${escArg(attr)} ${prefix}${token.name}(?{${prefix}${token.name} args (${argNames})|${promptDefault}})`; + const argsBtn = `${escHtml(argsDisplay)}`; + // No data-value needed — Roll20 strips HTML, text content is read directly + easingContent = `${escHtml(prefix + token.name)}(${argsBtn})${easingBtn}`; + } else { + easingContent = displayEasing + ? `${escHtml(displayEasing)}${easingBtn}` + : easingBtn; + } + html += cell(`${attr}:easing`, easingContent); + } else { + html += cell(`${attr}:easing`, ''); + } + } + }); + html += ''; + }); + + html += '
Time (ms)typecommand${escHtml(attr)}`; + html += `${escHtml(attr)}:easing
${kf.time !== null ? kf.time : '?'}`; + html += `⚠ Parse error: ${escHtml(kf.error)}`; + if (kf.rawCells) html += ` (${escHtml(kf.rawCells.slice(0, 80))}${kf.rawCells.length > 80 ? '…' : ''})`; + html += `
${content}
${escHtml(fmtTime(kf.time))}${escHtml(kfType)}${typeBtn}
'; + }); + + // ---- Attribute key ---- + html += `
`; + html += `+/- = relative delta   = = absolute value   `; + html += `empty cell = no change at this keyframe
`; + html += `Easing: leave blank for linear. Use a curve name for ease-in (e.g. quad) ` + + `or ~name for ease-out (e.g. ~quad). ` + + `For ease-in-out, add an empty row with ~curve easing at the midpoint. ` + + `Available curves: ${EASING_NAMES().join(', ')}.
`; + html += `Command column: use the ⚙ button to set commands reliably. Direct editing supports !commands; for slash commands (e.g. /w) use the ⚙ button instead.`; + html += `
`; + + return html; + }; + + // Format a keyframe time value for display in the handout table + const fmtTime = (t) => { + if (typeof t === 'number') return `=${t}`; + if (t && 'rel' in t) return `+${t.rel}`; + if (t && 'abs' in t) return `=${t.abs}`; + return '?'; + }; + + // Check if a keyframe's time matches a formatted time string (from button URL) + const kfTimeMatches = (kf, timeStr) => { + return fmtTime(kf.time) === timeStr; + }; + + const escHtml = (str) => String(str || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + + const escArg = (str) => String(str || '').replace(/\s+/g, '-'); + + const btnHtml = (label, cmd) => { + // cmd may or may not start with '!' — normalise to always have exactly one + const href = cmd.startsWith('!') ? cmd : `!${cmd}`; + return `${escHtml(label)}`; + }; + + // ========================================================================= + // Handout parser + // ========================================================================= + + /** + * Parse a recording handout's HTML back into a recording object. + * Returns { name, objectType, duration, notes, tracks, attrCols } or null on failure. + */ + const parseHandout = (name, html) => { + // Decode HTML entities then normalise Roll20-mangled markup. + // Roll20's editor wraps content in

tags, encodes quotes, etc. + const decode = (s) => String(s) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' '); + + const body = decode(html) + .replace(/<\/?p[^>]*>/gi, '\n') + .replace(/]*>/gi, '\n') + .replace(/\r\n/g, '\n'); + + // Helpers — defined early so they're available throughout the parser + const stripButtons = (s) => String(s).replace(/[⚙✕×]+/g, '').trim(); + + const extractCellValue = (cellHtml) => { + const m = cellHtml.match(/data-value="([^"]*)"/); + if (m) return m[1] + .replace(/&/g, '&').replace(/"/g, '"') + .replace(/</g, '<').replace(/>/g, '>').trim(); + return cellHtml.replace(/<[^>]+>/g, '') + .replace(/&/g, '&').replace(/ /g, ' ').trim(); + }; + + const recording = { + name, + objectType: 'graphic', + duration: 0, + notes: '', + tracks: {}, + }; + const attrCols = []; + + // Strip remaining tags from a string + const stripTags = (s) => String(s).replace(/<[^>]+>/g, '').trim(); + + // Parse metadata — labels are wrapped in tags so we match: + // "Object type: graphic" or "Object type: graphic" + // Pattern: label text, optional close tag, whitespace, then the value + const metaVal = (label) => { + const re = new RegExp(label + '[^<]*(?:<[^>]+>)?\\s*([^<\\n]+)', 'i'); + const m = body.match(re); + return m ? stripTags(m[1]).trim() : null; + }; + const objTypeVal = metaVal('Object type'); + if (objTypeVal) recording.objectType = objTypeVal; + // Duration is computed dynamically from keyframes after parsing, + // so we don't need to store or parse it from the handout. + const notesVal = metaVal('Notes'); + if (notesVal) recording.notes = notesVal; + + // Parse tables — each table is one track + const tableRe = /]*>([\s\S]*?)<\/table>/gi; + let tableMatch; + let trackIdx = 0; + + while ((tableMatch = tableRe.exec(body)) !== null) { + const tableHtml = tableMatch[1]; + const trackId = `track-${trackIdx++}`; + const track = { keyframes: [] }; + + // Parse header row to get column order + const headerMatch = tableHtml.match(/]*>([\s\S]*?)<\/tr>/i); + if (!headerMatch) continue; + const headerHtml = headerMatch[1]; + const headers = []; + const thRe = /]*>([\s\S]*?)<\/th>/gi; + let thMatch; + while ((thMatch = thRe.exec(headerHtml)) !== null) { + const cellHtml = thMatch[1]; + // Prefer data-attr span for reliable attribute name extraction + const spanMatch = cellHtml.match(/data-attr="([^"]+)"/); + let raw; + if (spanMatch) { + // Strip button chars then extract valid attr name chars + const cleaned = stripButtons(spanMatch[1]).match(/^[A-Za-z0-9_:]+/); + raw = cleaned ? cleaned[0] : ''; + } else { + raw = stripButtons(cellHtml.replace(/<[^>]+>/g, '')); + } + headers.push(raw); + // Collect unique base attribute column names + // Exclude: time, type, __command marker, :easing suffixes + // Sanitize: extract only valid attribute name characters + const RESERVED = new Set(['Time (ms)', 'type', '__command', 'command']); + if (raw && !RESERVED.has(raw) && !raw.includes(':')) { + const sanitized = (raw.match(/^[A-Za-z0-9_]+/) || [''])[0]; + if (sanitized && !RESERVED.has(sanitized) && !attrCols.includes(sanitized)) { + attrCols.push(sanitized); + } + } + } + + // Parse data rows + const rowRe = /]*>([\s\S]*?)<\/tr>/gi; + rowRe.exec(tableHtml); // skip header row + let rowMatch; + while ((rowMatch = rowRe.exec(tableHtml)) !== null) { + const rowHtml = rowMatch[1]; + const cells = []; + const tdRe = /]*>([\s\S]*?)<\/td>/gi; + let tdMatch; + while ((tdMatch = tdRe.exec(rowHtml)) !== null) { + cells.push(tdMatch[1].replace(/<[^>]+>/g, '').trim()); + } + if (cells.length < 2) continue; + + // Decode time — if NaN, emit a parse-error row + const rawTime = stripButtons(extractCellValue(cells[0])).trim(); + + // Parse time cell — must start with = (absolute) or + (relative): + // =number → absolute literal + // =expr → absolute expression + // +number → relative literal + // +expr → relative expression + let parsedTime; + const isRel = rawTime.startsWith('+'); + const isAbs = rawTime.startsWith('='); + + if (!isRel && !isAbs) { + track.keyframes.push({ + time: null, + type: 'parse-error', + error: `Time must start with = (absolute) or + (relative): "${rawTime}"`, + rawCells: cells.join(' | '), + deltas: {}, + easings: {}, + }); + continue; + } + + const inner = rawTime.slice(1).trim(); + + if (isRel) { + const numVal = parseFloat(inner); + parsedTime = (!isNaN(numVal) && !/[A-Za-z(]/.test(inner)) + ? { rel: numVal } + : { rel: inner, isExpr: true }; + } else { + // absolute + const numVal = parseFloat(inner); + if (!isNaN(numVal) && !/[A-Za-z(]/.test(inner)) { + parsedTime = numVal; + } else if (/[A-Za-z(]/.test(inner)) { + parsedTime = { abs: inner, isExpr: true }; + } else { + track.keyframes.push({ + time: null, + type: 'parse-error', + error: `Could not parse time value: "${rawTime}"`, + rawCells: cells.join(' | '), + deltas: {}, + easings: {}, + }); + continue; + } + } + + // Type is encoded as class="seq-type-" on the time + // CSS classes are preserved reliably by Roll20's editor + const parsedType = stripButtons(extractCellValue(cells[1])); + + const kf = { + time: parsedTime, // number | {abs,isExpr} | {rel,isExpr} + type: parsedType || 'change', + deltas: {}, + easings: {}, + }; + + let colIdx = 2; // skip time + type columns + headers.slice(2).forEach((header) => { + if (colIdx >= cells.length) return; + const rawCell = cells[colIdx++]; + if (!rawCell) return; + // Decode HTML entities Roll20 may have encoded in cell content + const cell = stripButtons(rawCell + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/×/g, '×') + .replace(/×/g, '×') + .replace(/ /g, ' ')); + if (!cell) return; + + if (header === '__command') { + if (cell) { + // Restore encoded leading slash (user types / instead of /) + // Also handle if Roll20 decoded it to / already + kf.command = cell + .replace(/^&#47;/, '/') + .replace(/^//, '/') + .replace(/^&#x2F;/, '/') + .replace(/^//, '/'); + + } + return; + } + + if (header.endsWith(':easing')) { + const attrName = (header.replace(/:easing$/, '') + .match(/^[A-Za-z0-9_]+/) || [''])[0]; + // Roll20 strips HTML from table cells — read plain text; + // Roll20 strips all HTML from table cells on save, so + // read the plain text content directly + const easingVal = stripButtons(rawCell + .replace(/<[^>]+>/g, '') + .replace(/&/g, '&') + .replace(/(/g, '(') + .replace(/)/g, ')') + .replace(/,/g, ',') + .replace(/ /g, ' ') + ).trim(); + // Only store recognised easing names + if (attrName && easingVal && !validateEasingExpr(easingVal)) { + kf.easings[attrName] = easingVal; + } + return; + } + + // Sanitize header to valid attribute name + const cleanHeader = (header.match(/^[A-Za-z0-9_]+/) || [''])[0]; + const reg = getAttrReg(cleanHeader); + if (!reg) { + kf.cellErrors = kf.cellErrors || {}; + kf.cellErrors[cleanHeader] = `Unknown attribute "${cleanHeader}"`; + return; + } + + try { + const parsed = reg.parse(cell); + if (parsed === null || parsed === undefined) { + kf.cellErrors = kf.cellErrors || {}; + kf.cellErrors[cleanHeader] = `Could not parse "${cell}"`; + return; + } + if ('delta' in parsed && isNaN(parsed.delta)) { + kf.cellErrors = kf.cellErrors || {}; + kf.cellErrors[cleanHeader] = `Invalid delta "${cell}"`; + return; + } + kf.deltas[cleanHeader] = parsed; + } catch(e) { + kf.cellErrors = kf.cellErrors || {}; + kf.cellErrors[cleanHeader] = `Error: ${e.message}`; + } + }); + + // Include command and change rows with deltas + // Also include blank change rows (user may have added them manually) + if (Object.keys(kf.deltas).length > 0 || + kf.type === 'command' || + kf.type === 'change') { + track.keyframes.push(kf); + } + } + + recording.tracks[trackId] = track; + } + + // Compute duration from last keyframe — expression times treated as 0 + // (unknown until runtime) so duration may be underestimated for expr-timed recordings + let maxTime = 0; + Object.values(recording.tracks).forEach(track => { + (track.keyframes || []).forEach(kf => { + const t = typeof kf.time === 'number' ? kf.time : 0; + if (t > maxTime) maxTime = t; + }); + }); + recording.duration = maxTime; + + return { recording, attrCols }; + }; + + // ========================================================================= + // Recording Engine + // ========================================================================= + + /** + * Start a recording session for an object. + * @param {string} objId Roll20 object ID + * @param {string} name Recording name (or null for unnamed) + * @param {string[]} attrs Attributes to record (or null for all) + */ + const startRecording = (objId, name, attrs, playerid) => { + const obj = getObj('graphic', objId); + if (!obj) return false; + + const recordAttrs = attrs || getAllAttrNames('graphic'); + + // Snapshot current state (core attrs only — virtual attrs have no get()) + // Virtual attrs get their first value via notifyChange on first callback + const snapshot = {}; + recordAttrs.forEach(attrName => { + const reg = getAttrReg(attrName); + if (reg && reg.objectType === 'graphic') { + try { snapshot[attrName] = reg.get(obj); } catch(e) { /* virtual */ } + } + }); + + activeSessions[objId] = { + name, + playerid, + startTime: Date.now(), + lastSnapshot: snapshot, + recordAttrs, + keyframes: [], + paused: false, + pausedAt: null, + }; + + // Subscribe virtual attribute watchers + recordAttrs.forEach(attrName => { + const reg = getAttrReg(attrName); + if (!reg || !reg.startWatch) return; + reg.startWatch(obj, (currVal) => notifyChange(obj, qualifiedAttrName(reg), currVal)); + }); + + return true; + }; + + /** + * Capture a keyframe for an object based on its current state vs last snapshot. + * Called from change event handlers. + */ + const captureKeyframe = (obj) => { + const objId = obj.get('id'); + const session = activeSessions[objId]; + if (!session || session.paused) return; + + const now = Date.now(); + const time = now - session.startTime; + const deltas = {}; + let changed = false; + + session.recordAttrs.forEach(attrName => { + const reg = getAttrReg(attrName); + if (!reg) return; + const curr = reg.get(obj); + const prev = session.lastSnapshot[attrName]; + const delta = reg.diff(prev, curr); + if (delta !== null && delta !== undefined) { + deltas[attrName] = { delta }; + session.lastSnapshot[attrName] = curr; + changed = true; + } + }); + + if (!changed) return; + + // For each attribute that changed in this event, if it has changed + // before (i.e. it already appears in a prior keyframe), stamp an identity + // entry onto the immediately preceding keyframe to terminate that lerp + // segment cleanly. Attributes that are changing for the first time don't + // need this — there's no prior segment to terminate. + if (session.keyframes.length > 0) { + const prevKf = session.keyframes[session.keyframes.length - 1]; + Object.keys(deltas).forEach(attrName => { + const reg = getAttrReg(attrName); + if (!reg || reg.lerp === null || !reg.identity) return; + // Only stamp identity if this attribute has appeared in a prior keyframe + const appearedBefore = session.keyframes.some(kf => attrName in kf.deltas); + if (!appearedBefore) return; + // Don't overwrite if prev keyframe already has an entry for this attr + if (attrName in prevKf.deltas) return; + const id = reg.identity(obj); + if (!id || ('abs' in id && (id.abs === undefined || id.abs === null))) return; + prevKf.deltas[attrName] = id; + }); + } + + session.keyframes.push({ + time, + type: 'change', + deltas, + easings: {}, + }); + + session.duration = time; + pingSession(obj, session); + }; + + /** + * Ping the token's map position to give visual feedback that a keyframe + * was captured. Only pings for the session's owning player. + */ + const pingSession = (obj, session) => { + if (!session.playerid) return; + sendPing( + obj.get('left'), + obj.get('top'), + session.playerid, + obj.get('_pageid'), + false + ); + }; + + /** + * Notify the recorder that a virtual attribute has changed. + * Called internally via the notify closure passed to reg.startWatch. + * External scripts never call this directly — they just call notify(currVal). + * Handles prevVal tracking internally. + * + * @param {object} obj Roll20 object + * @param {string} attrName Registered attribute name (from reg.name) + * @param {*} currVal Current value of the attribute + */ + const notifyChange = (obj, attrName, currVal) => { + const objId = obj.get('id'); + const session = activeSessions[objId]; + if (!session || session.paused) return; + + const reg = getAttrReg(attrName); + if (!reg) return; + + // First notification — establish baseline, no keyframe emitted + if (!(attrName in session.lastSnapshot)) { + session.lastSnapshot[attrName] = currVal; + return; + } + + const prevVal = session.lastSnapshot[attrName]; + const delta = reg.diff(prevVal, currVal); + if (delta === null || delta === undefined) return; + + session.lastSnapshot[attrName] = currVal; + const time = Date.now() - session.startTime; + + // Merge into existing keyframe at same timestamp if one exists, + // otherwise push a new keyframe + const existing = session.keyframes.find(kf => kf.time === time); + if (existing) { + existing.deltas[attrName] = { delta }; + } else { + session.keyframes.push({ + time, + type: 'change', + deltas: { [attrName]: { delta } }, + easings: {}, + }); + } + + session.duration = time; + pingSession(obj, session); + }; + + /** + * Stop recording and return the captured keyframes. + */ + const stopRecording = (objId) => { + const session = activeSessions[objId]; + if (!session) return null; + + // Unsubscribe virtual attribute watchers + const obj = getObj('graphic', objId); + if (obj) { + session.recordAttrs.forEach(attrName => { + const reg = getAttrReg(attrName); + if (reg && reg.stopWatch) reg.stopWatch(obj); + }); + } + + delete activeSessions[objId]; + return session; + }; + + const pauseRecording = (objId) => { + const session = activeSessions[objId]; + if (!session || session.paused) return; + session.paused = true; + session.pausedAt = Date.now(); + }; + + const resumeRecording = (objId) => { + const session = activeSessions[objId]; + if (!session || !session.paused) return; + // Shift startTime forward by the paused duration so timestamps remain correct + const pausedDuration = Date.now() - session.pausedAt; + session.startTime += pausedDuration; + session.paused = false; + session.pausedAt = null; + }; + + /** + * Build a recording object from a session and save it to a handout. + */ + const saveRecording = (name, session, callback) => { + const track = { + label: '', + keyframes: session.keyframes, + }; + const recording = { + name, + objectType: 'graphic', + duration: session.duration || 0, + notes: '', + tracks: { 'track-0': track }, + }; + + const attrCols = session.recordAttrs.filter(attr => + session.keyframes.some(kf => attr in kf.deltas) + ); + + const handout = getOrCreateHandout(name); + const html = generateHandoutHtml(name, recording, attrCols); + setHandoutNotes(handout, html, name); + + // Cache the parsed recording + recordingCache[name] = { recording, attrCols }; + + if (callback) callback(handout); + return handout; + }; + + // ========================================================================= + // Playback Engine + // ========================================================================= + + const PLAYBACK_FPS_DEFAULT = 30; + const PLAYBACK_FPS = (() => { + const cfg = typeof globalconfig !== 'undefined' && globalconfig.sequence; + const val = cfg && parseInt(cfg['Playback FPS'], 10); + return (val && val > 0) ? val : PLAYBACK_FPS_DEFAULT; + })(); + const PLAYBACK_INTERVAL_MS = Math.round(1000 / PLAYBACK_FPS); + + /** + * Load a recording from its handout into the cache. + * Async due to Roll20's handout notes API. + */ + const loadRecording = (name, callback) => { + if (recordingCache[name]) { + callback(recordingCache[name]); + return; + } + const handout = findHandout(name); + if (!handout) { + log(`${SCRIPT_NAME}: loadRecording — no handout found for "${name}"`); + callback(null); + return; + } + + getHandoutNotes(handout, (html) => { + if (!html) { + log(`${SCRIPT_NAME}: loadRecording — handout notes empty for "${name}"`); + callback(null); + return; + } + log(`${SCRIPT_NAME}: loadRecording — parsing "${name}" (${html.length} chars)`); + const result = parseHandout(name, html); + if (!result) { + log(`${SCRIPT_NAME}: loadRecording — parseHandout returned null for "${name}"`); + callback(null); + return; + } + const trackCount = Object.keys(result.recording.tracks || {}).length; + const kfCount = Object.values(result.recording.tracks || {}) + .reduce((sum, t) => sum + (t.keyframes || []).length, 0); + log(`${SCRIPT_NAME}: loadRecording — "${name}" parsed OK: ` + + `duration=${result.recording.duration}ms, ` + + `tracks=${trackCount}, keyframes=${kfCount}, ` + + `attrCols=${result.attrCols.join(',')}`); + recordingCache[name] = result; + callback(result); + }); + }; + + /** + * Apply a single keyframe's deltas to an object. + * @param {object} obj Roll20 object + * @param {object} kf Keyframe + * @param {string[]} onlyAttrs Whitelist (null = all) + * @param {string[]} skipAttrs Blacklist + */ + const applyKeyframe = (obj, kf, onlyAttrs, skipAttrs) => { + Object.entries(kf.deltas || {}).forEach(([attrName, parsed]) => { + if (onlyAttrs && !onlyAttrs.includes(attrName)) return; + if (skipAttrs && skipAttrs.includes(attrName)) return; + const reg = getAttrReg(attrName); + if (!reg) return; + if ('abs' in parsed) { + reg.set(obj, parsed.abs); + } else if ('delta' in parsed) { + reg.apply(obj, parsed.delta); + } + }); + }; + + /** + * Interpolate between two keyframes at time t (0–1) for a given attribute. + */ + const interpolateAttr = (attrName, prevKf, nextKf, t) => { + const reg = getAttrReg(attrName); + if (!reg || !reg.lerp) return null; // non-interpolatable + + // We need absolute values to lerp — compute by summing deltas from start + // This is done in the playback loop which maintains a running state + return null; // placeholder — see playback loop + }; + + /** + * Build a running absolute-value state from keyframes up to a given index. + * Uses each attribute's own reg.apply/reg.set via a shadow object, so + * multiplicative or otherwise non-additive attributes are handled correctly + * with no extra work from the registering script. + * + * upToIndex of -1 returns initialState unchanged. + */ + /** + * Strip easing cells that are redundant due to the identity-delta rule: + * Scan backward from each identity-delta row (and from an implicit row + * after the last row), stripping easing from all rows back to and + * including the last non-identity-delta row. + * + * An "identity delta" is a delta that produces no change: + * { delta: 0 } for numeric, { delta: 1 } for scale, + * or an explicit +0 / ×1 as parsed. + * + * Modifies kf.easings in place for the given attrName. + */ + const isIdentityParsed = (parsed, reg) => { + if (!parsed) return false; // empty cell — not identity delta + if ('expr' in parsed) return false; // expressions are never identity + if ('abs' in parsed) return false; // absolute values are never identity + if ('delta' in parsed) { + // For scale, identity delta is 1; for others, 0 + const identDelta = reg && reg.identity ? reg.identity() : { delta: 0 }; + return parsed.delta === (identDelta.delta !== undefined ? identDelta.delta : 0); + } + return false; + }; + + /** + * Sort keyframes preserving relative/expr rows as anchored to their + * surrounding absolute-literal rows. Only absolute-literal rows are + * reordered. If the table starts with non-literal rows, a =0 anchor + * row is inserted. + */ + const sortKeyframes = (keyframes) => { + if (!keyframes || keyframes.length === 0) return keyframes; + + const isAbsLiteral = (kf) => typeof kf.time === 'number'; + + // If first row is not absolute literal, insert a =0 anchor + let kfs = keyframes; + if (!isAbsLiteral(kfs[0])) { + kfs = [{ time: 0, type: 'change', deltas: {}, easings: {} }, ...kfs]; + } + + // Partition into anchor groups: each group is one abs-literal row + // plus all non-literal rows that immediately follow it + const groups = []; + let currentGroup = null; + kfs.forEach(kf => { + if (isAbsLiteral(kf)) { + currentGroup = { anchor: kf.time, rows: [kf] }; + groups.push(currentGroup); + } else { + currentGroup.rows.push(kf); + } + }); + + // Sort groups by anchor value (stable — preserves relative order within groups) + groups.sort((a, b) => a.anchor - b.anchor); + + return groups.flatMap(g => g.rows); + }; + + const stripRedundantEasings = (keyframes, attrName) => { + const reg = getAttrReg(attrName); + + // Build list of "strip trigger" indices: + // identity-delta rows + implicit sentinel after last row + const triggers = []; + keyframes.forEach((kf, i) => { + const parsed = kf.deltas && kf.deltas[attrName]; + if (isIdentityParsed(parsed, reg)) triggers.push(i); + }); + triggers.push(keyframes.length); // implicit end-of-table sentinel + + triggers.forEach(triggerIdx => { + // Scan backward from triggerIdx - 1 (not including the trigger itself) + // Strip easing until we find and strip the last non-identity-delta row + for (let i = triggerIdx - 1; i >= 0; i--) { + const kf = keyframes[i]; + const parsed = kf.deltas && kf.deltas[attrName]; + // Strip easing from this row + if (kf.easings && kf.easings[attrName]) { + delete kf.easings[attrName]; + } + // If this row has a non-identity delta, stop here + if (parsed && !isIdentityParsed(parsed, reg)) break; + } + }); + }; + + /** + * Resolve all keyframe timestamps for a playback session upfront. + * Called at startPlayback and at each loop cycle reset. + */ + const resolveAllKfTimes = (pb, kfs) => { + pb.resolvedTimes = {}; + for (let i = 0; i < kfs.length; i++) { + resolveKfTime(pb, kfs, i); + } + }; + + /** + * Resolve the timestamp for keyframe at index i. + * Caches result in pb.resolvedTimes[i]. + * Time expressions have access to only one variable: + * prev — previous resolved timestamp (ms) + * orig and curr are unavailable: orig is always 0 (write 0 directly), + * curr has no meaning since timestamps are resolved upfront. + */ + const resolveKfTime = (pb, kfs, i) => { + if (pb.resolvedTimes[i] !== undefined) return pb.resolvedTimes[i]; + + const kf = kfs[i]; + const t = kf.time; + + if (typeof t === 'number') { + pb.resolvedTimes[i] = t; + return t; + } + + const prevResolved = i > 0 ? resolveKfTime(pb, kfs, i - 1) : 0; + + if (!t || (!('rel' in t) && !('abs' in t))) { + pb.resolvedTimes[i] = prevResolved; + return prevResolved; + } + + // Only prev is available in time expressions — evaluate in TIME_EXPR_SCOPE + const evalTime = (expr) => evalTimeExpr(expr, prevResolved); + + let resolved; + try { + if ('rel' in t) { + const delta = t.isExpr ? evalTime(String(t.rel)) : Number(t.rel); + resolved = prevResolved + delta; + } else { + resolved = t.isExpr ? evalTime(String(t.abs)) : Number(t.abs); + } + } catch(e) { + log(`${SCRIPT_NAME}: time expression error at kf ${i}: ${e.message}`); + resolved = prevResolved; + } + + pb.resolvedTimes[i] = resolved; + return resolved; + }; + + /** + * Resolve any expression deltas in a keyframe against the given state. + * Returns a new deltas object with all {expr} entries replaced by + * concrete {abs} or {delta} values. + * + * @param {object} deltas - raw kf.deltas + * @param {object} initialState - pb.initialState (orig) + * @param {object} prevState - running state before this keyframe (prev) + * @param {object} liveObj - Roll20 graphic object (curr) + */ + const resolveDeltas = (deltas, initialState, prevState, liveObj, cumulative, t, memo) => { + const resolved = {}; + Object.entries(deltas || {}).forEach(([attrName, parsed]) => { + if (!parsed || !parsed.expr) { resolved[attrName] = parsed; return; } + const reg = getAttrReg(attrName); + if (!reg) { resolved[attrName] = parsed; return; } + + const orig = initialState[attrName] !== undefined ? initialState[attrName] : 0; + const prev = prevState[attrName] !== undefined ? prevState[attrName] : orig; + const curr = liveObj ? (reg.get(liveObj) || 0) : prev; + + try { + const val = evalExpr(parsed.expr, orig, prev, curr, + { obj: liveObj, t: t || 0, memo: memo || null, isContinuousSegment: false, cumulative: cumulative || {} }); + if (parsed.mode === 'abs') { + resolved[attrName] = { abs: val }; + } else if (parsed.mode === 'mul') { + resolved[attrName] = { delta: val }; + } else { + // add mode — apply sign + resolved[attrName] = { delta: (parsed.sign || 1) * val }; + } + } catch(e) { + log(`${SCRIPT_NAME}: expression error for ${attrName}: ${e.message}`); + resolved[attrName] = parsed; // leave unresolved, skip gracefully + } + }); + return resolved; + }; + + const makeShadow = (initialState) => ({ + _state: Object.assign({}, initialState), + get(k) { return this._state[k]; }, + set(k, v) { this._state[k] = v; }, + }); + + const buildRunningState = (keyframes, upToIndex, initialState) => { + const shadow = makeShadow(initialState); + for (let i = 0; i <= upToIndex && i < keyframes.length; i++) { + if (keyframes[i].type === 'parse-error') continue; + Object.entries(keyframes[i].deltas || {}).forEach(([attrName, parsed]) => { + if (!parsed) return; + const reg = getAttrReg(attrName); + if (!reg) return; + if ('abs' in parsed) reg.set(shadow, parsed.abs); + else if ('delta' in parsed) reg.apply(shadow, parsed.delta); + }); + } + return shadow._state; + }; + + /** + * Start playback of a recording on an object. + */ + const startPlayback = (objId, recording, attrCols, opts) => { + const obj = getObj('graphic', objId); + if (!obj) return false; + + const tracks = recording.tracks || {}; + const trackId = opts.trackId || Object.keys(tracks)[0]; + const track = tracks[trackId]; + if (!track || !track.keyframes || track.keyframes.length === 0) return false; + + // Snapshot current state as the "zero point" for delta application + const initialState = {}; + attrCols.forEach(attrName => { + const reg = getAttrReg(attrName); + if (!reg) return; + const val = reg.get(obj); + // Sanitize: numeric attrs default to 0 if empty/NaN + initialState[attrName] = (reg.valueType === 'number' && (val === '' || val === null || val === undefined || isNaN(val))) ? 0 : val; + }); + + const pb = { + recordingName: recording.name, + trackId, + keyframes: track.keyframes, + startTime: Date.now() - (opts.offset || 0), + speed: opts.speed || 1.0, + loop: opts.loop || false, + // loopMode: 'reset' (snap back to initialState each cycle) or + // 'accumulate' (new initialState = end state of prev cycle) + loopMode: opts.loopMode || 'reset', + loopsLeft: opts.loops !== undefined ? opts.loops : null, + reverse: opts.reverse || false, + easing: opts.easing || null, + onlyAttrs: opts.only || null, + skipAttrs: opts.exclude || null, + duration: recording.duration || 0, + paused: false, + pausedAt: null, + preview: opts.preview || false, + silent: opts.silent || false, + playerid: opts.playerid || null, + cumulative: {}, // per-playback scratchpad for registered functions + resolvedTimes: [0], // resolvedTimes[i] = resolved timestamp for kf[i] + currentEasings: {}, // per-attribute current easing, updated as empty-row easing switches are crossed + initialState, + lastKfIndex: -1, + }; + // Pre-compute absolute state at each keyframe index for fast lookup. + // Uses the shadow object so reg.apply handles multiplicative attrs correctly. + // runningStates[0] = initialState, runningStates[i+1] = after keyframe i. + const shadow = makeShadow(initialState); + const runningStates = [Object.assign({}, shadow._state)]; + (track.keyframes || []).forEach(kf => { + Object.entries(kf.deltas || {}).forEach(([attrName, parsed]) => { + if (!parsed) return; + const reg = getAttrReg(attrName); + if (!reg) return; + if ('abs' in parsed) reg.set(shadow, parsed.abs); + else if ('delta' in parsed) reg.apply(shadow, parsed.delta); + }); + runningStates.push(Object.assign({}, shadow._state)); + }); + pb.runningStates = runningStates; + + // Resolve all keyframe timestamps upfront so lerp durations are known + // before the first tick fires + resolveAllKfTimes(pb, pb.keyframes); + + activePlayback[objId] = pb; + + // Start interval + playbackIntervals[objId] = setInterval( + () => tickPlayback(objId), + PLAYBACK_INTERVAL_MS + ); + + return true; + }; + + /** + * Single playback tick — called at PLAYBACK_FPS. + * + * All attributes are driven from absolute values derived by accumulating + * deltas from initialState. This avoids double-application bugs that arise + * from mixing delta-based applyKeyframe with absolute-based lerp. + * + * Step attributes (non-lerp-able) are applied once when their keyframe + * timestamp is first crossed. Lerp attributes are interpolated every tick + * between the surrounding keyframes. + */ + const tickPlayback = (objId) => { + const pb = activePlayback[objId]; + if (!pb || pb.paused) return; + + const obj = getObj('graphic', objId); + if (!obj) { stopPlayback(objId); return; } + + const kfs = pb.reverse ? [...pb.keyframes].reverse() : pb.keyframes; + + const elapsed = (Date.now() - pb.startTime) * pb.speed; + const duration = pb.duration; + if (duration <= 0) { stopPlayback(objId); return; } + + let t = elapsed; + let stopping = false; + + if (t >= duration) { + if (pb.loop || (pb.loopsLeft !== null && pb.loopsLeft > 0)) { + if (pb.loopsLeft !== null) pb.loopsLeft--; + if (pb.loopMode === 'accumulate') { + // New initialState = state at end of this cycle + // runningStates last entry = state after all keyframes + pb.initialState = pb.runningStates[pb.runningStates.length - 1]; + // Recompute runningStates from the new initialState + const shadow = makeShadow(pb.initialState); + const newRS = [Object.assign({}, shadow._state)]; + pb.keyframes.forEach(kf => { + Object.entries(kf.deltas || {}).forEach(([attrName, parsed]) => { + if (!parsed) return; + const reg = getAttrReg(attrName); + if (!reg) return; + if ('abs' in parsed) reg.set(shadow, parsed.abs); + else if ('delta' in parsed) reg.apply(shadow, parsed.delta); + }); + newRS.push(Object.assign({}, shadow._state)); + }); + pb.runningStates = newRS; + } + pb.startTime = Date.now(); + pb.lastKfIndex = -1; + pb.memoCache = {}; // reset function memo on next loop + pb.cumulative = {}; // reset scratchpad each loop cycle + pb.currentEasings = {}; // reset easing switches each loop cycle + pb.rotNudgeDir = 1; // reset nudge direction each loop + // Re-resolve all timestamps for this loop cycle + resolveAllKfTimes(pb, kfs); + t = 0; + } else { + t = duration; + stopping = true; + } + } + + // Snap t to the final keyframe if within half a tick AND we're already + // past the second-to-last keyframe (i.e. in the final segment). + // This ensures the last segment completes without snapping past intermediate keyframes. + const HALF_TICK = PLAYBACK_INTERVAL_MS / 2; + const lastKfTime = pb.resolvedTimes[kfs.length - 1]; + const secondLastKfTime = kfs.length >= 2 ? pb.resolvedTimes[kfs.length - 2] : 0; + if (lastKfTime !== undefined && t >= secondLastKfTime && Math.abs(lastKfTime - t) <= HALF_TICK) { + t = lastKfTime; + } + + // Normalized time (0-1) for expression scope + const tNorm = t / duration; + + let prevIdx = -1; + let nextIdx = kfs.length; + for (let i = 0; i < kfs.length; i++) { + const rt = pb.resolvedTimes[i] !== undefined + ? pb.resolvedTimes[i] : (typeof kfs[i].time === 'number' ? kfs[i].time : Infinity); + if (rt <= t) prevIdx = i; + else { nextIdx = i; break; } + } + + // runningStates[0] = initialState, runningStates[i+1] = after kf[i] + // So state after kf at index i = pb.runningStates[i+1] + // State before any keyframe = pb.runningStates[0] + const rs = pb.runningStates; + const stateAt = (idx) => rs[idx + 1] || rs[0]; // idx -1 → rs[0] + + // ── Command keyframes, step attributes, and easing switches ───────────── + for (let i = pb.lastKfIndex + 1; i <= prevIdx; i++) { + const kf = kfs[i]; + + // Skip parse-error rows entirely during playback + if (kf.type === 'parse-error') continue; + + // Track easing switches from empty-row easing cells + // An empty-row easing (no delta for this attr) switches the current easing + // for future lerps of that attribute + Object.entries(kf.easings || {}).forEach(([attrName, easingExpr]) => { + if (!easingExpr) return; + const hasDelta = kf.deltas && attrName in kf.deltas; + if (!hasDelta) { + pb.currentEasings[attrName] = easingExpr; + } + }); + + // Fire command keyframes + if (kf.type === 'command' && kf.command) { + const cmd = kf.command + .replace(/\{\{tokenId\}\}/g, obj.get('id')) + .replace(/\{\{tokenName\}\}/g, obj.get('name') || ''); + // Send as the player who started playback so permissions and + // whisper targets work correctly. Fall back to 'gm' if unavailable. + let sender = 'gm'; + if (pb.playerid) { + const player = getObj('player', pb.playerid); + if (player) sender = player.get('_displayname') || 'gm'; + } + sendChat(sender, cmd); + } + + // Apply step (non-lerp) attributes + if (kf.type !== 'command') { + const prevState = stateAt(i - 1); + const state = stateAt(i); + // Resolve any expression deltas before applying + const resolvedDeltas = resolveDeltas(kf.deltas, pb.initialState, prevState, obj, pb.cumulative, tNorm, {}); + // Re-apply with resolved deltas via shadow + const shadow = makeShadow(prevState); + Object.entries(resolvedDeltas || {}).forEach(([attrName, parsed]) => { + if (!parsed) return; + const reg = getAttrReg(attrName); + if (!reg || reg.lerp !== null) return; + if ('abs' in parsed) reg.set(shadow, parsed.abs); + else if ('delta' in parsed) reg.apply(shadow, parsed.delta); + }); + Object.keys(resolvedDeltas || {}).forEach(attrName => { + if (pb.onlyAttrs && !pb.onlyAttrs.includes(attrName)) return; + if (pb.skipAttrs && pb.skipAttrs.includes(attrName)) return; + const reg = getAttrReg(attrName); + if (!reg || reg.lerp !== null) return; + const val = shadow._state[attrName]; + if (val !== undefined) reg.set(obj, val); + }); + } + } + pb.lastKfIndex = prevIdx; + + // ── Lerp attributes ────────────────────────────────────────────────── + // Collect lerp-able attrs that appear anywhere in the recording + const lerpAttrs = new Set(); + kfs.forEach(kf => { + Object.keys(kf.deltas || {}).forEach(attrName => { + const reg = getAttrReg(attrName); + if (reg && reg.lerp !== null) lerpAttrs.add(attrName); + }); + }); + + const prevAbsState = stateAt(prevIdx); + + const batchSet = {}; + lerpAttrs.forEach(attrName => { + if (pb.onlyAttrs && !pb.onlyAttrs.includes(attrName)) return; + if (pb.skipAttrs && pb.skipAttrs.includes(attrName)) return; + + const reg = getAttrReg(attrName); + if (!reg || !reg.lerp) return; + + const prevVal = prevAbsState[attrName]; + if (prevVal === undefined) return; + + let interpolated; + if (nextIdx < kfs.length) { + const nextKf = kfs[nextIdx]; + const hasDeltaForAttr = nextKf.deltas && attrName in nextKf.deltas; + if (hasDeltaForAttr) { + // Resolve expression if needed to get the target value + let nextParsed = nextKf.deltas[attrName]; + if (nextParsed && nextParsed.expr) { + const srcEasing = prevIdx >= 0 && kfs[prevIdx].easings && kfs[prevIdx].easings[attrName] + ? kfs[prevIdx].easings[attrName] + : (pb.currentEasings[attrName] || pb.easing || 'linear'); + const isContinuous = srcEasing === 'continuous'; + const orig = pb.initialState[attrName] !== undefined ? pb.initialState[attrName] : 0; + const prev = prevAbsState[attrName] !== undefined ? prevAbsState[attrName] : orig; + + // Per-segment memo cache for function memoization + const memoKey = `${nextIdx}:${attrName}`; + if (!pb.memoCache) pb.memoCache = {}; + if (!pb.memoCache[memoKey]) pb.memoCache[memoKey] = {}; + const memo = pb.memoCache[memoKey]; + + try { + const val = evalExpr(nextParsed.expr, orig, prev, undefined, + { obj, reg, t: tNorm, memo, isContinuousSegment: isContinuous, cumulative: pb.cumulative || {} }); + let resolved = nextParsed.mode === 'abs' ? { abs: val } + : nextParsed.mode === 'mul' ? { delta: val } + : { delta: (nextParsed.sign || 1) * val }; + // In continuous segments, use resolved value directly (no lerp) + if (isContinuous) { + const shadow = makeShadow(prevAbsState); + if ('abs' in resolved) reg.set(shadow, resolved.abs); + else if ('delta' in resolved) reg.apply(shadow, resolved.delta); + interpolated = shadow._state[attrName]; + } else { + // Non-continuous: memoization ensures same result each tick, + // so we can use it as the lerp target + nextParsed = resolved; + } + } catch(e) { + log(`${SCRIPT_NAME}: lerp expr error for ${attrName}: ${e.message}`); + } + } + if (interpolated === undefined) { + // Normal lerp path (non-continuous expressions or non-expression deltas) + const shadow = makeShadow(prevAbsState); + if (nextParsed && 'abs' in nextParsed) reg.set(shadow, nextParsed.abs); + else if (nextParsed && 'delta' in nextParsed) reg.apply(shadow, nextParsed.delta); + const nextVal = shadow._state[attrName]; + const prevTime = prevIdx >= 0 + ? (pb.resolvedTimes[prevIdx] !== undefined ? pb.resolvedTimes[prevIdx] : (typeof kfs[prevIdx].time === 'number' ? kfs[prevIdx].time : 0)) + : 0; + const nextTime = pb.resolvedTimes[nextIdx] !== undefined + ? pb.resolvedTimes[nextIdx] + : (typeof nextKf.time === 'number' ? nextKf.time : prevTime); + const segDur = nextTime - prevTime; + const segElapsed = t - prevTime; + const segT = segDur > 0 ? segElapsed / segDur : 1; + const lerpEasing = prevIdx >= 0 && kfs[prevIdx].easings && kfs[prevIdx].easings[attrName] + ? kfs[prevIdx].easings[attrName] + : (pb.currentEasings[attrName] || pb.easing || 'linear'); + interpolated = reg.lerp(prevVal, nextVal, getEasing(lerpEasing)(segT)); + } + } else { + interpolated = prevVal; + } + } else { + interpolated = prevVal; + } + + // Collect position attrs for batching with rotation nudge + if (attrName === 'left' || attrName === 'top') { + if (interpolated !== undefined) batchSet[attrName] = interpolated; + } else if (attrName === 'rotation') { + if (reg.valueType === 'number' && isNaN(interpolated)) return; + // Always put rotation in batchSet so it goes out with position + // in a single obj.set() call — prevents nudge from overwriting it + batchSet.rotation = ((interpolated % 360) + 360) % 360; + } else { + if (reg.valueType === 'number' && isNaN(interpolated)) return; + reg.set(obj, interpolated); + } + }); + + // hasRealRotation = rotation is actually changing in the current segment + const hasPositionLerp = lerpAttrs.has('left') || lerpAttrs.has('top'); + const hasRealRotation = (() => { + if (!lerpAttrs.has('rotation')) return false; + if (nextIdx >= kfs.length) return false; + const d = kfs[nextIdx].deltas && kfs[nextIdx].deltas['rotation']; + if (!d) return false; + if ('abs' in d) return true; + if ('delta' in d) return d.delta !== 0; + return !!d.expr; + })(); + + // Batch position updates with rotation nudge in a single obj.set() + // so Roll20 renders position immediately rather than batching it + if (Object.keys(batchSet).length > 0) { + if (hasPositionLerp && !hasRealRotation && !stopping) { + const rot = typeof batchSet.rotation === 'number' ? batchSet.rotation : (parseFloat(obj.get('rotation')) || 0); + pb.rotNudgeDir = pb.rotNudgeDir === 1 ? -1 : 1; + const nudged = rot + 0.001 * pb.rotNudgeDir; + if (!isNaN(nudged)) batchSet.rotation = nudged; + } + // Strip any NaN values before sending to Firebase + Object.keys(batchSet).forEach(k => { if (isNaN(batchSet[k])) delete batchSet[k]; }); + if (Object.keys(batchSet).length > 0) obj.set(batchSet); + } else if (hasPositionLerp && !hasRealRotation && !stopping) { + const rot = parseFloat(obj.get('rotation')) || 0; + pb.rotNudgeDir = pb.rotNudgeDir === 1 ? -1 : 1; + const nudged = rot + 0.001 * pb.rotNudgeDir; + if (!isNaN(nudged)) obj.set('rotation', nudged); + } + + if (stopping) { + const playerid = pb.playerid; + const recName = pb.recordingName; + stopPlayback(objId); // auto-reverts if preview:true + // Send the stopped/reverted menu to the owning player (unless silent) + if (playerid && !pb.silent) sendPlaybackMenuTo(playerid, objId, recName); + } + }; + + // Last known initialState per object — persists after playback ends so + // the revert button can restore the token to its pre-playback state. + const lastInitialState = {}; + + const stopPlayback = (objId, forceRevert) => { + if (playbackIntervals[objId]) { + clearInterval(playbackIntervals[objId]); + delete playbackIntervals[objId]; + } + const pb = activePlayback[objId]; + if (pb) { + lastInitialState[objId] = pb.initialState; + // Auto-revert if preview mode and not looping + if (forceRevert || (pb.preview && !pb.loop)) { + revertPlayback(objId); + } + } + delete activePlayback[objId]; + }; + + /** + * Revert a token to its state at the start of the last playback. + */ + const revertPlayback = (objId) => { + const obj = getObj('graphic', objId); + const state = lastInitialState[objId]; + if (!obj || !state) return false; + Object.entries(state).forEach(([attrName, val]) => { + const reg = getAttrReg(attrName); + if (reg && val !== undefined) reg.set(obj, val); + }); + return true; + }; + + const pausePlayback = (objId) => { + const pb = activePlayback[objId]; + if (!pb || pb.paused) return; + pb.paused = true; + pb.pausedAt = Date.now(); + }; + + const resumePlayback = (objId) => { + const pb = activePlayback[objId]; + if (!pb || !pb.paused) return; + pb.startTime += Date.now() - pb.pausedAt; + pb.paused = false; + pb.pausedAt = null; + }; + + // ========================================================================= + // Chat helpers + // ========================================================================= + + const reply = (msg, tag, text, noarchive = false) => { + const body = text !== undefined ? text : tag; + const prefix = text !== undefined ? ` [${tag}]` : ''; + const player = getObj('player', msg.playerid); + const recipient = player ? player.get('_displayname') : msg.who.replace(' (GM)', ''); + sendChat(`${SCRIPT_NAME}${prefix}`, `/w "${recipient}" ${body}`, + null, noarchive ? { noarchive: true } : undefined); + }; + + const replyError = (msg, text) => reply(msg, 'Error', text); + + const resolveObjIds = (msg, flags, extras) => { + const fromSelected = flags.has('ignore-selected') + ? [] + : (msg.selected || []).map(s => s._id); + return [...fromSelected, ...extras].filter(id => !!getObj('graphic', id)); + }; + + // ========================================================================= + // Playback menu + // ========================================================================= + + const showRecordingMenu = (msg, objIds) => { + objIds.forEach(objId => { + const session = activeSessions[objId]; + const obj = getObj('graphic', objId); + if (!obj) return; + + const tokenName = obj.get('name') || objId; + const recName = session ? (session.name || '(unnamed)') : '—'; + const status = !session ? 'stopped' + : session.paused ? 'paused' : 'recording'; + const elapsed = session && !session.paused + ? Math.round(Date.now() - session.startTime) : 0; + const kfCount = session ? session.keyframes.length : 0; + + const tokenImgsrc = obj.get('imgsrc') || ''; + const tokenThumb = tokenImgsrc + ? `` + : ''; + + let html = `

`; + html += `${tokenThumb}${escHtml(tokenName)}
`; + html += `Recording: ${escHtml(recName)}
`; + html += `Status: ${status}`; + if (session) html += `   ${elapsed}ms   ${kfCount} keyframe${kfCount !== 1 ? 's' : ''}`; + html += `

`; + + if (session) { + if (session.paused) { + html += btnHtml('▶ Resume', `${CMD_TOKEN} resume ignore-selected ${objId}`); + } else { + html += btnHtml('⏸ Pause', `${CMD_TOKEN} pause ignore-selected ${objId}`); + } + html += btnHtml('⏹ Stop', `${CMD_TOKEN} stop ignore-selected ${objId}`); + } + html += btnHtml('🔄 Refresh', `${CMD_TOKEN} recording-menu ignore-selected ${objId}`); + html += `
`; + + reply(msg, 'Recording', html, true); + }); + }; + + /** + * Send the playback menu whisper directly to a player by ID, without + * needing a msg object. Used by tickPlayback when playback ends naturally. + */ + const sendPlaybackMenuTo = (playerid, objId, recName) => { + const player = getObj('player', playerid); + if (!player) return; + // Build a minimal fake msg so showPlaybackMenu can use reply() + const fakeName = player.get('_displayname') || player.get('displayname') || 'gm'; + const fakeMsg = { who: fakeName, playerid }; + showPlaybackMenu(fakeMsg, [objId], recName); + }; + + /** + * Show the playback menu for one or more objects. + * @param {object} msg Roll20 chat message + * @param {string[]} objIds Object IDs to show menus for + * @param {string|null} recName Optional recording name — shown even if not + * currently playing, with a Play button ready + */ + const showPlaybackMenu = (msg, objIds, recName) => { + objIds.forEach(objId => { + const pb = activePlayback[objId]; + const obj = getObj('graphic', objId); + if (!obj) return; + + const tokenName = obj.get('name') || objId; + // Prefer active playback's recording name, then caller-supplied name + const displayName = pb ? pb.recordingName : (recName || null); + const status = !pb ? (recName ? 'ready' : 'stopped') + : pb.paused ? 'paused' : 'playing'; + const elapsed = pb && !pb.paused + ? Math.round((Date.now() - pb.startTime) * pb.speed) : 0; + const dur = pb ? pb.duration : 0; + + // Refresh button always includes the recording name if we have one + const refreshCmd = displayName + ? `${CMD_TOKEN} playback-menu ${escArg(displayName)} ignore-selected ${objId}` + : `${CMD_TOKEN} playback-menu ignore-selected ${objId}`; + + const tokenImgsrc = obj.get('imgsrc') || ''; + const tokenThumb = tokenImgsrc + ? `` + : ''; + + let html = `
`; + html += `${tokenThumb}${escHtml(tokenName)}
`; + if (displayName) html += `Recording: ${escHtml(displayName)}
`; + html += `Status: ${status}`; + if (pb) html += `   ${elapsed}ms / ${dur}ms`; + html += `

`; + + if (pb) { + // ── Active playback controls ────────────────────────────── + if (pb.paused) { + html += btnHtml('▶ Resume', `${CMD_TOKEN} resume-play ignore-selected ${objId}`); + } else { + html += btnHtml('⏸ Pause', `${CMD_TOKEN} pause-play ignore-selected ${objId}`); + } + // Stop label depends on preview mode + const stopLabel = pb.preview ? '⏹ Stop+Revert' : '⏹ Stop'; + html += btnHtml(stopLabel, `${CMD_TOKEN} stop-play ignore-selected ${objId}`); + if (!pb.preview) { + html += btnHtml('⏮ Restart', `${CMD_TOKEN} play ${escArg(pb.recordingName)} ignore-selected ${objId}`); + } + // Loop toggle — cycles OFF → Reset → Accumulate → OFF + const loopLabel = !pb.loop ? '⟳ Loop: OFF' + : pb.loopMode === 'accumulate' ? '⟳ Loop: Accumulate' + : '⟳ Loop: Reset'; + const loopRecArg = displayName ? ` ${escArg(displayName)}` : ''; + html += btnHtml(loopLabel, `${CMD_TOKEN} toggle-loop${loopRecArg} ignore-selected ${objId}`); + } else { + // ── Stopped / ready controls ────────────────────────────── + if (displayName) { + html += btnHtml('▶ Play', `${CMD_TOKEN} play ${escArg(displayName)} ignore-selected ${objId}`); + html += btnHtml('👁 Preview', `${CMD_TOKEN} preview ${escArg(displayName)} ignore-selected ${objId}`); + } + // Revert only shown if there's a state to revert to + if (lastInitialState[objId]) { + html += btnHtml('↩ Revert', `${CMD_TOKEN} revert ignore-selected ${objId}`); + } + } + html += btnHtml('🔄 Refresh', refreshCmd); + html += `
`; + + reply(msg, 'Playback', html, true); + }); + }; + + // ========================================================================= + // Command handler + // ========================================================================= + + const handleInput = (msg) => { + if (msg.type !== 'api') return; + if (msg.content.split(' ')[0] !== CMD_TOKEN) return; + if (!playerIsGM(msg.playerid)) { + replyError(msg, 'Only the GM can use Sequence commands.'); + return; + } + + try { + const raw = msg.content.slice(CMD_TOKEN.length).trim().split(/\s+/).filter(Boolean); + const cmd = raw[0]; + const rest = raw.slice(1); + + // Parse flags and plain args + const flags = new Set(); + const args = []; + const opts = {}; + + rest.forEach((tok, i) => { + if (tok === 'ignore-selected') { flags.add('ignore-selected'); return; } + if (tok.startsWith('--')) { + const [k, v] = tok.slice(2).split('='); + if (v !== undefined) opts[k] = v; + else opts[k] = rest[i + 1] || true; // next token as value + flags.add(k); + return; + } + args.push(tok); + }); + + // Resolve object IDs (anything that looks like a Roll20 ID in args) + // Roll20 IDs start with '-' and contain alphanumeric, underscore, hyphen. + // Don't validate via getObj here — objects may not be loaded yet after + // sandbox restart. resolveObjIds filters out non-existent objects. + const isRollId = (a) => /^-[A-Za-z0-9_-]+$/.test(a); + const idArgs = args.filter(isRollId); + const nonIds = args.filter(a => !isRollId(a)); + const objIds = resolveObjIds(msg, flags, idArgs); + + // ---------------------------------------------------------------- + if (cmd === '--help') { + reply(msg, HELP_TEXT); + return; + } + + // ---------------------------------------------------------------- + if (cmd === 'record') { + const name = nonIds[0] || null; + const attrFilter = opts.attrs ? opts.attrs.split(',') : null; + if (objIds.length === 0) { + replyError(msg, 'Select or specify at least one token to record.'); + return; + } + let started = 0; + objIds.forEach(id => { + if (startRecording(id, name, attrFilter, msg.playerid)) started++; + }); + const startedIds = objIds.filter(id => activeSessions[id]); + showRecordingMenu(msg, startedIds); + return; + } + + // ---------------------------------------------------------------- + if (cmd === 'stop') { + if (objIds.length === 0) { + replyError(msg, 'Select or specify at least one token.'); + return; + } + objIds.forEach(id => { + const session = stopRecording(id); + if (!session) return; + if (session.name) { + saveRecording(session.name, session, (handout) => { + const recipient = msg.who.split(' ')[0]; + sendChat(`${SCRIPT_NAME} [Record]`, + `/w ${recipient} Stopped and saved ` + + `${escHtml(session.name)} — ` + + `${session.keyframes.length} keyframes, ${session.duration}ms.`); + }); + } else { + const recipient = msg.who.split(' ')[0]; + sendChat(`${SCRIPT_NAME} [Record]`, + `/w ${recipient} Stopped recording ` + + `(${session.keyframes.length} keyframes, ${session.duration}ms). ` + + `Use !sequence save <name> to save it.`); + s().unsavedSessions = s().unsavedSessions || {}; + s().unsavedSessions[id] = session; + } + }); + return; + } + + // ---------------------------------------------------------------- + if (cmd === 'pause') { + objIds.forEach(id => pauseRecording(id)); + showRecordingMenu(msg, objIds); + return; + } + + if (cmd === 'resume') { + objIds.forEach(id => resumeRecording(id)); + showRecordingMenu(msg, objIds); + return; + } + + // ---------------------------------------------------------------- + if (cmd === 'save') { + const name = nonIds[0]; + if (!name) { replyError(msg, 'Usage: !sequence save '); return; } + if (objIds.length === 0) { + replyError(msg, 'Select or specify at least one token.'); + return; + } + const existing = findHandout(name); + if (existing && !flags.has('force')) { + replyError(msg, + `A recording named "${name}" already exists. ` + + `Use --force to overwrite.`); + return; + } + objIds.forEach(id => { + // Look for an unsaved session for this object + const session = (s().unsavedSessions || {})[id]; + if (!session) { + reply(msg, 'Record', + `No unsaved recording found for ${id}. ` + + `Start a recording with !sequence record.`); + return; + } + delete s().unsavedSessions[id]; + saveRecording(name, session, () => { + reply(msg, 'Record', + `Saved recording "${name}". ` + + `${session.keyframes.length} keyframes, ${session.duration}ms.`); + }); + }); + return; + } + + // ---------------------------------------------------------------- + if (cmd === 'list') { + const handouts = findAllRecordingHandouts(); + if (handouts.length === 0) { + reply(msg, 'Sequence', 'No recordings found.'); + return; + } + let out = `${handouts.length} recording(s):
`; + handouts.forEach(h => { + const recName = h.get('name').slice(HANDOUT_PREFIX.length); + out += `• ${escHtml(recName)} ` + + `[${btnHtml('Edit', `${CMD_TOKEN} edit ${escArg(recName)}`)} ` + + `${btnHtml('Play', `${CMD_TOKEN} play ${escArg(recName)}`)}]
`; + }); + reply(msg, 'Sequence', out); + return; + } + + // ---------------------------------------------------------------- + if (cmd === 'edit') { + const name = nonIds[0]; + if (!name) { replyError(msg, 'Usage: !sequence edit '); return; } + const handout = findHandout(name); + if (!handout) { + replyError(msg, `No recording named "${name}" found.`); + return; + } + // Whisper a link to open the handout + const handoutId = handout.get('id'); + reply(msg, 'Sequence', + `Opening recording "${name}": ` + + `` + + `[Open Handout]`); + return; + } + + // ---------------------------------------------------------------- + if (cmd === 'delete') { + const name = nonIds[0]; + if (!name) { replyError(msg, 'Usage: !sequence delete '); return; } + if (!flags.has('force')) { + replyError(msg, + `Are you sure you want to delete "${name}"? ` + + btnHtml('Yes, delete', `${CMD_TOKEN} delete ${escArg(name)} --force`)); + return; + } + const handout = findHandout(name); + if (!handout) { + replyError(msg, `No recording named "${name}" found.`); + return; + } + handout.remove(); + delete recordingCache[name]; + reply(msg, 'Sequence', `Deleted recording "${name}".`); + return; + } + + // ---------------------------------------------------------------- + if (cmd === 'play') { + const name = nonIds[0]; + if (!name) { replyError(msg, 'Usage: !sequence play '); return; } + if (objIds.length === 0) { + replyError(msg, 'Select or specify at least one token.'); + return; + } + loadRecording(name, (result) => { + if (!result) { + replyError(msg, `No recording named "${name}" found.`); + return; + } + const { recording, attrCols } = result; + const playOpts = { + speed: opts.speed ? parseFloat(opts.speed) : 1.0, + loop: flags.has('loop'), + loops: opts.loops ? parseInt(opts.loops, 10) : undefined, + reverse: flags.has('reverse'), + offset: opts.offset ? parseInt(opts.offset, 10): 0, + easing: opts.easing || null, + only: opts.only ? opts.only.split(',') : null, + exclude: opts.exclude ? opts.exclude.split(',') : null, + playerid: msg.playerid, + silent: !!msg.sceneInfo || flags.has('silent'), + }; + let started = 0; + objIds.forEach(id => { + if (startPlayback(id, recording, attrCols, playOpts)) started++; + }); + if (!msg.sceneInfo) showPlaybackMenu(msg, objIds.filter(id => activePlayback[id]), name); + }); + return; + } + + // ---------------------------------------------------------------- + if (cmd === 'stop-play') { + const stoppedNames = objIds.map(id => + activePlayback[id] ? activePlayback[id].recordingName : null + ); + objIds.forEach(id => stopPlayback(id)); // auto-reverts if preview mode + if (!msg.sceneInfo) objIds.forEach((id, i) => showPlaybackMenu(msg, [id], stoppedNames[i])); + return; + } + + if (cmd === 'pause-play') { + objIds.forEach(id => pausePlayback(id)); + showPlaybackMenu(msg, objIds); + return; + } + + if (cmd === 'resume-play') { + objIds.forEach(id => resumePlayback(id)); + showPlaybackMenu(msg, objIds); + return; + } + + // ---------------------------------------------------------------- + if (cmd === 'toggle-loop') { + if (objIds.length === 0) { + replyError(msg, 'Select or specify at least one token.'); + return; + } + objIds.forEach(id => { + const pb = activePlayback[id]; + if (!pb) return; + // Cycle: OFF → Reset → Accumulate → OFF + if (!pb.loop) { + pb.loop = true; + pb.loopMode = 'reset'; + } else if (pb.loopMode === 'reset') { + pb.loopMode = 'accumulate'; + } else { + pb.loop = false; + pb.loopMode = 'reset'; + } + }); + const menuRecName = nonIds[0] || null; + showPlaybackMenu(msg, objIds, menuRecName); + return; + } + + // ---------------------------------------------------------------- + if (cmd === 'revert') { + if (objIds.length === 0) { + replyError(msg, 'Select or specify at least one token.'); + return; + } + let reverted = 0; + objIds.forEach(id => { if (revertPlayback(id)) reverted++; }); + if (reverted === 0) { + replyError(msg, 'No revert state available. Play a recording first.'); + return; + } + // Refresh the playback menu after reverting + const revertRecName = nonIds[0] || null; + showPlaybackMenu(msg, objIds, revertRecName); + return; + } + + // ---------------------------------------------------------------- + if (cmd === 'playback-menu') { + // Optional first non-ID arg is a recording name + const menuRecName = nonIds[0] || null; + showPlaybackMenu(msg, objIds, menuRecName); + return; + } + + // ---------------------------------------------------------------- + if (cmd === 'preview') { + const name = nonIds[0]; + if (!name) { replyError(msg, 'Usage: !sequence preview '); return; } + if (objIds.length === 0) { + replyError(msg, 'Select or specify at least one token to preview on.'); + return; + } + loadRecording(name, (result) => { + if (!result) { replyError(msg, `No recording named "${name}" found.`); return; } + const { recording, attrCols } = result; + objIds.forEach(id => startPlayback(id, recording, attrCols, { preview: true, playerid: msg.playerid })); + showPlaybackMenu(msg, objIds.filter(id => activePlayback[id]), name); + }); + return; + } + + // ---------------------------------------------------------------- + if (cmd === 'add-row') { + // Usage: !sequence add-row