Files
RUWIO/src/stores/scene.ts
T
thandClaude Opus 4.7 31604052ca revert macro-clips button feature (keep markers + scripts)
The button authoring ended up too far from the intended model after
three correction passes. Pulling it out entirely so we can rebuild
from a clean spec. Markers, script tracks, script frames, the Action
editor, the Script DSL and the runtime for all of the above stay —
they work.

Removed:
- types: ButtonState, StateTransition, ButtonHitArea, ButtonClip,
  ButtonInstance, setButtonState/setButtonDisabled Action variants,
  Scene.buttonClips/buttonInstances
- store: activeButtonClip* getters, all add/remove/setButton* actions,
  seedDefaultButtonClip, enterButtonEdit/setActiveButtonState/
  exitButtonEdit, button-label resolution in actionRuntime
- useButtonRuntime composable, buttonBlend helper,
  ButtonInstanceDialog, sidebar Buttons section, Timeline button
  rendering + pointer handlers, CanvasStage button rendering +
  pointer state machine, exportPlayer button runtime + hit-testing
- DSL commands state/enable/disable and their parse errors
- 35 button-related tests + helpers; stale testing doc; stale design
  doc

Re-applied three uncommitted fixes that were unrelated to the button
work:
- clicking a marker no longer jumps the playhead (only drag / dblclick
  act on it)
- script tracks get a right-click context menu (rename + delete,
  color in the existing palette)
- onTracksPointerDown excludes .script-lane / .script-frame so
  clicks on empty lanes or on frame dots reach the frame-open path
  instead of being swallowed by box-select pointer capture

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 23:37:49 +02:00

1449 lines
51 KiB
TypeScript

