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) <noreply@anthropic.com>
This commit is contained in:
1 parent
21cf6e9a12
commit
56cecabaae
6 files changed
+543
-32
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruwio",
|
||||
"private": true,
|
||||
"version": "0.18.2",
|
||||
"version": "0.18.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+44
-18
@@ -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<AnimatableProps> = {
|
||||
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<string>()
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
+45
-6
@@ -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;ti3<insts3.length;ti3++){
|
||||
var inst4=insts3[ti3];
|
||||
var clip4=clipById2[inst4.clipId];
|
||||
if(!clip4) continue;
|
||||
var tS4=inst4.trimStart||0;
|
||||
var tE4=inst4.trimEnd||0;
|
||||
var vis4=Math.max(0,clip4.duration-tS4-tE4);
|
||||
var off4=time-inst4.startTime;
|
||||
if(off4<0||off4>vis4) 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<gContent.length;gi3++) drawLayer(gContent[gi3],time);
|
||||
for(var gi3=0;gi3<gContentIds.length;gi3++) drawChildInGroup(gContentIds[gi3]);
|
||||
} else {
|
||||
var mcanvas=document.createElement('canvas');
|
||||
mcanvas.width=canvas.width; mcanvas.height=canvas.height;
|
||||
@@ -1032,7 +1071,7 @@ const ENGINE_SOURCE = `(function(){
|
||||
mctx.setTransform(tm.a,tm.b,tm.c,tm.d,tm.e,tm.f);
|
||||
var savedCtx=ctx;
|
||||
ctx=mctx;
|
||||
for(var gi4=0;gi4<gContent.length;gi4++) drawLayer(gContent[gi4],time);
|
||||
for(var gi4=0;gi4<gContentIds.length;gi4++) drawChildInGroup(gContentIds[gi4]);
|
||||
for(var gm=0;gm<gMasks.length;gm++){
|
||||
var mL=gMasks[gm];
|
||||
mctx.save();
|
||||
|
||||
+119
-6
@@ -311,6 +311,113 @@ function drawMaskShape(
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
/** Render the contents of a scene-level TrackGroup. Unlike
|
||||
* `drawMaskedGroup` (which only handles Layer[] — used by buttons
|
||||
* whose layers are self-contained), a scene group can hold layer
|
||||
* AND clip-track children. Dropping clip tracks from the group
|
||||
* branch used to make any group-with-only-clips invisible in
|
||||
* mask-preview. This walker handles both kinds and still applies
|
||||
* any group-local mask to the composited content.
|
||||
*
|
||||
* childIds are rendered bottom-to-top the same way the top-level
|
||||
* loop iterates rawOrder — reverse traversal, so childIds[0] ends
|
||||
* up on top of the stack. Masks (always Layer with `isMask`) apply
|
||||
* to every non-mask sibling rendered below them in the group. */
|
||||
function drawTrackGroup(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
childIds: string[],
|
||||
time: number,
|
||||
layerById: Map<string, Layer>,
|
||||
clipTrackById: Map<string, NonNullable<Scene['clipTracks']>[number]>,
|
||||
instancesByTrack: Map<string, NonNullable<Scene['clipInstances']>>,
|
||||
clipById: Map<string, NonNullable<Scene['clips']>[number]>,
|
||||
assetMap: Map<string, Asset>,
|
||||
images: Record<string, HTMLImageElement>,
|
||||
buttonStates?: Map<string, ButtonState>,
|
||||
): 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '0.18.2'
|
||||
export const APP_VERSION = '0.18.3'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user