0.8.6: clip instances get stage transforms (x/y/scale/rotation)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
53c0410824
commit
d60684edd1
7 files changed
+512
-41
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "animatr",
|
"name": "animatr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.8.5",
|
"version": "0.8.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+127
-34
@@ -23,6 +23,7 @@ import type {
|
|||||||
Asset,
|
Asset,
|
||||||
ButtonAsset,
|
ButtonAsset,
|
||||||
ButtonState,
|
ButtonState,
|
||||||
|
ClipInstance,
|
||||||
HitShape,
|
HitShape,
|
||||||
Keyframe,
|
Keyframe,
|
||||||
Layer,
|
Layer,
|
||||||
@@ -728,9 +729,14 @@ interface RenderedClipLayer extends RenderedLayer {
|
|||||||
instanceId: string
|
instanceId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface RenderedClipInstance {
|
||||||
|
instance: ClipInstance
|
||||||
|
items: RenderedClipLayer[]
|
||||||
|
}
|
||||||
|
|
||||||
type StackItem =
|
type StackItem =
|
||||||
| { kind: 'layer'; item: RenderedLayer }
|
| { kind: 'layer'; item: RenderedLayer }
|
||||||
| { kind: 'clip'; item: RenderedClipLayer }
|
| { kind: 'clipInstance'; bundle: RenderedClipInstance }
|
||||||
|
|
||||||
function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps {
|
function applyLivePose(props: AnimatableProps, layerId: string): AnimatableProps {
|
||||||
const live = liveLayerPose.value
|
const live = liveLayerPose.value
|
||||||
@@ -833,23 +839,26 @@ const stackItems = computed<StackItem[]>(() => {
|
|||||||
const offsetIntoBlock = store.playhead - inst.startTime
|
const offsetIntoBlock = store.playhead - inst.startTime
|
||||||
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
|
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
|
||||||
const localTime = trimStart + offsetIntoBlock
|
const localTime = trimStart + offsetIntoBlock
|
||||||
// Clip's own layers: reverse too so layer[0] ends up on top
|
// Bundle the clip's layers under a single clip-instance entry
|
||||||
|
// so the template can wrap them in a draggable Konva group that
|
||||||
|
// carries the instance's x / y / scale transform.
|
||||||
|
const items: RenderedClipLayer[] = []
|
||||||
for (let k = clip.layers.length - 1; k >= 0; k--) {
|
for (let k = clip.layers.length - 1; k >= 0; k--) {
|
||||||
const cl = clip.layers[k]
|
const cl = clip.layers[k]
|
||||||
if (!cl.visible) continue
|
if (!cl.visible) continue
|
||||||
if (!isLayerActiveAt(cl, localTime)) continue
|
if (!isLayerActiveAt(cl, localTime)) continue
|
||||||
const ca = assetMap.value.get(cl.assetId)
|
const ca = assetMap.value.get(cl.assetId)
|
||||||
if (!ca) continue
|
if (!ca) continue
|
||||||
out.push({
|
items.push({
|
||||||
kind: 'clip',
|
instanceId: inst.id,
|
||||||
item: {
|
layer: cl,
|
||||||
instanceId: inst.id,
|
asset: ca,
|
||||||
layer: cl,
|
props: sampleLayer(cl, localTime),
|
||||||
asset: ca,
|
|
||||||
props: sampleLayer(cl, localTime),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if (items.length) {
|
||||||
|
out.push({ kind: 'clipInstance', bundle: { instance: inst, items } })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
@@ -1224,6 +1233,50 @@ function imageConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-clip-instance wrapper group. Carries the instance's static
|
||||||
|
* x / y / scale so the user can reposition the packed clip on the
|
||||||
|
* stage without touching its inner keyframes. `rotation` is
|
||||||
|
* deliberately absent — the user asked for move + scale only.
|
||||||
|
* The inner child configs keep `listening: false`, so pointer
|
||||||
|
* events funnel to the outer group and drag/transform apply as a
|
||||||
|
* unit. */
|
||||||
|
function clipInstanceGroupConfig(inst: ClipInstance) {
|
||||||
|
return {
|
||||||
|
id: `ci-group:${inst.id}`,
|
||||||
|
x: inst.x ?? 0,
|
||||||
|
y: inst.y ?? 0,
|
||||||
|
scaleX: inst.scaleX ?? 1,
|
||||||
|
scaleY: inst.scaleY ?? 1,
|
||||||
|
rotation: inst.rotation ?? 0,
|
||||||
|
draggable: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClipInstanceDragEnd(
|
||||||
|
e: Konva.KonvaEventObject<DragEvent>,
|
||||||
|
instanceId: string,
|
||||||
|
) {
|
||||||
|
const node = e.target
|
||||||
|
store.setClipInstanceTransform(instanceId, {
|
||||||
|
x: node.x(),
|
||||||
|
y: node.y(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClipInstanceTransformEnd(
|
||||||
|
e: Konva.KonvaEventObject<Event>,
|
||||||
|
instanceId: string,
|
||||||
|
) {
|
||||||
|
const node = e.target
|
||||||
|
store.setClipInstanceTransform(instanceId, {
|
||||||
|
scaleX: node.scaleX(),
|
||||||
|
scaleY: node.scaleY(),
|
||||||
|
rotation: node.rotation(),
|
||||||
|
x: node.x(),
|
||||||
|
y: node.y(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function clipBaseConfig(instanceId: string, layer: Layer, props: AnimatableProps) {
|
function clipBaseConfig(instanceId: string, layer: Layer, props: AnimatableProps) {
|
||||||
return {
|
return {
|
||||||
id: `ci:${instanceId}:${layer.id}`,
|
id: `ci:${instanceId}:${layer.id}`,
|
||||||
@@ -1235,7 +1288,11 @@ function clipBaseConfig(instanceId: string, layer: Layer, props: AnimatableProps
|
|||||||
opacity: props.opacity,
|
opacity: props.opacity,
|
||||||
visible: layer.visible,
|
visible: layer.visible,
|
||||||
draggable: false,
|
draggable: false,
|
||||||
listening: false,
|
// Keep clip children hit-testable so a click on the clip's
|
||||||
|
// visible shape bubbles up to the parent group's @mousedown
|
||||||
|
// selection handler. They don't carry their own handlers, so
|
||||||
|
// the event bubbles straight up.
|
||||||
|
listening: true,
|
||||||
...shadowCfg(props),
|
...shadowCfg(props),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1808,7 +1865,9 @@ function onPivotDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const transformerConfig = {
|
const transformerConfig = {
|
||||||
rotateEnabled: false,
|
rotateEnabled: true,
|
||||||
|
rotationSnaps: [0, 45, 90, 135, 180, -135, -90, -45],
|
||||||
|
rotationSnapTolerance: 4,
|
||||||
enabledAnchors: [
|
enabledAnchors: [
|
||||||
'top-left',
|
'top-left',
|
||||||
'top-center',
|
'top-center',
|
||||||
@@ -1837,6 +1896,22 @@ function attachTransformer(retriesLeft = 3) {
|
|||||||
const stage = stageRef.value?.getStage()
|
const stage = stageRef.value?.getStage()
|
||||||
const tr = transformerRef.value?.getNode()
|
const tr = transformerRef.value?.getNode()
|
||||||
if (!stage || !tr) return
|
if (!stage || !tr) return
|
||||||
|
|
||||||
|
// Clip-instance selection takes precedence — the transformer wraps
|
||||||
|
// the group so its move + scale handles update the instance's
|
||||||
|
// static x / y / scale.
|
||||||
|
const clipInstId = store.selectedClipInstanceId
|
||||||
|
if (clipInstId) {
|
||||||
|
const groupNode = stage.findOne<Konva.Node>(`#ci-group:${clipInstId}`)
|
||||||
|
if (!groupNode && retriesLeft > 0) {
|
||||||
|
attachRetryTimer = setTimeout(() => attachTransformer(retriesLeft - 1), 16)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tr.nodes(groupNode ? [groupNode] : [])
|
||||||
|
tr.getLayer()?.batchDraw()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const id = store.selectedLayerId
|
const id = store.selectedLayerId
|
||||||
if (!id) {
|
if (!id) {
|
||||||
tr.nodes([])
|
tr.nodes([])
|
||||||
@@ -1865,7 +1940,13 @@ function attachTransformer(retriesLeft = 3) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [store.selectedLayerId, stackItems.value, store.playhead] as const,
|
() =>
|
||||||
|
[
|
||||||
|
store.selectedLayerId,
|
||||||
|
store.selectedClipInstanceId,
|
||||||
|
stackItems.value,
|
||||||
|
store.playhead,
|
||||||
|
] as const,
|
||||||
async () => {
|
async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
attachTransformer()
|
attachTransformer()
|
||||||
@@ -2902,7 +2983,7 @@ onBeforeUnmount(() => {
|
|||||||
:key="
|
:key="
|
||||||
entry.kind === 'layer'
|
entry.kind === 'layer'
|
||||||
? `l:${entry.item.layer.id}`
|
? `l:${entry.item.layer.id}`
|
||||||
: `ci:${entry.item.instanceId}:${entry.item.layer.id}:${i}`
|
: `ci:${entry.bundle.instance.id}:${i}`
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<template v-if="entry.kind === 'layer'">
|
<template v-if="entry.kind === 'layer'">
|
||||||
@@ -2989,26 +3070,38 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<v-rect
|
<v-group
|
||||||
v-if="entry.item.asset.kind === 'rect'"
|
:config="clipInstanceGroupConfig(entry.bundle.instance)"
|
||||||
:config="clipRectConfig(entry.item)"
|
@mousedown="store.selectClipInstance(entry.bundle.instance.id)"
|
||||||
/>
|
@dragend="onClipInstanceDragEnd($event, entry.bundle.instance.id)"
|
||||||
<v-circle
|
@transformend="onClipInstanceTransformEnd($event, entry.bundle.instance.id)"
|
||||||
v-else-if="entry.item.asset.kind === 'circle'"
|
>
|
||||||
:config="clipCircleConfig(entry.item)"
|
<template
|
||||||
/>
|
v-for="sub in entry.bundle.items"
|
||||||
<v-text
|
:key="`ci:${entry.bundle.instance.id}:${sub.layer.id}`"
|
||||||
v-else-if="entry.item.asset.kind === 'text'"
|
>
|
||||||
:config="clipTextConfig(entry.item)"
|
<v-rect
|
||||||
/>
|
v-if="sub.asset.kind === 'rect'"
|
||||||
<v-line
|
:config="clipRectConfig(sub)"
|
||||||
v-else-if="entry.item.asset.kind === 'polygon'"
|
/>
|
||||||
:config="clipPolygonConfig(entry.item)"
|
<v-circle
|
||||||
/>
|
v-else-if="sub.asset.kind === 'circle'"
|
||||||
<v-image
|
:config="clipCircleConfig(sub)"
|
||||||
v-else-if="entry.item.asset.kind === 'image' && clipImageConfig(entry.item)"
|
/>
|
||||||
:config="clipImageConfig(entry.item)!"
|
<v-text
|
||||||
/>
|
v-else-if="sub.asset.kind === 'text'"
|
||||||
|
:config="clipTextConfig(sub)"
|
||||||
|
/>
|
||||||
|
<v-line
|
||||||
|
v-else-if="sub.asset.kind === 'polygon'"
|
||||||
|
:config="clipPolygonConfig(sub)"
|
||||||
|
/>
|
||||||
|
<v-image
|
||||||
|
v-else-if="sub.asset.kind === 'image' && clipImageConfig(sub)"
|
||||||
|
:config="clipImageConfig(sub)!"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-group>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
<template
|
<template
|
||||||
|
|||||||
+288
-5
@@ -986,6 +986,21 @@ export function registerAllTests(): void {
|
|||||||
expect(r).toContain('out.blur')
|
expect(r).toContain('out.blur')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('runtime wraps each clip instance in translate + rotate + scale', () => {
|
||||||
|
// Pin that the exported runtime's per-clip draw loop honours
|
||||||
|
// ClipInstance.x/y/scaleX/scaleY/rotation — the canvas editor
|
||||||
|
// and the exported HTML stay visually identical for any
|
||||||
|
// clip-instance transform.
|
||||||
|
const r = generateRuntimeScript()
|
||||||
|
expect(r).toContain('inst2.x')
|
||||||
|
expect(r).toContain('inst2.y')
|
||||||
|
expect(r).toContain('inst2.scaleX')
|
||||||
|
expect(r).toContain('inst2.scaleY')
|
||||||
|
expect(r).toContain('inst2.rotation')
|
||||||
|
expect(r).toContain('ctx.rotate(irot2*Math.PI/180)')
|
||||||
|
expect(r).toContain('ctx.scale(isx2,isy2)')
|
||||||
|
})
|
||||||
|
|
||||||
test('runtime rangeOut falls back to scene.duration (not last keyframe)', () => {
|
test('runtime rangeOut falls back to scene.duration (not last keyframe)', () => {
|
||||||
// Regression: the runtime used to fall back to `effectiveEnd(scene)`
|
// Regression: the runtime used to fall back to `effectiveEnd(scene)`
|
||||||
// — the time of the last keyframe — when scene.outTime was unset.
|
// — the time of the last keyframe — when scene.outTime was unset.
|
||||||
@@ -1298,19 +1313,35 @@ export function registerAllTests(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
suite('ui — Modal', () => {
|
suite('ui — Modal', () => {
|
||||||
|
// Modal teleports to document.body. If the running app has a
|
||||||
|
// dialog open (welcome / export / settings), a plain
|
||||||
|
// document.querySelector('[role="dialog"]') would match that
|
||||||
|
// instead of what the test just mounted. Snapshot the live
|
||||||
|
// dialogs first and diff after mount so each assertion only
|
||||||
|
// considers the nodes this test created.
|
||||||
|
const snapshotDialogs = () =>
|
||||||
|
new Set(Array.from(document.querySelectorAll('[role="dialog"]')))
|
||||||
|
const dialogsAddedSince = (baseline: Set<Element>) =>
|
||||||
|
Array.from(document.querySelectorAll('[role="dialog"]')).filter(
|
||||||
|
(el) => !baseline.has(el),
|
||||||
|
)
|
||||||
|
|
||||||
test('renders nothing when modelValue is false', async () => {
|
test('renders nothing when modelValue is false', async () => {
|
||||||
|
const baseline = snapshotDialogs()
|
||||||
const handle = mountComponent(Modal, { modelValue: false, title: 'X' })
|
const handle = mountComponent(Modal, { modelValue: false, title: 'X' })
|
||||||
await nextTick()
|
await nextTick()
|
||||||
expect(document.querySelector('[role="dialog"]')).toBeNull()
|
expect(dialogsAddedSince(baseline).length).toBe(0)
|
||||||
handle.unmount()
|
handle.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('teleports backdrop + dialog to body when open, renders title', async () => {
|
test('teleports backdrop + dialog to body when open, renders title', async () => {
|
||||||
|
const baseline = snapshotDialogs()
|
||||||
const handle = mountComponent(Modal, { modelValue: true, title: 'Hallo Modal' })
|
const handle = mountComponent(Modal, { modelValue: true, title: 'Hallo Modal' })
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const dialog = document.querySelector('[role="dialog"]') as HTMLElement | null
|
const added = dialogsAddedSince(baseline)
|
||||||
expect(dialog).toBeTruthy()
|
expect(added.length).toBe(1)
|
||||||
expect(dialog!.textContent?.includes('Hallo Modal')).toBe(true)
|
const dialog = added[0] as HTMLElement
|
||||||
|
expect(dialog.textContent?.includes('Hallo Modal')).toBe(true)
|
||||||
handle.unmount()
|
handle.unmount()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1349,6 +1380,7 @@ export function registerAllTests(): void {
|
|||||||
|
|
||||||
test('dismissable=false hides close button and swallows Esc', async () => {
|
test('dismissable=false hides close button and swallows Esc', async () => {
|
||||||
let latest: boolean | null = null
|
let latest: boolean | null = null
|
||||||
|
const baseline = snapshotDialogs()
|
||||||
const handle = mountComponent(Modal, {
|
const handle = mountComponent(Modal, {
|
||||||
modelValue: true,
|
modelValue: true,
|
||||||
title: 'Busy',
|
title: 'Busy',
|
||||||
@@ -1358,7 +1390,10 @@ export function registerAllTests(): void {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
await nextTick()
|
await nextTick()
|
||||||
const btn = document.querySelector('[role="dialog"] header button')
|
const added = dialogsAddedSince(baseline)
|
||||||
|
expect(added.length).toBe(1)
|
||||||
|
const dialog = added[0] as HTMLElement
|
||||||
|
const btn = dialog.querySelector('header button')
|
||||||
expect(btn).toBeNull()
|
expect(btn).toBeNull()
|
||||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -1689,6 +1724,254 @@ export function registerAllTests(): void {
|
|||||||
expect(ci.trimEnd).toBe(0)
|
expect(ci.trimEnd).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('setClipInstanceTransform stores x / y / scale / rotation', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const c = s.addClip('Xf')
|
||||||
|
const t = s.scene.clipTracks![0]
|
||||||
|
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||||||
|
s.setClipInstanceTransform(ci.id, {
|
||||||
|
x: 80,
|
||||||
|
y: -30,
|
||||||
|
scaleX: 2,
|
||||||
|
scaleY: 0.5,
|
||||||
|
rotation: 45,
|
||||||
|
})
|
||||||
|
expect(ci.x).toBe(80)
|
||||||
|
expect(ci.y).toBe(-30)
|
||||||
|
expect(ci.scaleX).toBe(2)
|
||||||
|
expect(ci.scaleY).toBe(0.5)
|
||||||
|
expect(ci.rotation).toBe(45)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setClipInstanceTransform normalises rotation into (-180, 180]', () => {
|
||||||
|
// Values outside the canonical range are wrapped so the stored
|
||||||
|
// number stays human-readable across multiple transform
|
||||||
|
// sessions (Konva's transformer accumulates past 360).
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const c = s.addClip('Xf')
|
||||||
|
const t = s.scene.clipTracks![0]
|
||||||
|
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||||||
|
s.setClipInstanceTransform(ci.id, { rotation: 370 })
|
||||||
|
expect(ci.rotation).toBe(10)
|
||||||
|
s.setClipInstanceTransform(ci.id, { rotation: -190 })
|
||||||
|
expect(ci.rotation).toBe(170)
|
||||||
|
s.setClipInstanceTransform(ci.id, { rotation: 360 })
|
||||||
|
// 360 % 360 = 0 → pruned back out of the object.
|
||||||
|
expect('rotation' in ci).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setClipInstanceTransform prunes defaults so JSON stays clean', () => {
|
||||||
|
// Defaults (x=0, y=0, scale=1) are absent on a freshly-added
|
||||||
|
// instance. Writing them back explicitly mustn't leave the
|
||||||
|
// field around — serialized scenes stay minimal and diffable.
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const c = s.addClip('Xf')
|
||||||
|
const t = s.scene.clipTracks![0]
|
||||||
|
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||||||
|
s.setClipInstanceTransform(ci.id, { x: 50, scaleX: 2 })
|
||||||
|
expect(ci.x).toBe(50)
|
||||||
|
expect(ci.scaleX).toBe(2)
|
||||||
|
// Clear back to defaults — the keys should get deleted.
|
||||||
|
s.setClipInstanceTransform(ci.id, { x: 0, scaleX: 1 })
|
||||||
|
expect('x' in ci).toBe(false)
|
||||||
|
expect('scaleX' in ci).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setClipInstanceTransform leaves unspecified fields alone', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const c = s.addClip('Xf')
|
||||||
|
const t = s.scene.clipTracks![0]
|
||||||
|
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||||||
|
s.setClipInstanceTransform(ci.id, { x: 10, y: 20, scaleX: 3, scaleY: 3 })
|
||||||
|
// Partial patch: only x. y / scale must survive.
|
||||||
|
s.setClipInstanceTransform(ci.id, { x: 99 })
|
||||||
|
expect(ci.x).toBe(99)
|
||||||
|
expect(ci.y).toBe(20)
|
||||||
|
expect(ci.scaleX).toBe(3)
|
||||||
|
expect(ci.scaleY).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setClipInstanceTransform on an unknown id is a no-op', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const c = s.addClip('Xf')
|
||||||
|
const t = s.scene.clipTracks![0]
|
||||||
|
s.addClipInstance(c.id, 0, t.id)!
|
||||||
|
s.setClipInstanceTransform('not-an-id', { x: 5 })
|
||||||
|
// No-op; no side effects or throw.
|
||||||
|
expect(s.scene.clipInstances!.every((ci) => ci.x === undefined)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('selectClipInstance clears the layer selection and vice versa', () => {
|
||||||
|
// Selection is mutually exclusive — stage transformer only
|
||||||
|
// targets one thing at a time.
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
|
||||||
|
const l = s.addLayer(a.id)!
|
||||||
|
const c = s.addClip('Xf')
|
||||||
|
const t = s.scene.clipTracks![0]
|
||||||
|
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||||||
|
|
||||||
|
s.selectLayer(l.id)
|
||||||
|
expect(s.selectedLayerId).toBe(l.id)
|
||||||
|
expect(s.selectedClipInstanceId).toBe(null)
|
||||||
|
|
||||||
|
s.selectClipInstance(ci.id)
|
||||||
|
expect(s.selectedClipInstanceId).toBe(ci.id)
|
||||||
|
expect(s.selectedLayerId).toBe(null)
|
||||||
|
expect(s.selectedLayerIds).toEqual([])
|
||||||
|
|
||||||
|
s.selectLayer(l.id)
|
||||||
|
expect(s.selectedLayerId).toBe(l.id)
|
||||||
|
expect(s.selectedClipInstanceId).toBe(null)
|
||||||
|
|
||||||
|
s.selectClipInstance(null)
|
||||||
|
expect(s.selectedClipInstanceId).toBe(null)
|
||||||
|
// selecting null on the clip branch doesn't touch the layer
|
||||||
|
// selection — the user just cleared the clip-side pointer.
|
||||||
|
expect(s.selectedLayerId).toBe(l.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('removeClipInstance clears selectedClipInstanceId when it matched', () => {
|
||||||
|
const s = makeFreshStore()
|
||||||
|
const c = s.addClip('Xf')
|
||||||
|
const t = s.scene.clipTracks![0]
|
||||||
|
const ci = s.addClipInstance(c.id, 0, t.id)!
|
||||||
|
s.selectClipInstance(ci.id)
|
||||||
|
s.removeClipInstance(ci.id)
|
||||||
|
expect(s.selectedClipInstanceId).toBe(null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drawSceneFrame rotates the clip-instance group on the canvas', () => {
|
||||||
|
// Geometry that keeps the rect on-canvas through a 180° flip:
|
||||||
|
// - 20×10 rect anchored at layer origin (0,0) with no offset.
|
||||||
|
// - Clip instance at x=50, y=50 → unrotated, rect lives at
|
||||||
|
// world (50..70, 50..60).
|
||||||
|
// - Rotate 180° → rect flips to world (30..50, 40..50).
|
||||||
|
// Both pre and post positions sit comfortably on the 100×100
|
||||||
|
// canvas so we can assert concrete pixels on either side.
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.scene.canvas.width = 100
|
||||||
|
s.scene.canvas.height = 100
|
||||||
|
s.scene.canvas.background = 'transparent'
|
||||||
|
s.scene.duration = 5
|
||||||
|
|
||||||
|
const rect = s.addAsset({
|
||||||
|
kind: 'rect',
|
||||||
|
name: 'R',
|
||||||
|
width: 20,
|
||||||
|
height: 10,
|
||||||
|
fill: '#ff0000',
|
||||||
|
})
|
||||||
|
const clip = s.addClip('C')
|
||||||
|
clip.duration = 5
|
||||||
|
clip.layers = [
|
||||||
|
{
|
||||||
|
id: 'clip-layer-r',
|
||||||
|
name: 'R',
|
||||||
|
assetId: rect.id,
|
||||||
|
visible: true,
|
||||||
|
base: { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0, opacity: 1 },
|
||||||
|
keyframes: [],
|
||||||
|
offsetX: 0,
|
||||||
|
offsetY: 0,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const track = s.scene.clipTracks![0]
|
||||||
|
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||||||
|
s.setClipInstanceTransform(ci.id, { x: 50, y: 50 })
|
||||||
|
|
||||||
|
// Baseline: unrotated rect covers world (50..70, 50..60). A
|
||||||
|
// pixel at (60, 55) must be red; (40, 45) must be transparent.
|
||||||
|
const unrotated = document.createElement('canvas')
|
||||||
|
unrotated.width = 100
|
||||||
|
unrotated.height = 100
|
||||||
|
const uCtx = unrotated.getContext('2d')!
|
||||||
|
drawSceneFrame(uCtx, s.scene, 0, {})
|
||||||
|
const beforeInside = uCtx.getImageData(60, 55, 1, 1).data
|
||||||
|
const beforeOutside = uCtx.getImageData(40, 45, 1, 1).data
|
||||||
|
expect(beforeInside[0] > 200).toBe(true)
|
||||||
|
expect(beforeInside[3] > 200).toBe(true)
|
||||||
|
expect(beforeOutside[3]).toBe(0)
|
||||||
|
|
||||||
|
// Rotate 180°: rect flips around (50, 50) to world (30..50,
|
||||||
|
// 40..50). The two probe pixels swap roles.
|
||||||
|
s.setClipInstanceTransform(ci.id, { rotation: 180 })
|
||||||
|
const rotated = document.createElement('canvas')
|
||||||
|
rotated.width = 100
|
||||||
|
rotated.height = 100
|
||||||
|
const rCtx = rotated.getContext('2d')!
|
||||||
|
drawSceneFrame(rCtx, s.scene, 0, {})
|
||||||
|
const afterInside = rCtx.getImageData(40, 45, 1, 1).data
|
||||||
|
const afterOutside = rCtx.getImageData(60, 55, 1, 1).data
|
||||||
|
expect(afterInside[0] > 200).toBe(true)
|
||||||
|
expect(afterInside[3] > 200).toBe(true)
|
||||||
|
expect(afterOutside[3]).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('drawSceneFrame translates + scales the clip-instance group on the canvas', () => {
|
||||||
|
// Seeds a 100×100 scene with a tiny red rect clip; places one
|
||||||
|
// instance at the top-left (0,0) with 2× scale so the 10×10
|
||||||
|
// rect renders as a 20×20 block. Reads pixels to confirm the
|
||||||
|
// transform actually landed on the canvas-path renderer
|
||||||
|
// (mask-preview, thumbnails, etc. all go through drawSceneFrame).
|
||||||
|
const s = makeFreshStore()
|
||||||
|
s.scene.canvas.width = 100
|
||||||
|
s.scene.canvas.height = 100
|
||||||
|
s.scene.canvas.background = 'transparent'
|
||||||
|
s.scene.duration = 5
|
||||||
|
|
||||||
|
const rect = s.addAsset({
|
||||||
|
kind: 'rect',
|
||||||
|
name: 'R',
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
fill: '#ff0000',
|
||||||
|
})
|
||||||
|
const clip = s.addClip('C')
|
||||||
|
clip.duration = 5
|
||||||
|
// Build the clip's sole layer directly — addLayer would target
|
||||||
|
// the scene, not the clip library.
|
||||||
|
clip.layers = [
|
||||||
|
{
|
||||||
|
id: 'clip-layer-1',
|
||||||
|
name: 'RL',
|
||||||
|
assetId: rect.id,
|
||||||
|
visible: true,
|
||||||
|
base: {
|
||||||
|
x: 5,
|
||||||
|
y: 5,
|
||||||
|
scaleX: 1,
|
||||||
|
scaleY: 1,
|
||||||
|
rotation: 0,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
keyframes: [],
|
||||||
|
offsetX: 5,
|
||||||
|
offsetY: 5,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const track = s.scene.clipTracks![0]
|
||||||
|
const ci = s.addClipInstance(clip.id, 0, track.id)!
|
||||||
|
// Transform: nudge to (0,0) origin for the top-left and scale 2×.
|
||||||
|
s.setClipInstanceTransform(ci.id, { x: 0, y: 0, scaleX: 2, scaleY: 2 })
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = 100
|
||||||
|
canvas.height = 100
|
||||||
|
const ctx = canvas.getContext('2d')!
|
||||||
|
drawSceneFrame(ctx, s.scene, 0, {})
|
||||||
|
// Without transform the 10×10 rect at (5,5) covers x=0..10, y=0..10
|
||||||
|
// (layer's own offset centres it on its base pos). With scale 2×
|
||||||
|
// it covers 0..20 in both axes. A pixel at (15, 15) should be red.
|
||||||
|
const insideAfterScale = ctx.getImageData(15, 15, 1, 1).data
|
||||||
|
expect(insideAfterScale[0] > 200).toBe(true)
|
||||||
|
expect(insideAfterScale[3] > 200).toBe(true)
|
||||||
|
// A pixel at (25, 25) should still be blank (outside the
|
||||||
|
// scaled rect + stage-render still clips to canvas).
|
||||||
|
const outsideAfterScale = ctx.getImageData(25, 25, 1, 1).data
|
||||||
|
expect(outsideAfterScale[3]).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
test('setTrackColor applies to layer and can be cleared with null', () => {
|
test('setTrackColor applies to layer and can be cleared with null', () => {
|
||||||
const s = makeFreshStore()
|
const s = makeFreshStore()
|
||||||
const a = s.addAsset({ kind: 'rect', name: 'Col', width: 20, height: 20, fill: '#123' })
|
const a = s.addAsset({ kind: 'rect', name: 'Col', width: 20, height: 20, fill: '#123' })
|
||||||
|
|||||||
+13
-1
@@ -1,6 +1,6 @@
|
|||||||
import type { Scene } from '@/types/scene'
|
import type { Scene } from '@/types/scene'
|
||||||
|
|
||||||
const RUNTIME_VERSION = '15'
|
const RUNTIME_VERSION = '17'
|
||||||
|
|
||||||
const ENGINE_SOURCE = `(function(){
|
const ENGINE_SOURCE = `(function(){
|
||||||
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
|
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
|
||||||
@@ -732,9 +732,21 @@ const ENGINE_SOURCE = `(function(){
|
|||||||
var offset=time-inst2.startTime;
|
var offset=time-inst2.startTime;
|
||||||
if(offset<0||offset>visibleClip)continue;
|
if(offset<0||offset>visibleClip)continue;
|
||||||
var localT=tS+offset;
|
var localT=tS+offset;
|
||||||
|
var ix2=inst2.x||0, iy2=inst2.y||0;
|
||||||
|
var isx2=(inst2.scaleX==null?1:inst2.scaleX);
|
||||||
|
var isy2=(inst2.scaleY==null?1:inst2.scaleY);
|
||||||
|
var irot2=inst2.rotation||0;
|
||||||
|
var needsXf=ix2!==0||iy2!==0||isx2!==1||isy2!==1||irot2!==0;
|
||||||
|
if(needsXf){
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(ix2,iy2);
|
||||||
|
if(irot2!==0) ctx.rotate(irot2*Math.PI/180);
|
||||||
|
ctx.scale(isx2,isy2);
|
||||||
|
}
|
||||||
for(var cl2=clip2.layers.length-1;cl2>=0;cl2--){
|
for(var cl2=clip2.layers.length-1;cl2>=0;cl2--){
|
||||||
drawLayer(clip2.layers[cl2],localT);
|
drawLayer(clip2.layers[cl2],localT);
|
||||||
}
|
}
|
||||||
|
if(needsXf) ctx.restore();
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -446,7 +446,21 @@ export function drawSceneFrame(
|
|||||||
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
|
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
|
||||||
const offsetIntoBlock = time - inst.startTime
|
const offsetIntoBlock = time - inst.startTime
|
||||||
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
|
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
|
||||||
|
const ix = inst.x ?? 0
|
||||||
|
const iy = inst.y ?? 0
|
||||||
|
const isx = inst.scaleX ?? 1
|
||||||
|
const isy = inst.scaleY ?? 1
|
||||||
|
const irot = inst.rotation ?? 0
|
||||||
|
const needsTransform =
|
||||||
|
ix !== 0 || iy !== 0 || isx !== 1 || isy !== 1 || irot !== 0
|
||||||
|
if (needsTransform) {
|
||||||
|
drawCtx.save()
|
||||||
|
drawCtx.translate(ix, iy)
|
||||||
|
if (irot !== 0) drawCtx.rotate((irot * Math.PI) / 180)
|
||||||
|
drawCtx.scale(isx, isy)
|
||||||
|
}
|
||||||
drawLayers(drawCtx, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
|
drawLayers(drawCtx, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
|
||||||
|
if (needsTransform) drawCtx.restore()
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ interface State {
|
|||||||
* anchor. Used by group-selected / delete-selected and any
|
* anchor. Used by group-selected / delete-selected and any
|
||||||
* operation that needs the whole set. */
|
* operation that needs the whole set. */
|
||||||
selectedLayerIds: string[]
|
selectedLayerIds: string[]
|
||||||
|
/** Selected clip-instance id — drives the canvas transformer when a
|
||||||
|
* packed clip is clicked on the stage. Mutually exclusive with
|
||||||
|
* `selectedLayerId`: selecting a clip clears the layer selection
|
||||||
|
* and vice versa. */
|
||||||
|
selectedClipInstanceId: string | null
|
||||||
playhead: number
|
playhead: number
|
||||||
playing: boolean
|
playing: boolean
|
||||||
activeClipId: string | null
|
activeClipId: string | null
|
||||||
@@ -268,6 +273,7 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
scene: createBlankScene(),
|
scene: createBlankScene(),
|
||||||
selectedLayerId: null,
|
selectedLayerId: null,
|
||||||
selectedLayerIds: [],
|
selectedLayerIds: [],
|
||||||
|
selectedClipInstanceId: null,
|
||||||
playhead: 0,
|
playhead: 0,
|
||||||
playing: false,
|
playing: false,
|
||||||
activeClipId: null,
|
activeClipId: null,
|
||||||
@@ -454,6 +460,14 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
selectLayer(id: string | null) {
|
selectLayer(id: string | null) {
|
||||||
this.selectedLayerId = id
|
this.selectedLayerId = id
|
||||||
this.selectedLayerIds = id ? [id] : []
|
this.selectedLayerIds = id ? [id] : []
|
||||||
|
if (id) this.selectedClipInstanceId = null
|
||||||
|
},
|
||||||
|
selectClipInstance(id: string | null) {
|
||||||
|
this.selectedClipInstanceId = id
|
||||||
|
if (id) {
|
||||||
|
this.selectedLayerId = null
|
||||||
|
this.selectedLayerIds = []
|
||||||
|
}
|
||||||
},
|
},
|
||||||
/** Shift-click behaviour: add the layer to the selection if it's
|
/** Shift-click behaviour: add the layer to the selection if it's
|
||||||
* not already there; otherwise remove it. The anchor moves to
|
* not already there; otherwise remove it. The anchor moves to
|
||||||
@@ -1808,6 +1822,49 @@ export const useSceneStore = defineStore('scene', {
|
|||||||
removeClipInstance(id: string) {
|
removeClipInstance(id: string) {
|
||||||
if (!this.scene.clipInstances) return
|
if (!this.scene.clipInstances) return
|
||||||
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.id !== id)
|
this.scene.clipInstances = this.scene.clipInstances.filter((ci) => ci.id !== id)
|
||||||
|
if (this.selectedClipInstanceId === id) this.selectedClipInstanceId = null
|
||||||
|
},
|
||||||
|
/** Update the per-instance x / y / scaleX / scaleY. Missing fields
|
||||||
|
* on the patch are left alone; clearing a field (setting it to
|
||||||
|
* the default 0 / 1) prunes it from the stored object so the
|
||||||
|
* JSON stays clean. The clip's own keyframes are untouched. */
|
||||||
|
setClipInstanceTransform(
|
||||||
|
id: string,
|
||||||
|
patch: Partial<{
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
scaleX: number
|
||||||
|
scaleY: number
|
||||||
|
rotation: number
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
const inst = this.scene.clipInstances?.find((ci) => ci.id === id)
|
||||||
|
if (!inst) return
|
||||||
|
if (patch.x !== undefined) {
|
||||||
|
if (patch.x === 0) delete inst.x
|
||||||
|
else inst.x = patch.x
|
||||||
|
}
|
||||||
|
if (patch.y !== undefined) {
|
||||||
|
if (patch.y === 0) delete inst.y
|
||||||
|
else inst.y = patch.y
|
||||||
|
}
|
||||||
|
if (patch.scaleX !== undefined) {
|
||||||
|
if (patch.scaleX === 1) delete inst.scaleX
|
||||||
|
else inst.scaleX = patch.scaleX
|
||||||
|
}
|
||||||
|
if (patch.scaleY !== undefined) {
|
||||||
|
if (patch.scaleY === 1) delete inst.scaleY
|
||||||
|
else inst.scaleY = patch.scaleY
|
||||||
|
}
|
||||||
|
if (patch.rotation !== undefined) {
|
||||||
|
// Normalise to (-180, 180] so the stored value stays human-
|
||||||
|
// readable and idempotent across multiple transform sessions.
|
||||||
|
let r = patch.rotation % 360
|
||||||
|
if (r > 180) r -= 360
|
||||||
|
if (r <= -180) r += 360
|
||||||
|
if (r === 0) delete inst.rotation
|
||||||
|
else inst.rotation = r
|
||||||
|
}
|
||||||
},
|
},
|
||||||
addTrackGroup(name?: string): TrackGroup {
|
addTrackGroup(name?: string): TrackGroup {
|
||||||
const group: TrackGroup = {
|
const group: TrackGroup = {
|
||||||
|
|||||||
@@ -215,6 +215,18 @@ export interface ClipInstance {
|
|||||||
clipTrackId: string
|
clipTrackId: string
|
||||||
trimStart?: number
|
trimStart?: number
|
||||||
trimEnd?: number
|
trimEnd?: number
|
||||||
|
/**
|
||||||
|
* Static per-instance transform applied as a group wrapper over the
|
||||||
|
* clip's layers on the stage. The clip itself is NOT animatable —
|
||||||
|
* these values are constants for this particular placement. Missing
|
||||||
|
* fields default to 0 (position / rotation) or 1 (scale).
|
||||||
|
*/
|
||||||
|
x?: number
|
||||||
|
y?: number
|
||||||
|
scaleX?: number
|
||||||
|
scaleY?: number
|
||||||
|
/** Degrees clockwise; applied around the group's origin. */
|
||||||
|
rotation?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackgroundVideo {
|
export interface BackgroundVideo {
|
||||||
|
|||||||
Reference in new issue
Block a user