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:
1 parent
3bc329813c
commit
c17e2e8a9f
8 files changed
+261
-7
No files matched your search
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user