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'