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'