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

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
"version": "1.2.1",
"version": "1.2.2",
"type": "module",
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -631,7 +631,7 @@ onBeforeUnmount(() => {
:title="t('app.stop')"
@click="stop"
/>
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s</span>
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration.toFixed(2) }}s</span>
</div>
</div>
<div class="topbar-right">
+4 -1
View File
@@ -273,7 +273,10 @@ const bgTransparent = computed({
})
const duration = computed({
get: () => store.scene.duration,
// Round display to 2 decimals so a frame-aligned duration like
// 4.733333… shows as 4.73 in the input, matching the header
// format. Setter passes the user-typed value through unchanged.
get: () => Math.round(store.scene.duration * 100) / 100,
set: (v: number) => store.setDuration(v),
})
+91
View File
@@ -464,6 +464,53 @@ function onScrubPointerDown(e: PointerEvent) {
seekFromEvent(e, el)
}
// -- Duration resize handle ---------------------------------------
// Vertical grip pinned to the right edge of the scrub ruler. Dragging
// it stretches or shrinks the active timeline (clip duration when in
// clip-edit, scene duration otherwise) by mapping clientX back to a
// time in the lane and snapping to the scene frame grid.
interface DurationDrag {
pointerId: number
laneEl: HTMLElement
}
const durationDrag = ref<DurationDrag | null>(null)
function onDurationHandlePointerDown(e: PointerEvent) {
if (e.button !== 0) return
const handle = e.currentTarget as HTMLElement
const lane = handle.parentElement
if (!lane) return
e.preventDefault()
e.stopPropagation()
handle.setPointerCapture(e.pointerId)
durationDrag.value = { pointerId: e.pointerId, laneEl: lane }
}
function onDurationHandlePointerMove(e: PointerEvent) {
const d = durationDrag.value
if (!d || d.pointerId !== e.pointerId) return
const rect = d.laneEl.getBoundingClientRect()
if (rect.width <= 0) return
const pps = effectivePxPerSec.value
if (pps <= 0) return
const rawSeconds = (e.clientX - rect.left) / pps
// Snap to the frame grid so the resulting duration is a clean
// multiple of 1/fps (otherwise dragging produces float garbage
// like 4.732891s).
const f = fps.value
const snapped = Math.max(1 / f, Math.round(rawSeconds * f) / f)
store.setDuration(snapped)
}
function onDurationHandlePointerUp(e: PointerEvent) {
const d = durationDrag.value
if (!d || d.pointerId !== e.pointerId) return
const handle = e.currentTarget as HTMLElement
if (handle.hasPointerCapture(e.pointerId)) handle.releasePointerCapture(e.pointerId)
durationDrag.value = null
}
function onScrubPointerMove(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (!el.hasPointerCapture(e.pointerId)) return
@@ -3411,6 +3458,18 @@ onBeforeUnmount(() => {
<span class="timeline-marker-flag"></span>
<span class="timeline-marker-label">{{ marker.name }}</span>
</span>
<span
v-if="!store.activeButtonAssetId"
class="duration-handle"
v-tooltip="t('timeline.durationHandle')"
:style="{ left: pxFromTime(duration) + 'px' }"
@pointerdown="onDurationHandlePointerDown"
@pointermove="onDurationHandlePointerMove"
@pointerup="onDurationHandlePointerUp"
@pointercancel="onDurationHandlePointerUp"
>
<span class="duration-handle-grip"></span>
</span>
<span
class="scrub-handle"
:class="{ obscured: scrubHandleObscured }"
@@ -4907,6 +4966,38 @@ onBeforeUnmount(() => {
font-weight: 700;
}
/* Duration-resize grip pinned to the right edge of the scrub ruler.
Dragging changes the active timeline's duration (clip in clip-edit,
scene otherwise). Sits on top of the ruler so it's always reachable
even when the playhead is parked at the end. */
.duration-handle {
position: absolute;
top: 0;
bottom: 0;
transform: translateX(-50%);
width: 12px;
cursor: ew-resize;
z-index: 6;
display: flex;
align-items: center;
justify-content: center;
}
.duration-handle-grip {
width: 4px;
height: 70%;
border-radius: 2px;
background: var(--accent);
opacity: 0.55;
transition: opacity 120ms var(--ease), background 120ms var(--ease);
pointer-events: none;
}
.duration-handle:hover .duration-handle-grip,
.duration-handle:active .duration-handle-grip {
opacity: 1;
}
.scrub-handle {
position: absolute;
top: 0;
+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()
+1
View File
@@ -265,6 +265,7 @@ export const en = {
hitTrackDelete: 'Delete hit shape',
addKeyframeTooltip: 'Add keyframe for the selected track at the playhead',
setEndTooltip: 'Set / move end keyframe to the playhead — layer disappears here',
durationHandle: 'Drag to change the timeline duration',
endKfTooltip: 'End keyframe {time} — layer disappears at this time',
endKfMakeEnd: 'Convert to end keyframe',
endKfBackToNormal: 'Back to normal keyframe',
+14 -3
View File
@@ -208,6 +208,17 @@ function snapToButtonStateTime(t: number): number {
return best
}
/** Round a time to the nearest frame boundary at the scene's fps and
* clamp to [0, duration]. Used for script-frame timing so the stored
* time always matches an actual rendered frame — drag input ratios
* otherwise produce values like 1.4181818 that never align with the
* playhead during tick(). */
function quantizeToFrame(time: number, fps: number, duration: number): number {
const f = fps > 0 ? fps : 60
const snapped = Math.round(time * f) / f
return Math.max(0, Math.min(duration, snapped))
}
/** The button editor has two orthogonal sub-modes: state editing (4 content
* states as kf-time slots) vs hit-area editing (hit shapes only, with normal
* state as onion-skin). Switching modes keeps each mode's last selection. */
@@ -2134,7 +2145,7 @@ export const useSceneStore = defineStore('scene', {
): ScriptFrame | null {
const track = this.scene.scriptTracks?.find((t) => t.id === trackId)
if (!track) return null
const t = Math.max(0, Math.min(this.scene.duration, time))
const t = quantizeToFrame(time, this.scene.fps, this.scene.duration)
const frame: ScriptFrame = {
id: uid(),
trackId,
@@ -2156,7 +2167,7 @@ export const useSceneStore = defineStore('scene', {
setScriptFrameTime(id: string, time: number) {
const frame = this.scene.scriptFrames?.find((f) => f.id === id)
if (!frame) return
frame.time = Math.max(0, Math.min(this.scene.duration, time))
frame.time = quantizeToFrame(time, this.scene.fps, this.scene.duration)
this.scene.scriptFrames?.sort((a, b) => a.time - b.time)
},
duplicateScriptFrame(frameId: string): string | null {
@@ -2182,7 +2193,7 @@ export const useSceneStore = defineStore('scene', {
const copy: ScriptFrame = {
id: uid(),
trackId: source.trackId,
time: target,
time: quantizeToFrame(target, this.scene.fps, this.scene.duration),
actions: source.actions.map((a) => ({ ...a })),
}
if (source.label) copy.label = source.label
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.2.1'
export const APP_VERSION = '1.2.2'
export const APP_NAME = 'RUWIO'