From 56cecabaaeea4bddbe36c878a5628190a93b8714 Mon Sep 17 00:00:00 2001
From: th
Date: Fri, 24 Apr 2026 16:20:18 +0200
Subject: [PATCH] 0.18.3: grouped clip tracks render, playhead is a snap magnet
- Fix mask-preview / frame-export dropping any group whose children
are clip tracks. render.ts's old group branch only pulled Layer
children out of group.childIds; new drawTrackGroup walks both
layer AND clip-track ids and still applies group-local masks to
the composite. Same fix mirrored into the HTML export runtime in
exportPlayer.ts.
- Snapping magnet for the playhead: snapTime now includes
store.playhead in its candidate list by default. Clip drags, trim
handles, keyframe drags, marker drags and in/out brackets stick
to the needle when snap is on. Scrub handlers pass
ignorePlayhead:true so the needle doesn't stick to its own prior
position during a scrub.
- Clip trim handles derive the snapped edge first and back out to
trimStart/trimEnd, so the edge lands exactly on the magnet.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
package.json | 2 +-
src/components/Timeline.vue | 62 +++++--
src/devtools/tests.ts | 333 ++++++++++++++++++++++++++++++++++++
src/lib/exportPlayer.ts | 51 +++++-
src/lib/render.ts | 125 +++++++++++++-
src/version.ts | 2 +-
6 files changed, 543 insertions(+), 32 deletions(-)
diff --git a/package.json b/package.json
index 163a5cc..f49d1ec 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
- "version": "0.18.2",
+ "version": "0.18.3",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue
index a84106f..cbbb426 100644
--- a/src/components/Timeline.vue
+++ b/src/components/Timeline.vue
@@ -331,8 +331,19 @@ function allKeyframeTimes(ignoreKeyframeId?: string): number[] {
return out
}
-function snapTime(t: number, ignoreKeyframeId?: string): number {
- return snapToCandidates(t, allKeyframeTimes(ignoreKeyframeId), {
+interface SnapTimeOpts {
+ ignoreKeyframeId?: string
+ /** True when snapping the playhead itself — omits the playhead
+ * from the candidate list so the scrub doesn't stick to its own
+ * previous position. Clip drags, clip trims, keyframe drags and
+ * marker drags all pick up the playhead as a magnet by default. */
+ ignorePlayhead?: boolean
+}
+
+function snapTime(t: number, opts: SnapTimeOpts = {}): number {
+ const candidates = allKeyframeTimes(opts.ignoreKeyframeId)
+ if (!opts.ignorePlayhead) candidates.push(store.playhead)
+ return snapToCandidates(t, candidates, {
enabled: snapEnabled.value,
pxPerSec: effectivePxPerSec.value,
pixelThreshold: SNAP_PX,
@@ -425,7 +436,7 @@ function seekFromEvent(e: PointerEvent, el: HTMLElement) {
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
- store.setPlayhead(snapTime(ratio * duration.value))
+ store.setPlayhead(snapTime(ratio * duration.value, { ignorePlayhead: true }))
}
function onScrubPointerDown(e: PointerEvent) {
@@ -585,7 +596,7 @@ function onLaneClick(e: MouseEvent) {
const rect = el.getBoundingClientRect()
if (rect.width <= 0) return
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
- store.setPlayhead(snapTime(ratio * duration.value))
+ store.setPlayhead(snapTime(ratio * duration.value, { ignorePlayhead: true }))
}
const canCreateKf = computed(() => {
@@ -602,7 +613,10 @@ function addKeyframeForSelected() {
if (!id) return
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer || isLayerIdLocked(id)) return
- const time = snapTime(store.playhead)
+ // Snap the playhead itself to the nearest keyframe before adding —
+ // the current playhead position is NOT a candidate (it's the value
+ // we're rounding).
+ const time = snapTime(store.playhead, { ignorePlayhead: true })
const sampled = sampleLayer(layer, time)
const props: Partial = {
scaleX: sampled.scaleX,
@@ -1087,9 +1101,11 @@ function onKeyframePointerMove(e: PointerEvent) {
// For snapping, ignore all currently selected kfs so we don't snap to ourselves
const ignoreIds = new Set()
for (const s of d.snapshot) ignoreIds.add(s.keyframeId)
+ // Include the playhead as a snap magnet so dragged keyframes
+ // stick to it the same way they stick to sibling keyframes.
const snappedPrimary = snapToCandidates(
rawNewTime,
- allKeyframeTimesExcluding(ignoreIds),
+ [...allKeyframeTimesExcluding(ignoreIds), store.playhead],
{
enabled: snapEnabled.value,
pxPerSec: effectivePxPerSec.value,
@@ -1854,7 +1870,11 @@ function onClipBlockPointerMove(e: PointerEvent) {
const pps = effectivePxPerSec.value
if (pps <= 0) return
const deltaSec = deltaPx / pps
- const newStart = Math.max(0, m.initialStartTime + deltaSec)
+ // Snap the clip's left edge to the playhead (and keyframes) when
+ // snapping is on. The cascade in setClipInstancePosition still
+ // runs afterwards, so colliding with a neighbour on the track
+ // behaves the same as it did before.
+ const newStart = Math.max(0, snapTime(m.initialStartTime + deltaSec))
store.setClipInstancePosition(m.instanceId, newStart)
}
@@ -2500,28 +2520,34 @@ function onClipHandlePointerMove(e: PointerEvent) {
const pps = effectivePxPerSec.value
if (pps <= 0) return
const rawDelta = (e.clientX - t.startX) / pps
+ const inst = store.scene.clipInstances?.find((ci) => ci.id === t.instanceId)
+ const clip = inst ? store.scene.clips?.find((c) => c.id === inst.clipId) : null
+ if (!clip) return
if (t.side === 'start') {
- // Clamp the delta locally so pulling the handle past the natural
- // start doesn't shift startTime — previously trimStart clamped to
- // 0 in the store but startTime still moved, which slid the whole
- // clip. Bounds: trimStart stays in [0, maxBudget - trimEnd] and
- // we also can't shift startTime below 0.
- const inst = store.scene.clipInstances?.find((ci) => ci.id === t.instanceId)
- const clip = inst ? store.scene.clips?.find((c) => c.id === inst.clipId) : null
- if (!clip) return
+ // Compute the target left-edge time, snap it to the playhead /
+ // keyframes, then derive the delta. Clamping below makes sure
+ // we don't shift startTime past 0 or eat past the clip's
+ // available budget — same bounds as before.
+ const targetEdge = t.initialStartTime + rawDelta
+ const snappedEdge = snapTime(targetEdge)
+ const snappedDelta = snappedEdge - t.initialStartTime
const MIN_VISIBLE = 0.05
const maxBudget = clip.duration - MIN_VISIBLE
const minDelta = Math.max(-t.initialTrimStart, -t.initialStartTime)
const maxDelta = maxBudget - t.initialTrimStart - t.initialTrimEnd
- const delta = Math.min(Math.max(rawDelta, minDelta), maxDelta)
+ const delta = Math.min(Math.max(snappedDelta, minDelta), maxDelta)
store.trimClipInstance(t.instanceId, {
trimStart: t.initialTrimStart + delta,
startTime: t.initialStartTime + delta,
})
} else {
- // shift right edge right = less trimEnd (reveal more)
+ // shift right edge right = less trimEnd (reveal more). Snap the
+ // edge position, then derive trimEnd.
+ const initialRightEdge = t.initialStartTime + clip.duration - t.initialTrimEnd
+ const snappedEdge = snapTime(initialRightEdge + rawDelta)
+ const newTrimEnd = clip.duration - (snappedEdge - t.initialStartTime)
store.trimClipInstance(t.instanceId, {
- trimEnd: t.initialTrimEnd - rawDelta,
+ trimEnd: newTrimEnd,
})
}
}
diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts
index 05e2580..7f87565 100644
--- a/src/devtools/tests.ts
+++ b/src/devtools/tests.ts
@@ -6467,6 +6467,73 @@ export function registerAllTests(): void {
handle.unmount()
}
})
+
+ test('right-click on a group row → Rename group → prompt → renameGroup', async () => {
+ const { dialogState, resolveDialog } = await import(
+ '@/components/ui/dialogs'
+ )
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
+ const l = s.addLayer(a.id)!
+ s.selectLayer(l.id)
+ const groupId = s.groupSelectedLayers('G1')!
+ const group = s.scene.groups!.find((g) => g.id === groupId)!
+ const handle = mountComponent(Timeline, {})
+ try {
+ await nextTick()
+ const row = handle.host.querySelector(
+ '.track.group-track .track-name',
+ ) as HTMLElement
+ expect(row).toBeTruthy()
+ row.dispatchEvent(
+ new MouseEvent('contextmenu', {
+ bubbles: true, cancelable: true, clientX: 10, clientY: 10,
+ }),
+ )
+ await nextTick()
+ const menu = handle.host.querySelector('.kf-menu') as HTMLElement
+ expect(menu).toBeTruthy()
+ const renameBtn = findMenuItem(menu, i18nT('timeline.renameGroup'))
+ expect(renameBtn).toBeTruthy()
+ renameBtn!.click()
+ expect(dialogState.value?.kind).toBe('prompt')
+ resolveDialog('Hero group')
+ await nextTick()
+ expect(group.name).toBe('Hero group')
+ } finally {
+ handle.unmount()
+ }
+ })
+
+ test('dblclick on a group row opens the rename prompt', async () => {
+ // Groups also support rename via double-click on the track
+ // name. Covers the second entry point so it stays wired up.
+ const { dialogState, resolveDialog } = await import(
+ '@/components/ui/dialogs'
+ )
+ const s = makeFreshStore()
+ const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
+ const l = s.addLayer(a.id)!
+ s.selectLayer(l.id)
+ const groupId = s.groupSelectedLayers('Old')!
+ const group = s.scene.groups!.find((g) => g.id === groupId)!
+ const handle = mountComponent(Timeline, {})
+ try {
+ await nextTick()
+ const row = handle.host.querySelector(
+ '.track.group-track .track-name',
+ ) as HTMLElement
+ row.dispatchEvent(
+ new MouseEvent('dblclick', { bubbles: true, cancelable: true }),
+ )
+ expect(dialogState.value?.kind).toBe('prompt')
+ resolveDialog('New name')
+ await nextTick()
+ expect(group.name).toBe('New name')
+ } finally {
+ handle.unmount()
+ }
+ })
})
suite('ui — Timeline group eye button', () => {
@@ -6496,6 +6563,133 @@ export function registerAllTests(): void {
})
})
+ suite('ui — Timeline playhead-magnetic snap', () => {
+ // Snapping on → dragged clip blocks + clip trim handles stick to
+ // the playhead (and to keyframes).
+
+ /** Measure pps from the clip lane's layout width. The clip lane is
+ * styled width: laneWidth = duration * effectivePxPerSec, with
+ * NO minimum floor — unlike the clip-block itself, whose width
+ * is max(6px, …) and over-reports pps for short clips. */
+ function measurePxPerSec(root: HTMLElement, sceneDuration: number): number {
+ const lane = root.querySelector('.clip-lane') as HTMLElement
+ const w = lane.getBoundingClientRect().width
+ return w / sceneDuration
+ }
+
+ /** Dispatch a clean pointerdown / pointermove / pointerup sequence
+ * that drags an element by `deltaPx` horizontally from its
+ * centre. Real mouse pointers use pointerId 1; reusing that
+ * keeps setPointerCapture happy across handlers. */
+ function dragX(el: HTMLElement, deltaPx: number) {
+ const r = el.getBoundingClientRect()
+ const cx = r.left + r.width / 2
+ const cy = r.top + r.height / 2
+ el.dispatchEvent(new PointerEvent('pointerdown', {
+ bubbles: true, cancelable: true, pointerId: 1, button: 0,
+ clientX: cx, clientY: cy,
+ }))
+ el.dispatchEvent(new PointerEvent('pointermove', {
+ bubbles: true, cancelable: true, pointerId: 1,
+ clientX: cx + deltaPx, clientY: cy,
+ }))
+ el.dispatchEvent(new PointerEvent('pointerup', {
+ bubbles: true, cancelable: true, pointerId: 1, button: 0,
+ clientX: cx + deltaPx, clientY: cy,
+ }))
+ }
+
+ test('dragging a clip block lands exactly on the playhead when close', async () => {
+ const sceneDur = 20
+ const s = makeFreshStore()
+ s.setDuration(sceneDur)
+ const clip = s.addClip('C')
+ clip.duration = 10 // big enough that block width is always > 6px min
+ const track = s.scene.clipTracks![0]
+ const ci = s.addClipInstance(clip.id, 0, track.id)!
+ s.setPlayhead(6)
+ const handle = mountComponent(Timeline, {})
+ try {
+ await nextTick()
+ const block = handle.host.querySelector('.clip-block') as HTMLElement
+ expect(block).toBeTruthy()
+ const pps = measurePxPerSec(handle.host, sceneDur)
+ expect(pps > 0).toBe(true)
+ // Land ~0.03s off the playhead = well under the 8px snap
+ // threshold at any realistic pps.
+ const targetStart = 5.97
+ const deltaPx = (targetStart - ci.startTime) * pps
+ dragX(block, deltaPx)
+ await nextTick()
+ expect(ci.startTime).toBe(6)
+ } finally {
+ handle.unmount()
+ }
+ })
+
+ test('dragging the right handle of a clip snaps its end to the playhead', async () => {
+ // Reproduces the user request literally: "pull the end of a
+ // clip — it has to snap to the needle."
+ const sceneDur = 20
+ const s = makeFreshStore()
+ s.setDuration(sceneDur)
+ const clip = s.addClip('C')
+ clip.duration = 10 // right edge at startTime+10
+ const track = s.scene.clipTracks![0]
+ const ci = s.addClipInstance(clip.id, 0, track.id)!
+ s.setPlayhead(3)
+ const handle = mountComponent(Timeline, {})
+ try {
+ await nextTick()
+ const block = handle.host.querySelector('.clip-block') as HTMLElement
+ const rightHandle = block.querySelector(
+ '.clip-handle.end',
+ ) as HTMLElement
+ expect(rightHandle).toBeTruthy()
+ const pps = measurePxPerSec(handle.host, sceneDur)
+ const targetEdge = 3.05
+ const deltaPx = (targetEdge - 10) * pps
+ dragX(rightHandle, deltaPx)
+ await nextTick()
+ // Right edge = 0 + 10 - trimEnd, snapped to 3 → trimEnd = 7.
+ expect(ci.trimEnd).toBeCloseTo(7, 5)
+ } finally {
+ handle.unmount()
+ }
+ })
+
+ test('dragging the left handle of a clip snaps its start to the playhead', async () => {
+ const sceneDur = 20
+ const s = makeFreshStore()
+ s.setDuration(sceneDur)
+ const clip = s.addClip('C')
+ clip.duration = 10
+ const track = s.scene.clipTracks![0]
+ const ci = s.addClipInstance(clip.id, 1, track.id)!
+ s.setPlayhead(4)
+ const handle = mountComponent(Timeline, {})
+ try {
+ await nextTick()
+ const block = handle.host.querySelector('.clip-block') as HTMLElement
+ const leftHandle = block.querySelector(
+ '.clip-handle.start',
+ ) as HTMLElement
+ expect(leftHandle).toBeTruthy()
+ const pps = measurePxPerSec(handle.host, sceneDur)
+ const targetEdge = 3.95
+ const deltaPx = (targetEdge - 1) * pps
+ dragX(leftHandle, deltaPx)
+ await nextTick()
+ // Left edge snapped to 4 → startTime = 4, trimStart absorbs
+ // the 3s shift (initialTrimStart 0 + delta 3).
+ expect(ci.startTime).toBeCloseTo(4, 5)
+ expect(ci.trimStart).toBeCloseTo(3, 5)
+ } finally {
+ handle.unmount()
+ }
+ })
+ })
+
suite('ui — Timeline audio-clip context menu', () => {
test('right-click on an audio-clip-block opens a menu with Duplicate + Delete', async () => {
const s = makeFreshStore()
@@ -8806,6 +9000,145 @@ export function registerAllTests(): void {
})
})
+ suite('render — grouped clip tracks respect group masks', () => {
+ // Regression: the render.ts group branch used to extract only
+ // layer children from group.childIds, dropping clip tracks
+ // silently. A group containing only clips rendered as empty in
+ // mask-preview and frame-based exports — even when a SIBLING
+ // group had a mask, the clip group was invisible too because
+ // the renderer simply walked past its contents.
+
+ function setupWithClipTrackInGroup() {
+ const s = makeFreshStore()
+ s.scene.canvas.width = 100
+ s.scene.canvas.height = 100
+ s.scene.canvas.background = 'transparent'
+ const rectAsset = s.addAsset({
+ kind: 'rect', name: 'R', width: 100, height: 100, fill: '#00ff00',
+ })
+ // Clip with a single full-stage green rect, instantiated on a
+ // clip track tucked inside group A.
+ const clip = s.addClip('C')
+ clip.duration = 1
+ s.enterClip(clip.id)
+ const inner = s.addLayer(rectAsset.id)!
+ s.upsertKeyframe(inner.id, 0, { x: 50, y: 50 })
+ s.exitClip()
+ const track = s.scene.clipTracks![0]
+ s.addClipInstance(clip.id, 0, track.id)
+ // Wrap the track in group A (no mask inside — just content).
+ const groupA = s.addTrackGroup('A', track.id)
+ groupA.childIds.push(track.id)
+ const order = s.scene.trackOrder!
+ const ti = order.indexOf(track.id)
+ if (ti >= 0) order.splice(ti, 1)
+ return { s }
+ }
+
+ test('group containing only clip tracks renders its instance content', () => {
+ const { s } = setupWithClipTrackInGroup()
+ const canvas = document.createElement('canvas')
+ canvas.width = 100
+ canvas.height = 100
+ const ctx = canvas.getContext('2d')!
+ drawSceneFrame(ctx, s.scene, 0, {})
+ // The clip's green rect should cover the canvas — at least one
+ // interior pixel is green with full alpha.
+ const px = ctx.getImageData(50, 50, 1, 1).data
+ expect(px[1] > 200).toBe(true)
+ expect(px[3] > 200).toBe(true)
+ })
+
+ test('sibling group with a mask does not erase an unmasked group next to it', () => {
+ // User-reported scenario: group A (clip) + group G (element B
+ // + mask C). Before the fix, mask-preview showed nothing for
+ // A; afterwards, A's content is independent of G's mask.
+ const { s } = setupWithClipTrackInGroup()
+ // Group G holds a single red layer + a small positive mask,
+ // both at the centre. Without the fix A's green would stay
+ // off-screen because the render path dropped its clip track.
+ const red = s.addAsset({
+ kind: 'rect', name: 'B', width: 40, height: 40, fill: '#ff0000',
+ })
+ const bLayer = s.addLayer(red.id)!
+ s.upsertKeyframe(bLayer.id, 0, { x: 50, y: 50 })
+ const maskAsset = s.addAsset({
+ kind: 'circle', name: 'M', radius: 10, fill: '#ffffff',
+ })
+ const cLayer = s.addLayer(maskAsset.id)!
+ s.upsertKeyframe(cLayer.id, 0, { x: 50, y: 50 })
+ s.setLayerMask(cLayer.id, true, 'positive')
+ // Wrap B + C in a fresh group G. selectLayer + groupSelectedLayers
+ // groups them together, leaving group A untouched.
+ s.selectLayer(bLayer.id)
+ s.toggleLayerSelection(cLayer.id)
+ const gId = s.groupSelectedLayers('G')!
+ expect(gId).toBeTruthy()
+ const canvas = document.createElement('canvas')
+ canvas.width = 100
+ canvas.height = 100
+ const ctx = canvas.getContext('2d')!
+ drawSceneFrame(ctx, s.scene, 0, {})
+ // A pixel far from the mask-centre (e.g. corner 10,10) is
+ // outside G's mask — B would be clipped there. But group A's
+ // green clip covers the whole canvas and is unaffected by G's
+ // mask, so the pixel must be green (and opaque).
+ const corner = ctx.getImageData(10, 10, 1, 1).data
+ expect(corner[1] > 200).toBe(true)
+ expect(corner[3] > 200).toBe(true)
+ })
+
+ test('mask inside a group clips clip-track content in that same group', () => {
+ // The group-local mask still has to apply to clip instances
+ // that sit alongside it — otherwise grouping a clip with a
+ // mask would silently render the clip unmasked.
+ const s = makeFreshStore()
+ s.scene.canvas.width = 100
+ s.scene.canvas.height = 100
+ s.scene.canvas.background = 'transparent'
+ const rect = s.addAsset({
+ kind: 'rect', name: 'R', width: 100, height: 100, fill: '#00ff00',
+ })
+ const clip = s.addClip('C')
+ clip.duration = 1
+ s.enterClip(clip.id)
+ const inner = s.addLayer(rect.id)!
+ s.upsertKeyframe(inner.id, 0, { x: 50, y: 50 })
+ s.exitClip()
+ const track = s.scene.clipTracks![0]
+ s.addClipInstance(clip.id, 0, track.id)
+ // Build a mask circle, then group (clip track + mask) together.
+ const maskAsset = s.addAsset({
+ kind: 'circle', name: 'M', radius: 15, fill: '#ffffff',
+ })
+ const m = s.addLayer(maskAsset.id)!
+ s.upsertKeyframe(m.id, 0, { x: 50, y: 50 })
+ s.setLayerMask(m.id, true, 'positive')
+ const g = s.addTrackGroup('G', track.id)
+ g.childIds.push(m.id)
+ g.childIds.push(track.id)
+ const order = s.scene.trackOrder!
+ // Remove the clip track + mask layer from the top-level order
+ // so only the group sits there.
+ for (const id of [track.id, m.id]) {
+ const i = order.indexOf(id)
+ if (i >= 0) order.splice(i, 1)
+ }
+ const canvas = document.createElement('canvas')
+ canvas.width = 100
+ canvas.height = 100
+ const ctx = canvas.getContext('2d')!
+ drawSceneFrame(ctx, s.scene, 0, {})
+ // Inside the mask — green kept.
+ const inside = ctx.getImageData(50, 50, 1, 1).data
+ expect(inside[1] > 200).toBe(true)
+ expect(inside[3] > 200).toBe(true)
+ // Outside the mask — cleared.
+ const outside = ctx.getImageData(5, 5, 1, 1).data
+ expect(outside[3]).toBe(0)
+ })
+ })
+
suite('scene store — markers', () => {
test('addMarker defaults the name when none provided', () => {
const s = makeFreshStore()
diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts
index e146ddf..8ace46b 100644
--- a/src/lib/exportPlayer.ts
+++ b/src/lib/exportPlayer.ts
@@ -1016,14 +1016,53 @@ const ENGINE_SOURCE = `(function(){
}
var grp=groupById2[id];
if(grp){
- var gContent=[], gMasks=[];
+ // gContentIds holds layer OR clip-track child ids. Dropping
+ // clip tracks here used to make any group-with-only-clips
+ // invisible in the runtime (same bug drawTrackGroup fixes
+ // on the editor side).
+ var gContentIds=[], gMasks=[];
for(var ch=grp.childIds.length-1;ch>=0;ch--){
- var cl0=layerById[grp.childIds[ch]];
- if(!cl0) continue;
- if(cl0.isMask) gMasks.push(cl0); else gContent.push(cl0);
+ var cid=grp.childIds[ch];
+ var cl0=layerById[cid];
+ if(cl0){
+ if(cl0.isMask) gMasks.push(cl0); else gContentIds.push(cid);
+ continue;
+ }
+ if(instByTrack[cid]) gContentIds.push(cid);
}
+ var drawChildInGroup=function(cid){
+ var lr=layerById[cid];
+ if(lr){ drawLayer(lr,time); return; }
+ var insts3=instByTrack[cid]||[];
+ for(var ti3=0;ti3vis4) continue;
+ var lt4=tS4+off4;
+ var ix4=inst4.x||0, iy4=inst4.y||0;
+ var isx4=(inst4.scaleX==null?1:inst4.scaleX);
+ var isy4=(inst4.scaleY==null?1:inst4.scaleY);
+ var irot4=inst4.rotation||0;
+ var needsXf4=ix4!==0||iy4!==0||isx4!==1||isy4!==1||irot4!==0;
+ if(needsXf4){
+ ctx.save();
+ ctx.translate(ix4,iy4);
+ if(irot4!==0) ctx.rotate(irot4*Math.PI/180);
+ ctx.scale(isx4,isy4);
+ }
+ for(var cl4=clip4.layers.length-1;cl4>=0;cl4--){
+ drawLayer(clip4.layers[cl4],lt4);
+ }
+ if(needsXf4) ctx.restore();
+ }
+ };
if(!gMasks.length){
- for(var gi3=0;gi3,
+ clipTrackById: Map[number]>,
+ instancesByTrack: Map>,
+ clipById: Map[number]>,
+ assetMap: Map,
+ images: Record,
+ buttonStates?: Map,
+): void {
+ // Partition into masks vs. renderable ids (layer ids + clip-track
+ // ids). Editor-hidden clip tracks drop out here so the cascade
+ // matches the Konva stage.
+ const masks: Layer[] = []
+ const renderIds: string[] = []
+ for (let k = childIds.length - 1; k >= 0; k--) {
+ const cid = childIds[k]
+ const layer = layerById.get(cid)
+ if (layer) {
+ if (layer.isMask) masks.push(layer)
+ else renderIds.push(cid)
+ continue
+ }
+ if (clipTrackById.get(cid)?.editorHidden) continue
+ if (instancesByTrack.has(cid)) {
+ renderIds.push(cid)
+ }
+ // Script / audio tracks don't paint on the canvas — skip silently.
+ }
+
+ const drawChild = (target: CanvasRenderingContext2D, id: string) => {
+ const layer = layerById.get(id)
+ if (layer) {
+ drawLayers(target, [layer], assetMap, images, time, buttonStates)
+ return
+ }
+ const instances = instancesByTrack.get(id) ?? []
+ for (const inst of instances) {
+ const clip = clipById.get(inst.clipId)
+ if (!clip) continue
+ const trimStart = inst.trimStart ?? 0
+ const trimEnd = inst.trimEnd ?? 0
+ const visible = Math.max(0, clip.duration - trimStart - trimEnd)
+ const offsetIntoBlock = time - inst.startTime
+ 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) {
+ target.save()
+ target.translate(ix, iy)
+ if (irot !== 0) target.rotate((irot * Math.PI) / 180)
+ target.scale(isx, isy)
+ }
+ drawLayers(target, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
+ if (needsTransform) target.restore()
+ }
+ }
+
+ if (masks.length === 0) {
+ for (const id of renderIds) drawChild(ctx, id)
+ return
+ }
+
+ const off = document.createElement('canvas')
+ off.width = ctx.canvas.width
+ off.height = ctx.canvas.height
+ const offCtx = off.getContext('2d')
+ if (!offCtx) {
+ for (const id of renderIds) drawChild(ctx, id)
+ return
+ }
+ const m = ctx.getTransform()
+ offCtx.setTransform(m.a, m.b, m.c, m.d, m.e, m.f)
+ for (const id of renderIds) drawChild(offCtx, id)
+ for (const mask of masks) {
+ if (!isLayerActiveAt(mask, time)) continue
+ offCtx.save()
+ offCtx.globalCompositeOperation =
+ mask.maskMode === 'negative' ? 'destination-out' : 'destination-in'
+ drawMaskShape(offCtx, mask, assetMap, time)
+ offCtx.restore()
+ }
+ ctx.save()
+ ctx.setTransform(1, 0, 0, 1, 0, 0)
+ ctx.drawImage(off, 0, 0)
+ ctx.restore()
+}
+
function drawMaskedGroup(
ctx: CanvasRenderingContext2D,
children: Layer[],
@@ -491,12 +598,18 @@ export function drawSceneFrame(
// Konva stage's stackItems so mask-preview and thumbnails
// agree with the canvas.
if (group.editorHidden) continue
- const childLayers: Layer[] = []
- for (let k = group.childIds.length - 1; k >= 0; k--) {
- const cl = layerById.get(group.childIds[k])
- if (cl) childLayers.push(cl)
- }
- drawMaskedGroup(drawCtx, childLayers, assetMap, images, time, buttonStates)
+ drawTrackGroup(
+ drawCtx,
+ group.childIds,
+ time,
+ layerById,
+ clipTrackById,
+ instancesByTrack,
+ clipById,
+ assetMap,
+ images,
+ buttonStates,
+ )
}
}
diff --git a/src/version.ts b/src/version.ts
index 0d56b9c..4abde99 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,2 +1,2 @@
-export const APP_VERSION = '0.18.2'
+export const APP_VERSION = '0.18.3'
export const APP_NAME = 'RUWIO'
|