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
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruwio",
|
||||
"private": true,
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+1
-1
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '1.2.1'
|
||||
export const APP_VERSION = '1.2.2'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user