1.2.2: timeline duration handle + frame-snapped script frames + cleaner time display

- Duration grip at the right edge of the scrub ruler. Drag to
  resize the active timeline (clip duration in clip-edit, scene
  duration otherwise); maps clientX → seconds and snaps to the
  scene's frame grid before calling setDuration. Hidden in button-
  asset edit (state grid is fixed).
- Script-frame times go through a quantizeToFrame helper at the
  store level (addScriptFrame / setScriptFrameTime / duplicateScriptFrame),
  so drag input no longer produces values like 1.4181818… and the
  playhead lands exactly on the frame during tick().
- Header time and DocumentDrawer duration input now show the
  duration rounded to 2 decimals — frame-aligned floats like
  4.733333… read as 4.73. Setter still writes the user-typed full-
  precision value through unchanged.
This commit is contained in:
th committed 2026-04-25 23:00:13 +02:00
1 parent 3bc329813c
commit c17e2e8a9f
8 files changed
+261 -7

No files matched your search

+148
View File
@@ -4684,6 +4684,15 @@ export function registerAllTests(): void {
expect(btn).toBeTruthy()
})
test('topbar time display shows playhead + duration both rounded to 2 decimals', () => {
// Both halves run through .toFixed(2) so a frame-aligned float
// like 4.733333… reads as 4.73 instead of leaking precision.
const time = document.querySelector('.time') as HTMLElement | null
expect(time).toBeTruthy()
// "<num>s / <num>s" with each <num> being a 2-decimal float.
expect(/^-?\d+\.\d{2}s \/ -?\d+\.\d{2}s$/.test((time!.textContent ?? '').trim())).toBe(true)
})
test('canvas has crop toggle button', () => {
const cropBtn = document.querySelector('.crop-btn')
expect(cropBtn).toBeTruthy()
@@ -7390,6 +7399,32 @@ export function registerAllTests(): void {
handle.unmount()
})
test('duration field rounds the displayed value to 2 decimals', async () => {
// Frame-aligned durations (e.g. 99/24 ≈ 4.125 or 43/30 ≈
// 1.43333…) must show as 4.13 / 1.43 in the input — the raw
// float would leak repeating digits into the field.
const s = makeFreshStore()
s.setDuration(43 / 30) // ≈ 1.4333333…
const handle = mountComponent(DocumentDrawer, {})
await nextTick()
const { useDocumentDrawer } = await import(
'@/composables/useDocumentDrawer'
)
useDocumentDrawer().openDrawer()
await nextTick()
const inputs = handle.host.querySelectorAll(
'.drawer-panel input[type="number"]',
) as NodeListOf<HTMLInputElement>
const duration = inputs[2]
// Input.value is a string; parsing back must equal the
// rounded value, not the full-precision raw float.
expect(Number(duration.value)).toBe(1.43)
// And the underlying store still holds the precise value
// (rounding is display-only).
expect(s.scene.duration).toBeCloseTo(43 / 30, 6)
handle.unmount()
})
test('project name input reflects the shared useProjectName ref', async () => {
// The drawer mirrors the topbar's name label — editing lives in
// the drawer and the topbar just displays. The input defaults to
@@ -8784,6 +8819,95 @@ export function registerAllTests(): void {
})
})
suite('ui — Timeline duration handle', () => {
test('renders at the right edge of the lane and drags to extend the timeline', async () => {
// The handle's `left` style should land on the current
// duration's pixel offset; dragging clientX further right
// through pointermove must call setDuration with a frame-
// snapped value larger than before.
const s = makeFreshStore()
s.setDuration(10)
s.project.fps = 30
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const grip = handle.host.querySelector('.duration-handle') as HTMLElement
expect(grip).toBeTruthy()
// pointerdown on the handle, then pointermove with a
// clientX past where 12s would land. We use the lane's
// bounding rect to compute target clientX precisely.
const lane = grip.parentElement as HTMLElement
const laneRect = lane.getBoundingClientRect()
const pps = laneRect.width / 10 // duration is 10s
const targetX = laneRect.left + 12 * pps
grip.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, cancelable: true, pointerId: 1, button: 0,
clientX: laneRect.left + 10 * pps, clientY: laneRect.top + 5,
}))
grip.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, cancelable: true, pointerId: 1,
clientX: targetX, clientY: laneRect.top + 5,
}))
grip.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true, cancelable: true, pointerId: 1, button: 0,
clientX: targetX, clientY: laneRect.top + 5,
}))
await nextTick()
// Frame-snapped: 12s × 30fps = 360 → exactly 12.
expect(s.scene.duration).toBeCloseTo(12, 5)
} finally {
handle.unmount()
}
})
test('shrinks the timeline to a frame-aligned duration when dragged inward', async () => {
const s = makeFreshStore()
s.setDuration(10)
s.project.fps = 24
const handle = mountComponent(Timeline, {})
try {
await nextTick()
const grip = handle.host.querySelector('.duration-handle') as HTMLElement
const lane = grip.parentElement as HTMLElement
const laneRect = lane.getBoundingClientRect()
const pps = laneRect.width / 10
// Drag inward to ~4.123456s. Should snap to nearest frame
// at 24fps: round(4.123456 × 24) = round(98.96) = 99 → 99/24
const targetX = laneRect.left + 4.123456 * pps
grip.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, cancelable: true, pointerId: 1, button: 0,
clientX: laneRect.left + 10 * pps, clientY: laneRect.top + 5,
}))
grip.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, cancelable: true, pointerId: 1,
clientX: targetX, clientY: laneRect.top + 5,
}))
grip.dispatchEvent(new PointerEvent('pointerup', {
bubbles: true, cancelable: true, pointerId: 1, button: 0,
clientX: targetX, clientY: laneRect.top + 5,
}))
await nextTick()
expect(s.scene.duration).toBeCloseTo(99 / 24, 4)
} finally {
handle.unmount()
}
})
test('handle is hidden inside button-asset edit (state-grid is fixed)', async () => {
const s = makeFreshStore()
const ba = s.addButtonAsset('Btn', 80, 30)
s.enterButtonAssetEdit(ba.id)
const handle = mountComponent(Timeline, {})
try {
await nextTick()
expect(handle.host.querySelector('.duration-handle')).toBeNull()
} finally {
handle.unmount()
s.exitButtonAssetEdit()
}
})
})
suite('ui — Timeline audio track', () => {
test('audio track renders as an .audio-track lane with one block per clip', async () => {
const s = makeFreshStore()
@@ -11770,6 +11894,30 @@ export function registerAllTests(): void {
expect(times).toEqual([1, 3, 5])
})
test('addScriptFrame quantizes time to the scene fps', () => {
// Drag input feeds in arbitrary float ratios — store rounds to
// the nearest frame so the playhead lands exactly on the frame
// during tick() and the saved value isn't 1.4181818…
const s = makeFreshStore()
s.setDuration(10)
s.project.fps = 30
const t = s.addScriptTrack()
const f = s.addScriptFrame(t.id, 1.4181818)!
// 1.4181818 × 30 = 42.545 → round 43 → 43/30 ≈ 1.4333…
expect(f.time).toBeCloseTo(43 / 30, 6)
})
test('setScriptFrameTime quantizes the new time to the scene fps', () => {
const s = makeFreshStore()
s.setDuration(10)
s.project.fps = 24
const t = s.addScriptTrack()
const f = s.addScriptFrame(t.id, 0)!
s.setScriptFrameTime(f.id, 2.0833)
// 2.0833 × 24 = 49.9992 → 50 → 50/24
expect(f.time).toBeCloseTo(50 / 24, 6)
})
test('removeScriptTrack cascades its frames and trackOrder entry', () => {
const s = makeFreshStore()
const t = s.addScriptTrack()