diff --git a/package.json b/package.json index 5db2d8c..d11c5d9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ruwio", "private": true, - "version": "1.2.1", + "version": "1.2.2", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.vue b/src/App.vue index 3c03b93..451598e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -631,7 +631,7 @@ onBeforeUnmount(() => { :title="t('app.stop')" @click="stop" /> - {{ store.playhead.toFixed(2) }}s / {{ store.scene.duration }}s + {{ store.playhead.toFixed(2) }}s / {{ store.scene.duration.toFixed(2) }}s
diff --git a/src/components/DocumentDrawer.vue b/src/components/DocumentDrawer.vue index add76e6..6ce6404 100644 --- a/src/components/DocumentDrawer.vue +++ b/src/components/DocumentDrawer.vue @@ -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), }) diff --git a/src/components/Timeline.vue b/src/components/Timeline.vue index 0f44451..16538fc 100644 --- a/src/components/Timeline.vue +++ b/src/components/Timeline.vue @@ -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(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(() => { {{ marker.name }} + + + { 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; diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 8a401ae..3a9ac4d 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -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() + // "s / s" with each 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 + 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() diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 4396a59..ccc4e69 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -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', diff --git a/src/stores/scene.ts b/src/stores/scene.ts index ce1f67f..62bb823 100644 --- a/src/stores/scene.ts +++ b/src/stores/scene.ts @@ -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 diff --git a/src/version.ts b/src/version.ts index 339b23c..7548b61 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '1.2.1' +export const APP_VERSION = '1.2.2' export const APP_NAME = 'RUWIO'