import { defineStore } from 'pinia'
import type {
Action,
AnimatableProps,
Asset,
BackgroundVideo,
Clip,
ClipInstance,
ClipTrack,
Easing,
Keyframe,
Layer,
Marker,
MaskMode,
Scene,
ScriptFrame,
ScriptTrack,
TrackGroup,
} from '@/types/scene'
import { DEFAULT_BASE_PROPS } from '@/types/scene'
import { sampleLayer } from '@/lib/interpolate'
import {
buildMarkerMap,
evalAction,
scriptFramesInRange,
type ActionRuntime,
} from '@/lib/actions'
const uid = () => Math.random().toString(36).slice(2, 10)
type AssetInit = Asset extends infer A ? (A extends Asset ? Omit<A, 'id'> : never) : never
const KEYFRAME_EPSILON = 1e-3
interface State {
scene: Scene
selectedLayerId: string | null
playhead: number
playing: boolean
activeClipId: string | null
drawingMode: 'freeform' | null
drawingAssetId: string | null
drawingLayerId: string | null
}
function allKeyframes(layer: Layer): Keyframe[] {
const out: Keyframe[] = [...layer.keyframes]
if (layer.positionKeyframes) out.push(...layer.positionKeyframes)
if (layer.rotationKeyframes) out.push(...layer.rotationKeyframes)
return out
}
function findKf(
layer: Layer,
keyframeId: string,
): { kf: Keyframe; arr: Keyframe[] } | null {
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
const kf = arr.find((k) => k.id === keyframeId)
if (kf) return { kf, arr }
}
return null
}
function sortKfs(arr: Keyframe[]) {
arr.sort((a, b) => a.time - b.time)
}
function upsertInto(
arr: Keyframe[],
time: number,
props: Partial<AnimatableProps>,
snapshotProps: Partial<AnimatableProps> | null,
) {
const existing = arr.find((k) => Math.abs(k.time - time) < KEYFRAME_EPSILON)
if (existing) {
existing.props = { ...existing.props, ...props }
return
}
const kf: Keyframe = {
id: uid(),
time,
props: snapshotProps ? { ...snapshotProps, ...props } : { ...props },
easing: 'linear',
}
arr.push(kf)
sortKfs(arr)
}
const POSITION_KEYS = new Set(['x', 'y'])
const ROTATION_KEYS = new Set(['rotation'])
function pickProps(
src: Partial<AnimatableProps>,
keys: Set<string>,
): Partial<AnimatableProps> {
const out: Partial<AnimatableProps> = {}
for (const [k, v] of Object.entries(src)) {
if (keys.has(k) && v !== undefined) (out as Record<string, unknown>)[k] = v
}
return out
}
function omitProps(
src: Partial<AnimatableProps>,
keys: Set<string>,
): Partial<AnimatableProps> {
const out: Partial<AnimatableProps> = {}
for (const [k, v] of Object.entries(src)) {
if (!keys.has(k) && v !== undefined) (out as Record<string, unknown>)[k] = v
}
return out
}
function hasKeys(obj: Partial<AnimatableProps>): boolean {
for (const _ in obj) return true
return false
}
function normalizeScene(scene: Scene): Scene {
scene.clips ||= []
scene.clipTracks ||= []
scene.clipInstances ||= []
scene.groups ||= []
scene.scriptTracks ||= []
scene.scriptFrames ||= []
scene.markers ||= []
if (!scene.canvas.background) scene.canvas.background = '#ffffff'
const known = new Set<string>()
for (const l of scene.layers) known.add(l.id)
for (const t of scene.clipTracks) known.add(t.id)
for (const g of scene.groups) known.add(g.id)
// Cleanup: group childIds should only reference existing layers/clipTracks
const layerAndClipIds = new Set<string>()
for (const l of scene.layers) layerAndClipIds.add(l.id)
for (const t of scene.clipTracks) layerAndClipIds.add(t.id)
for (const g of scene.groups) {
g.childIds = g.childIds.filter((id) => layerAndClipIds.has(id))
}
// Dedupe: any given track can only be in one group
const inGroup = new Set<string>()
for (const g of scene.groups) {
g.childIds = g.childIds.filter((id) => {
if (inGroup.has(id)) return false
inGroup.add(id)
return true
})
}
if (!scene.trackOrder) scene.trackOrder = []
scene.trackOrder = scene.trackOrder.filter((id) => known.has(id))
for (const l of scene.layers) {
if (!inGroup.has(l.id) && !scene.trackOrder.includes(l.id)) {
scene.trackOrder.unshift(l.id)
}
}
for (const t of scene.clipTracks) {
if (!inGroup.has(t.id) && !scene.trackOrder.includes(t.id)) {
scene.trackOrder.push(t.id)
}
}
for (const g of scene.groups) {
if (!scene.trackOrder.includes(g.id)) scene.trackOrder.push(g.id)
}
return scene
}
export function createBlankScene(): Scene {
return normalizeScene({
version: 1,
canvas: { width: 1280, height: 720, background: '#ffffff' },
duration: 10,
fps: 60,
assets: [],
layers: [],
})
}
export const useSceneStore = defineStore('scene', {
state: (): State => ({
scene: createBlankScene(),
selectedLayerId: null,
playhead: 0,
playing: false,
activeClipId: null,
drawingMode: null,
drawingAssetId: null,
drawingLayerId: null,
}),
getters: {
activeClip(state): Clip | null {
if (!state.activeClipId) return null
const clips = state.scene.clips ?? []
return clips.find((c) => c.id === state.activeClipId) ?? null
},
activeLayers(): Layer[] {
const clip = this.activeClip
return clip ? clip.layers : this.scene.layers
},
activeDuration(): number {
const clip = this.activeClip
return clip ? clip.duration : this.scene.duration
},
lastKeyframeTime(): number {
let max = 0
for (const layer of this.activeLayers) {
for (const kf of allKeyframes(layer)) {
if (kf.time > max) max = kf.time
}
}
return Math.min(this.activeDuration, max)
},
effectiveEnd(): number {
let max = 0
for (const layer of this.activeLayers) {
for (const kf of allKeyframes(layer)) {
if (kf.time > max) max = kf.time
}
}
if (!this.activeClipId) {
const clipById = new Map<string, Clip>()
for (const c of this.scene.clips ?? []) clipById.set(c.id, c)
for (const inst of this.scene.clipInstances ?? []) {
const clip = clipById.get(inst.clipId)
if (!clip) continue
const visible = Math.max(
0,
clip.duration - (inst.trimStart ?? 0) - (inst.trimEnd ?? 0),
)
const end = inst.startTime + visible
if (end > max) max = end
}
}
if (max <= 0) return this.activeDuration
return Math.min(max, this.activeDuration)
},
/** Effective in-point for playback/export. Defaults to 0, clamped to duration. */
playbackIn(): number {
if (this.activeClipId) return 0
const v = this.scene.inTime ?? 0
return Math.max(0, Math.min(this.scene.duration, v))
},
/** Effective out-point for playback/export. Defaults to duration, clamped >=in. */
playbackOut(): number {
if (this.activeClipId) return this.activeDuration
const v = this.scene.outTime ?? this.scene.duration
return Math.max(this.playbackIn, Math.min(this.scene.duration, v))
},
},
actions: {
addAsset(asset: AssetInit): Asset {
const created = { ...asset, id: uid() } as Asset
this.scene.assets.push(created)
return created
},
addLayer(assetId: string, name?: string): Layer {
const asset = this.scene.assets.find((a) => a.id === assetId)
const base: AnimatableProps = {
...DEFAULT_BASE_PROPS,
x: this.scene.canvas.width / 2,
y: this.scene.canvas.height / 2,
}
if (asset && 'fill' in asset) base.fill = asset.fill
const layer: Layer = {
id: uid(),
name: name ?? asset?.name ?? 'Layer',
assetId,
visible: true,
base,
keyframes: [
{
id: uid(),
time: 0,
props: { ...base },
easing: 'linear',
},
],
}
if (asset) {
if (asset.kind === 'rect' || asset.kind === 'image') {
layer.offsetX = asset.width / 2
layer.offsetY = asset.height / 2
} else if (asset.kind === 'circle' || asset.kind === 'text' || asset.kind === 'polygon') {
layer.offsetX = 0
layer.offsetY = 0
}
if (asset.kind === 'polygon') {
layer.keyframes[0].props = {
...layer.keyframes[0].props,
points: asset.points.map((p) => ({ x: p.x, y: p.y })),
}
}
}
this.activeLayers.unshift(layer)
if (!this.activeClipId) {
const order = (this.scene.trackOrder ||= [])
if (!order.includes(layer.id)) order.unshift(layer.id)
}
this.selectedLayerId = layer.id
return layer
},
removeLayer(id: string) {
const layers = this.activeLayers
const idx = layers.findIndex((l) => l.id === id)
if (idx >= 0) layers.splice(idx, 1)
if (!this.activeClipId && this.scene.trackOrder) {
this.scene.trackOrder = this.scene.trackOrder.filter((x) => x !== id)
}
if (this.selectedLayerId === id) this.selectedLayerId = null
},
moveLayer(fromIndex: number, toIndex: number) {
const arr = this.activeLayers
if (fromIndex < 0 || fromIndex >= arr.length) return
if (toIndex < 0 || toIndex > arr.length) return
const clamped = Math.min(toIndex, arr.length - 1)
if (fromIndex === clamped) return
const [moved] = arr.splice(fromIndex, 1)
arr.splice(clamped, 0, moved)
},
moveTrack(fromIndex: number, toIndex: number) {
const order = this.scene.trackOrder ?? []
if (fromIndex < 0 || fromIndex >= order.length) return
if (toIndex < 0 || toIndex > order.length) return
const clamped = Math.min(toIndex, order.length - 1)
if (fromIndex === clamped) return
const [moved] = order.splice(fromIndex, 1)
order.splice(clamped, 0, moved)
},
selectLayer(id: string | null) {
this.selectedLayerId = id
},
setPlayhead(t: number) {
this.playhead = Math.max(0, Math.min(this.activeDuration, t))
},
setDuration(seconds: number) {
const clip = this.activeClip
if (clip) {
clip.duration = Math.max(0.1, seconds)
if (this.playhead > clip.duration) this.playhead = clip.duration
} else {
this.scene.duration = Math.max(0.1, seconds)
if (this.playhead > this.scene.duration) this.playhead = this.scene.duration
}
},
setFps(fps: number) {
this.scene.fps = Math.max(1, Math.round(fps))
},
setCanvasSize(width: number, height: number) {
this.scene.canvas.width = Math.max(1, Math.round(width))
this.scene.canvas.height = Math.max(1, Math.round(height))
},
setCanvasBackground(color: string) {
this.scene.canvas.background = color
},
setBackgroundVideo(video: BackgroundVideo | null) {
if (video && video.src) this.scene.backgroundVideo = { ...video }
else delete this.scene.backgroundVideo
},
setPlaybackRange(inTime: number | null, outTime: number | null) {
const dur = this.scene.duration
const hasIn = inTime !== null && inTime !== undefined
const hasOut = outTime !== null && outTime !== undefined
if (hasIn) {
const v = Math.max(0, Math.min(dur, inTime!))
if (v === 0 && this.scene.outTime === undefined) delete this.scene.inTime
else this.scene.inTime = v
}
if (hasOut) {
const v = Math.max(this.scene.inTime ?? 0, Math.min(dur, outTime!))
if (v === dur && this.scene.inTime === undefined) delete this.scene.outTime
else this.scene.outTime = v
}
// Re-clamp playhead into the new range
const lo = this.scene.inTime ?? 0
const hi = this.scene.outTime ?? dur
if (this.playhead < lo) this.playhead = lo
else if (this.playhead > hi) this.playhead = hi
},
upsertKeyframe(layerId: string, time: number, props: Partial<AnimatableProps>) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
if (!layer.separated) {
const snapshot = sampleLayer(layer, time)
upsertInto(layer.keyframes, time, props, snapshot)
return
}
const posProps = pickProps(props, POSITION_KEYS)
const rotProps = pickProps(props, ROTATION_KEYS)
const otherProps = omitProps(props, new Set(['x', 'y', 'rotation']))
if (hasKeys(posProps)) {
const arr = (layer.positionKeyframes ||= [])
upsertInto(arr, time, posProps, null)
}
if (hasKeys(rotProps)) {
const arr = (layer.rotationKeyframes ||= [])
upsertInto(arr, time, rotProps, null)
}
if (hasKeys(otherProps)) {
upsertInto(layer.keyframes, time, otherProps, null)
}
},
removeKeyframeAt(layerId: string, time: number) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
const filter = (kfs?: Keyframe[]) =>
kfs ? kfs.filter((k) => Math.abs(k.time - time) >= KEYFRAME_EPSILON) : kfs
layer.keyframes = filter(layer.keyframes) ?? []
if (layer.positionKeyframes) layer.positionKeyframes = filter(layer.positionKeyframes)
if (layer.rotationKeyframes) layer.rotationKeyframes = filter(layer.rotationKeyframes)
},
removeKeyframe(layerId: string, keyframeId: string) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
const found = findKf(layer, keyframeId)
if (!found) return
const idx = found.arr.indexOf(found.kf)
if (idx >= 0) found.arr.splice(idx, 1)
},
setKeyframeControl(layerId: string, keyframeId: string, ctrlX: number, ctrlY: number) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
const found = findKf(layer, keyframeId)
if (!found) return
found.kf.ctrlX = ctrlX
found.kf.ctrlY = ctrlY
},
clearKeyframeControl(layerId: string, keyframeId: string) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
const found = findKf(layer, keyframeId)
if (!found) return
delete found.kf.ctrlX
delete found.kf.ctrlY
},
// Given a target world point that the curve should pass through at
// segment progress `u` (0..1), solve for the quadratic bezier control.
setKeyframeControlFromPoint(
layerId: string,
keyframeId: string,
u: number,
targetX: number,
targetY: number,
) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
const found = findKf(layer, keyframeId)
if (!found) return
if (u <= 0 || u >= 1) return
// Find the previous keyframe in the same arr
const arr = [...found.arr].sort((x, y) => x.time - y.time)
const idx = arr.findIndex((k) => k.id === keyframeId)
if (idx <= 0) return
const prev = arr[idx - 1]
const samp = sampleLayer(layer, prev.time)
const endSamp = sampleLayer(layer, found.kf.time)
const p0x = samp.x
const p0y = samp.y
const p1x = endSamp.x
const p1y = endSamp.y
const oneMinus = 1 - u
// targetX = oneMinus²*p0x + 2·oneMinus·u·C + u²*p1x
// => C = (target - oneMinus²·p0 - u²·p1) / (2·oneMinus·u)
const denom = 2 * oneMinus * u
if (Math.abs(denom) < 1e-6) return
const cx = (targetX - oneMinus * oneMinus * p0x - u * u * p1x) / denom
const cy = (targetY - oneMinus * oneMinus * p0y - u * u * p1y) / denom
found.kf.ctrlX = cx
found.kf.ctrlY = cy
},
setKeyframeEasing(layerId: string, keyframeId: string, easing: Easing) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
const found = findKf(layer, keyframeId)
if (found) found.kf.easing = easing
},
setKeyframeHold(layerId: string, keyframeId: string, hold: boolean) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
const found = findKf(layer, keyframeId)
if (!found) return
if (hold) found.kf.hold = true
else delete found.kf.hold
},
setKeyframeTime(layerId: string, keyframeId: string, newTime: number) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
const found = findKf(layer, keyframeId)
if (!found) return
found.kf.time = Math.max(0, Math.min(this.activeDuration, newTime))
sortKfs(found.arr)
},
shiftLayerPivot(
layerId: string,
dWorldX: number,
dWorldY: number,
dLocalX: number,
dLocalY: number,
) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
layer.offsetX = (layer.offsetX ?? 0) + dLocalX
layer.offsetY = (layer.offsetY ?? 0) + dLocalY
layer.base.x += dWorldX
layer.base.y += dWorldY
const shift = (kfs?: Keyframe[]) => {
if (!kfs) return
for (const kf of kfs) {
if (kf.props.x !== undefined) kf.props.x += dWorldX
if (kf.props.y !== undefined) kf.props.y += dWorldY
}
}
shift(layer.keyframes)
shift(layer.positionKeyframes)
},
duplicateKeyframe(layerId: string, keyframeId: string): string | null {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return null
const found = findKf(layer, keyframeId)
if (!found) return null
const { kf: source, arr } = found
const fps = this.scene.fps > 0 ? this.scene.fps : 60
const offset = Math.max(3 / fps, 0.1)
const duration = this.activeDuration
const occupied = (t: number) => arr.some((k) => Math.abs(k.time - t) < KEYFRAME_EPSILON)
let target = source.time + offset
while (occupied(target) && target < duration) target += offset
if (target > duration) target = duration
if (occupied(target)) {
target = source.time - offset
while (occupied(target) && target > 0) target -= offset
if (target < 0) target = 0
if (occupied(target)) return null
}
const newKf: Keyframe = {
id: uid(),
time: target,
props: { ...source.props },
easing: source.easing,
}
arr.push(newKf)
sortKfs(arr)
return newKf.id
},
setTrackColor(trackId: string, color: string | null) {
const layer = this.activeLayers.find((l) => l.id === trackId)
if (layer) {
if (color === null) delete layer.color
else layer.color = color
return
}
const clipTrack = this.scene.clipTracks?.find((t) => t.id === trackId)
if (clipTrack) {
if (color === null) delete clipTrack.color
else clipTrack.color = color
return
}
const scriptTrack = this.scene.scriptTracks?.find((t) => t.id === trackId)
if (scriptTrack) {
if (color === null) delete scriptTrack.color
else scriptTrack.color = color
return
}
const group = this.scene.groups?.find((g) => g.id === trackId)
if (group) {
if (color === null) delete group.color
else group.color = color
}
},
shiftKeyframesPosition(
refs: Array<{ layerId: string; kind: 'main' | 'position' | 'rotation'; keyframeId: string }>,
dx: number,
dy: number,
) {
for (const ref of refs) {
if (ref.kind === 'rotation') continue
const layer = this.activeLayers.find((l) => l.id === ref.layerId)
if (!layer) continue
const arr =
ref.kind === 'position' ? layer.positionKeyframes : layer.keyframes
const kf = arr?.find((k) => k.id === ref.keyframeId)
if (!kf) continue
const sampled = sampleLayer(layer, kf.time)
const curX = kf.props.x ?? sampled.x
const curY = kf.props.y ?? sampled.y
kf.props = { ...kf.props, x: curX + dx, y: curY + dy }
}
},
movePolygonVertex(layerId: string, pointIndex: number, x: number, y: number, time?: number) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
const asset = this.scene.assets.find((a) => a.id === layer.assetId)
if (!asset || asset.kind !== 'polygon') return
if (pointIndex < 0 || pointIndex >= asset.points.length) return
const t = time ?? this.playhead
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
for (const kf of arr) {
if (kf.props.points) continue
const samp = sampleLayer(layer, kf.time)
const basePts = samp.points ?? asset.points
kf.props = {
...kf.props,
points: basePts.map((p) => ({ x: p.x, y: p.y })),
}
}
}
const sampled = sampleLayer(layer, t)
const current = sampled.points ?? asset.points
const next = current.map((p, i) => (i === pointIndex ? { x, y } : { x: p.x, y: p.y }))
this.upsertKeyframe(layerId, t, { points: next })
},
addPolygonPoint(assetId: string, atIndex: number, point: { x: number; y: number }) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'polygon') return
const insertAt = Math.max(0, Math.min(atIndex, asset.points.length))
asset.points.splice(insertAt, 0, { x: point.x, y: point.y })
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
for (const layer of layersUsing) {
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
for (const kf of arr) {
if (!kf.props.points) continue
const pts = kf.props.points.slice()
pts.splice(insertAt, 0, { x: point.x, y: point.y })
kf.props = { ...kf.props, points: pts }
}
}
}
},
setPolygonPointCount(assetId: string, targetCount: number) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'polygon') return
const target = Math.max(3, Math.min(64, Math.round(targetCount)))
if (asset.points.length === target) return
const resize = (pts: { x: number; y: number }[]) => {
const arr = pts.map((p) => ({ x: p.x, y: p.y }))
while (arr.length > target && arr.length > 3) arr.pop()
while (arr.length < target) {
const last = arr[arr.length - 1]
const first = arr[0]
arr.push({ x: (last.x + first.x) / 2, y: (last.y + first.y) / 2 })
}
return arr
}
asset.points = resize(asset.points)
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
for (const layer of layersUsing) {
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
for (const kf of arr) {
if (!kf.props.points) continue
kf.props = { ...kf.props, points: resize(kf.props.points) }
}
}
}
},
removePolygonPoint(assetId: string, pointIndex: number) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'polygon') return
if (asset.points.length <= 3) return
if (pointIndex < 0 || pointIndex >= asset.points.length) return
asset.points.splice(pointIndex, 1)
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
for (const layer of layersUsing) {
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
for (const kf of arr) {
if (!kf.props.points) continue
const pts = kf.props.points.slice()
if (pointIndex < pts.length) pts.splice(pointIndex, 1)
kf.props = { ...kf.props, points: pts }
}
}
}
},
startFreeformDrawing() {
if (this.drawingMode) return
// If the selected layer is already an open polygon, resume drawing on it
const selId = this.selectedLayerId
if (selId) {
const layer = this.activeLayers.find((l) => l.id === selId)
const asset = layer ? this.scene.assets.find((a) => a.id === layer.assetId) : null
if (layer && asset && asset.kind === 'polygon' && asset.closed === false) {
this.drawingMode = 'freeform'
this.drawingAssetId = asset.id
this.drawingLayerId = layer.id
return
}
}
const asset = this.addAsset({
kind: 'polygon',
name: 'Freeform',
points: [],
fill: 'transparent',
closed: false,
stroke: '#000000',
strokeWidth: 2,
}) as Extract<Asset, { kind: 'polygon' }>
const layer: Layer = {
id: uid(),
name: asset.name,
assetId: asset.id,
visible: true,
base: { ...DEFAULT_BASE_PROPS },
keyframes: [
{
id: uid(),
time: 0,
props: { ...DEFAULT_BASE_PROPS, points: [] },
easing: 'linear',
},
],
offsetX: 0,
offsetY: 0,
}
this.activeLayers.unshift(layer)
if (!this.activeClipId) {
;(this.scene.trackOrder ||= []).unshift(layer.id)
}
this.drawingMode = 'freeform'
this.drawingAssetId = asset.id
this.drawingLayerId = layer.id
this.selectedLayerId = layer.id
},
addDrawingPoint(x: number, y: number) {
if (this.drawingMode !== 'freeform') return
const assetId = this.drawingAssetId
const layerId = this.drawingLayerId
if (!assetId || !layerId) return
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'polygon') return
asset.points.push({ x, y })
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
for (const layer of layersUsing) {
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
for (const kf of arr) {
if (!kf.props.points) continue
kf.props = {
...kf.props,
points: [...kf.props.points, { x, y }],
}
}
}
// Ensure the first keyframe of the drawing layer always has a points array
if (layer.id === layerId && layer.keyframes[0] && !layer.keyframes[0].props.points) {
layer.keyframes[0].props = {
...layer.keyframes[0].props,
points: asset.points.map((p) => ({ x: p.x, y: p.y })),
}
}
}
},
finishDrawing() {
const layerId = this.drawingLayerId
const assetId = this.drawingAssetId
this.drawingMode = null
this.drawingAssetId = null
this.drawingLayerId = null
// If we have fewer than 2 points, drop it — not a usable shape
if (!assetId || !layerId) return
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'polygon') return
if (asset.points.length < 2) {
this.removeLayer(layerId)
this.scene.assets = this.scene.assets.filter((a) => a.id !== assetId)
}
},
cancelDrawing() {
const layerId = this.drawingLayerId
const assetId = this.drawingAssetId
this.drawingMode = null
this.drawingAssetId = null
this.drawingLayerId = null
if (layerId) this.removeLayer(layerId)
if (assetId) this.scene.assets = this.scene.assets.filter((a) => a.id !== assetId)
},
setPolygonVertexType(
assetId: string,
indexOrIndices: number | number[],
type: 'corner' | 'curve' | 'splitcurve',
) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'polygon') return
const list = Array.isArray(indexOrIndices) ? indexOrIndices : [indexOrIndices]
for (const index of list) {
if (index < 0 || index >= asset.points.length) continue
const point = asset.points[index]
if (type === 'corner') {
delete point.type
delete point.inX
delete point.inY
delete point.outX
delete point.outY
continue
}
const n = asset.points.length
const closed = asset.closed !== false
let prev: { x: number; y: number } | null = null
let next: { x: number; y: number } | null = null
if (index > 0) prev = asset.points[index - 1]
else if (closed) prev = asset.points[n - 1]
if (index < n - 1) next = asset.points[index + 1]
else if (closed) next = asset.points[0]
const dirX = (next?.x ?? point.x) - (prev?.x ?? point.x)
const dirY = (next?.y ?? point.y) - (prev?.y ?? point.y)
const k = 0.25
const baseOutX = dirX * k
const baseOutY = dirY * k
point.type = type
if (point.outX === undefined || point.outY === undefined) {
point.outX = baseOutX
point.outY = baseOutY
}
if (point.inX === undefined || point.inY === undefined) {
point.inX = -baseOutX
point.inY = -baseOutY
}
}
},
setPolygonVertexPositions(
layerId: string,
updates: { index: number; x: number; y: number }[],
time?: number,
) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
const asset = this.scene.assets.find((a) => a.id === layer.assetId)
if (!asset || asset.kind !== 'polygon') return
if (updates.length === 0) return
const t = time ?? this.playhead
// Backfill any existing keyframes that have points-array but lack
// matching entries / tangents for consistency with movePolygonVertex.
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
for (const kf of arr) {
if (kf.props.points) continue
const samp = sampleLayer(layer, kf.time)
const basePts = samp.points ?? asset.points
kf.props = {
...kf.props,
points: basePts.map((p) => ({ ...p })),
}
}
}
const sampled = sampleLayer(layer, t)
const current = sampled.points ?? asset.points
const updateMap = new Map<number, { x: number; y: number }>()
for (const u of updates) {
if (u.index < 0 || u.index >= current.length) continue
updateMap.set(u.index, u)
}
const next = current.map((p, i) => {
const u = updateMap.get(i)
if (!u) return { ...p }
return { ...p, x: u.x, y: u.y }
})
this.upsertKeyframe(layerId, t, { points: next })
},
setPolygonVertexTangent(
assetId: string,
index: number,
side: 'in' | 'out',
dx: number,
dy: number,
) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'polygon') return
if (index < 0 || index >= asset.points.length) return
const type = asset.points[index].type
if (!type || type === 'corner') return
const t = this.playhead
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
for (const layer of layersUsing) {
// Backfill any existing keyframes that have points but lack tangents
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
for (const kf of arr) {
if (!kf.props.points) continue
kf.props = {
...kf.props,
points: kf.props.points.map((p, i) => {
const ap = asset.points[i]
if (!ap) return { ...p }
return {
...p,
inX: p.inX ?? ap.inX,
inY: p.inY ?? ap.inY,
outX: p.outX ?? ap.outX,
outY: p.outY ?? ap.outY,
}
}),
}
}
}
const sampled = sampleLayer(layer, t)
const basePts = sampled.points ?? asset.points
const next = basePts.map((p, i) => {
const out: typeof p = { ...p }
if (i !== index) return out
if (side === 'in') {
out.inX = dx
out.inY = dy
if (type === 'curve') {
out.outX = -dx
out.outY = -dy
}
} else {
out.outX = dx
out.outY = dy
if (type === 'curve') {
out.inX = -dx
out.inY = -dy
}
}
return out
})
this.upsertKeyframe(layer.id, t, { points: next })
}
// Also keep asset-level defaults in sync so new keyframes start from
// the latest edit when no kf.points is present yet.
const basePoint = asset.points[index]
if (side === 'in') {
basePoint.inX = dx
basePoint.inY = dy
if (type === 'curve') {
basePoint.outX = -dx
basePoint.outY = -dy
}
} else {
basePoint.outX = dx
basePoint.outY = dy
if (type === 'curve') {
basePoint.inX = -dx
basePoint.inY = -dy
}
}
},
reversePolygonPoints(assetId: string) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'polygon') return
asset.points.reverse()
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
for (const layer of layersUsing) {
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
for (const kf of arr) {
if (!kf.props.points) continue
kf.props = { ...kf.props, points: [...kf.props.points].reverse() }
}
}
}
},
setPolygonClosed(assetId: string, closed: boolean) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'polygon') return
asset.closed = closed
},
insertPolygonPointOnEdge(assetId: string, atIndex: number, tOnEdge: number) {
const asset = this.scene.assets.find((a) => a.id === assetId)
if (!asset || asset.kind !== 'polygon') return
const n = asset.points.length
if (atIndex < 1 || atIndex > n) return
const prev = asset.points[atIndex - 1]
const next = asset.points[atIndex % n]
asset.points.splice(atIndex, 0, {
x: prev.x + (next.x - prev.x) * tOnEdge,
y: prev.y + (next.y - prev.y) * tOnEdge,
})
const layersUsing = this.scene.layers.filter((l) => l.assetId === assetId)
for (const layer of layersUsing) {
for (const arr of [layer.keyframes, layer.positionKeyframes, layer.rotationKeyframes]) {
if (!arr) continue
for (const kf of arr) {
if (!kf.props.points) continue
const pts = kf.props.points
const p = pts[atIndex - 1]
const q = pts[atIndex % pts.length]
const next = pts.slice()
next.splice(atIndex, 0, {
x: p.x + (q.x - p.x) * tOnEdge,
y: p.y + (q.y - p.y) * tOnEdge,
})
kf.props = { ...kf.props, points: next }
}
}
}
},
setLayerMask(layerId: string, isMask: boolean, mode?: MaskMode) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
if (isMask) {
layer.isMask = true
if (mode) layer.maskMode = mode
else if (!layer.maskMode) layer.maskMode = 'negative'
} else {
delete layer.isMask
delete layer.maskMode
}
},
setLayerMaskMode(layerId: string, mode: MaskMode) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
layer.maskMode = mode
},
setTrackLocked(trackId: string, locked: boolean) {
const layer = this.activeLayers.find((l) => l.id === trackId)
if (layer) {
if (locked) layer.locked = true
else delete layer.locked
return
}
const clipTrack = this.scene.clipTracks?.find((t) => t.id === trackId)
if (clipTrack) {
if (locked) clipTrack.locked = true
else delete clipTrack.locked
return
}
const scriptTrack = this.scene.scriptTracks?.find((t) => t.id === trackId)
if (scriptTrack) {
if (locked) scriptTrack.locked = true
else delete scriptTrack.locked
return
}
const group = this.scene.groups?.find((g) => g.id === trackId)
if (group) {
if (locked) group.locked = true
else delete group.locked
}
},
// -------- Markers --------------------------------------------------
addMarker(time: number, name?: string, color?: string | null): Marker {
const t = Math.max(0, Math.min(this.scene.duration, time))
const markers = (this.scene.markers ||= [])
const used = new Set(markers.map((m) => m.name))
let base = (name ?? '').trim() || 'Marker'
let final = base
let i = 2
while (used.has(final)) final = `${base} ${i++}`
const m: Marker = { id: uid(), name: final, time: t }
if (color != null) m.color = color
markers.push(m)
markers.sort((a, b) => a.time - b.time)
return m
},
removeMarker(id: string) {
if (!this.scene.markers) return
this.scene.markers = this.scene.markers.filter((m) => m.id !== id)
},
renameMarker(id: string, name: string): boolean {
const markers = this.scene.markers
if (!markers) return false
const target = markers.find((m) => m.id === id)
if (!target) return false
const next = name.trim()
if (!next) return false
if (next === target.name) return true
if (markers.some((m) => m.id !== id && m.name === next)) return false
target.name = next
return true
},
setMarkerTime(id: string, time: number) {
const marker = this.scene.markers?.find((m) => m.id === id)
if (!marker) return
marker.time = Math.max(0, Math.min(this.scene.duration, time))
this.scene.markers?.sort((a, b) => a.time - b.time)
},
setMarkerColor(id: string, color: string | null) {
const marker = this.scene.markers?.find((m) => m.id === id)
if (!marker) return
if (color == null) delete marker.color
else marker.color = color
},
// -------- Script tracks & frames -----------------------------------
addScriptTrack(name?: string): ScriptTrack {
const track: ScriptTrack = { id: uid(), name }
;(this.scene.scriptTracks ||= []).push(track)
;(this.scene.trackOrder ||= []).push(track.id)
return track
},
removeScriptTrack(id: string) {
if (this.scene.scriptTracks) {
this.scene.scriptTracks = this.scene.scriptTracks.filter((t) => t.id !== id)
}
if (this.scene.scriptFrames) {
this.scene.scriptFrames = this.scene.scriptFrames.filter((f) => f.trackId !== id)
}
if (this.scene.trackOrder) {
this.scene.trackOrder = this.scene.trackOrder.filter((tid) => tid !== id)
}
},
setScriptTrackName(id: string, name: string) {
const track = this.scene.scriptTracks?.find((t) => t.id === id)
if (track) track.name = name.trim() || undefined
},
addScriptFrame(
trackId: string,
time: number,
actions: Action[] = [],
label?: string,
): ScriptFrame | null {
const track = this.scene.scriptTracks?.find((t) => t.id === trackId)
if (!track) return null
const t = Math.max(0, Math.min(this.scene.duration, time))
const frame: ScriptFrame = {
id: uid(),
trackId,
time: t,
actions: [...actions],
}
if (label) frame.label = label
;(this.scene.scriptFrames ||= []).push(frame)
this.scene.scriptFrames.sort((a, b) => a.time - b.time)
return frame
},
removeScriptFrame(id: string) {
if (!this.scene.scriptFrames) return
this.scene.scriptFrames = this.scene.scriptFrames.filter((f) => f.id !== id)
},
setScriptFrameActions(id: string, actions: Action[]) {
const frame = this.scene.scriptFrames?.find((f) => f.id === id)
if (frame) frame.actions = [...actions]
},
setScriptFrameTime(id: string, time: number) {
const frame = this.scene.scriptFrames?.find((f) => f.id === id)
if (!frame) return
frame.time = Math.max(0, Math.min(this.scene.duration, time))
this.scene.scriptFrames?.sort((a, b) => a.time - b.time)
},
setLayerSeparated(layerId: string, separated: boolean) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
if (!!layer.separated === separated) return
if (separated) {
const posKfs: Keyframe[] = []
const rotKfs: Keyframe[] = []
const remaining: Keyframe[] = []
for (const kf of layer.keyframes) {
const posProps = pickProps(kf.props, POSITION_KEYS)
const rotProps = pickProps(kf.props, ROTATION_KEYS)
const rest = omitProps(kf.props, new Set(['x', 'y', 'rotation']))
if (hasKeys(posProps)) {
posKfs.push({ id: uid(), time: kf.time, props: posProps, easing: kf.easing })
}
if (hasKeys(rotProps)) {
rotKfs.push({ id: uid(), time: kf.time, props: rotProps, easing: kf.easing })
}
if (hasKeys(rest)) {
remaining.push({ id: uid(), time: kf.time, props: rest, easing: kf.easing })
}
}
sortKfs(posKfs)
sortKfs(rotKfs)
sortKfs(remaining)
layer.positionKeyframes = posKfs
layer.rotationKeyframes = rotKfs
layer.keyframes = remaining
layer.separated = true
} else {
interface Bucket {
props: Partial<AnimatableProps>
easing: Easing
}
const byTime = new Map<number, Bucket>()
const addAll = (kfs?: Keyframe[]) => {
if (!kfs) return
for (const kf of kfs) {
const key = Math.round(kf.time * 1e6)
let bucket = byTime.get(key)
if (!bucket) {
bucket = { props: {}, easing: kf.easing }
byTime.set(key, bucket)
}
bucket.props = { ...bucket.props, ...kf.props }
}
}
addAll(layer.keyframes)
addAll(layer.positionKeyframes)
addAll(layer.rotationKeyframes)
const merged: Keyframe[] = []
for (const [key, bucket] of byTime.entries()) {
merged.push({
id: uid(),
time: key / 1e6,
props: bucket.props,
easing: bucket.easing,
})
}
sortKfs(merged)
layer.keyframes = merged
layer.positionKeyframes = undefined
layer.rotationKeyframes = undefined
layer.separated = false
}
},
loadScene(scene: Scene) {
this.scene = normalizeScene(scene)
this.selectedLayerId = null
this.playhead = 0
this.playing = false
this.activeClipId = null
},
addClip(name?: string): Clip {
const clip: Clip = {
id: uid(),
name: name?.trim() || 'Clip',
duration: Math.max(1, Math.min(5, this.scene.duration)),
layers: [],
}
;(this.scene.clips ||= []).push(clip)
if (!this.scene.clipTracks?.length) this.addClipTrack()
return clip
},
removeClip(id: string) {
if (this.scene.clips) {
this.scene.clips = this.scene.clips.filter((c) => c.id !== id)
}
if (this.scene.clipInstances) {
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.clipId !== id)
}
if (this.activeClipId === id) {
this.activeClipId = null
this.selectedLayerId = null
this.playhead = 0
}
},
renameClip(id: string, name: string) {
const clip = this.scene.clips?.find((c) => c.id === id)
if (clip) clip.name = name.trim() || clip.name
},
addClipTrack(name?: string): ClipTrack {
const track: ClipTrack = { id: uid(), name }
;(this.scene.clipTracks ||= []).push(track)
;(this.scene.trackOrder ||= []).push(track.id)
return track
},
removeClipTrack(id: string) {
if (this.scene.clipTracks) {
this.scene.clipTracks = this.scene.clipTracks.filter((t) => t.id !== id)
}
if (this.scene.clipInstances) {
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.clipTrackId !== id)
}
if (this.scene.trackOrder) {
this.scene.trackOrder = this.scene.trackOrder.filter((x) => x !== id)
}
},
addClipInstance(clipId: string, startTime: number, clipTrackId: string): ClipInstance | null {
const clip = this.scene.clips?.find((c) => c.id === clipId)
if (!clip) return null
const track = this.scene.clipTracks?.find((t) => t.id === clipTrackId)
if (!track) return null
const instance: ClipInstance = {
id: uid(),
clipId,
startTime: Math.max(0, startTime),
clipTrackId,
}
;(this.scene.clipInstances ||= []).push(instance)
return instance
},
moveClipInstance(id: string, startTime: number, clipTrackId?: string) {
const inst = this.scene.clipInstances?.find((ci) => ci.id === id)
if (!inst) return
inst.startTime = Math.max(0, startTime)
if (clipTrackId) {
const track = this.scene.clipTracks?.find((t) => t.id === clipTrackId)
if (track) inst.clipTrackId = clipTrackId
}
},
trimClipInstance(
id: string,
patch: { startTime?: number; trimStart?: number; trimEnd?: number },
) {
const inst = this.scene.clipInstances?.find((ci) => ci.id === id)
if (!inst) return
const clip = this.scene.clips?.find((c) => c.id === inst.clipId)
if (!clip) return
const MIN_VISIBLE = 0.05
let trimStart = patch.trimStart ?? inst.trimStart ?? 0
let trimEnd = patch.trimEnd ?? inst.trimEnd ?? 0
let startTime = patch.startTime ?? inst.startTime
trimStart = Math.max(0, trimStart)
trimEnd = Math.max(0, trimEnd)
const maxBudget = clip.duration - MIN_VISIBLE
if (trimStart + trimEnd > maxBudget) {
if (patch.trimStart !== undefined && patch.trimEnd === undefined) {
trimStart = Math.max(0, maxBudget - trimEnd)
} else if (patch.trimEnd !== undefined && patch.trimStart === undefined) {
trimEnd = Math.max(0, maxBudget - trimStart)
} else {
trimStart = Math.min(trimStart, maxBudget)
trimEnd = Math.max(0, maxBudget - trimStart)
}
}
inst.trimStart = trimStart
inst.trimEnd = trimEnd
inst.startTime = Math.max(0, startTime)
},
removeClipInstance(id: string) {
if (!this.scene.clipInstances) return
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.id !== id)
},
addTrackGroup(name?: string): TrackGroup {
const group: TrackGroup = {
id: uid(),
name: name?.trim() || 'Group',
childIds: [],
}
;(this.scene.groups ||= []).push(group)
;(this.scene.trackOrder ||= []).unshift(group.id)
return group
},
removeTrackGroup(id: string) {
const group = this.scene.groups?.find((g) => g.id === id)
if (!group) return
const order = this.scene.trackOrder ?? []
const idx = order.indexOf(id)
if (idx >= 0) order.splice(idx, 1, ...group.childIds)
this.scene.groups = (this.scene.groups ?? []).filter((g) => g.id !== id)
},
renameGroup(id: string, name: string) {
const group = this.scene.groups?.find((g) => g.id === id)
if (!group) return
const n = name.trim()
if (n) group.name = n
},
toggleGroupCollapsed(id: string) {
const group = this.scene.groups?.find((g) => g.id === id)
if (!group) return
group.collapsed = !group.collapsed
},
moveTrackIntoGroup(trackId: string, groupId: string, atIndex?: number) {
const target = this.scene.groups?.find((g) => g.id === groupId)
if (!target) return
if (trackId === groupId) return
// Remove from top-level trackOrder
const order = this.scene.trackOrder ?? []
const ti = order.indexOf(trackId)
if (ti >= 0) order.splice(ti, 1)
// Remove from any other group's childIds
for (const g of this.scene.groups ?? []) {
const gi = g.childIds.indexOf(trackId)
if (gi >= 0) g.childIds.splice(gi, 1)
}
const at = atIndex === undefined ? target.childIds.length : Math.max(0, Math.min(atIndex, target.childIds.length))
target.childIds.splice(at, 0, trackId)
},
moveTrackOutOfGroup(trackId: string, toTopLevelIndex?: number) {
for (const g of this.scene.groups ?? []) {
const gi = g.childIds.indexOf(trackId)
if (gi >= 0) g.childIds.splice(gi, 1)
}
const order = (this.scene.trackOrder ||= [])
if (!order.includes(trackId)) {
const at = toTopLevelIndex === undefined ? 0 : Math.max(0, Math.min(toTopLevelIndex, order.length))
order.splice(at, 0, trackId)
}
},
enterClip(id: string) {
const clip = this.scene.clips?.find((c) => c.id === id)
if (!clip) return
this.activeClipId = id
this.selectedLayerId = null
this.playhead = 0
this.playing = false
},
exitClip() {
this.activeClipId = null
this.selectedLayerId = null
this.playhead = 0
this.playing = false
},
play() {
if (this.playing) return
this.playing = true
},
pause() {
this.playing = false
},
togglePlay() {
if (this.playing) this.pause()
else {
const end = this.playbackOut
const start = this.playbackIn
if (end <= start) return
// If the playhead sits outside the in/out range, snap to the in-point.
if (this.playhead < start || this.playhead >= end) this.playhead = start
this.play()
}
},
tick(deltaSeconds: number) {
if (!this.playing) return
const prev = this.playhead
const end = this.playbackOut
const nextRaw = prev + deltaSeconds
if (nextRaw >= end) {
this.playhead = end
this.playing = false
} else {
this.playhead = nextRaw
}
const landed = this.playhead
// Forward-only firing: script frames whose time sits in (prev, landed].
// Scrubs/setPlayhead/loop-wraps never reach this path, so the rule
// is naturally preserved.
const frames = scriptFramesInRange(this.scene.scriptFrames, prev, landed)
if (frames.length) {
const rt = this.actionRuntime()
for (const f of frames) {
for (const action of f.actions) evalAction(action, rt)
}
}
},
actionRuntime(): ActionRuntime {
const self = this
return {
fps: self.scene.fps,
playbackIn: self.playbackIn,
playbackOut: self.playbackOut,
duration: self.scene.duration,
markers: buildMarkerMap(self.scene.markers),
setPlayhead(t: number) {
self.setPlayhead(t)
},
play() {
self.play()
},
pause() {
self.pause()
},
warn(msg: string) {
// eslint-disable-next-line no-console
console.warn('[animatr]', msg)
},
}
},
},
})