1.2.9: global seconds/frames toggle + sane number-input widths/rounding

The Timeline ruler's existing s/fr toggle (store.editor.timelineDisplayMode)
now drives every time display and time input across the editor. Mixing
"frames here, seconds there" was inconsistent — pick the unit once, see
it everywhere.

New composable (src/composables/useTimeFormat.ts):
- formatTime(seconds, opts) → "1.42s" or "42 fr" (short variant drops
  trailing zeros for ruler-style ticks).
- timeToInputValue / inputValueToSeconds — paired conversions used at
  every <input type="number"> bound to a time. Storage stays in seconds
  (renderer + .ruw files unchanged); conversion happens at the UI edge.
- inputStep ('0.01' / '1') and unitLabel ('seconds' / 'frames') for
  consistent input attributes / labels.

Surfaces switched over:
- App.vue topbar playhead + duration.
- PropertiesPanel keyframe-here label (`@ 1.50s` → `@ 45 fr`).
- FullscreenPreview time label.
- AssetSidebar clip + audio asset durations.
- Timeline audio clip + clip trim tooltips (route through formatKfTime,
  which already honoured the toggle).
- DocumentDrawer scene duration input — step + min flip with the mode,
  label gets a (seconds)/(frames) suffix.
- ScriptFrameDialog time input — same flip.
- ActionEditor jumpTo time input — same flip. (jumpToFrame stays as
  frames since "frame" is its semantic, not a unit choice.)
- Kaleido offset on ClipInstance — now follows the global toggle too,
  was hardcoded frames before. The frames-by-default UX comes from
  the user defaulting to 'frames' on the ruler; switching to seconds
  shows the same offset in seconds without losing precision.

Number-input audit (cleaning up "ewig viele Nachkommastellen"):
- ScriptFrameDialog time: rounded display to ≤3 decimals at the edge.
- ActionEditor jumpTo time: same.
- PropertiesPanel kaleido pivot X/Y (ClipInstance): rounded to int.
- RangeSlider strokeWidth format: snapped to 0.5 increments.

Number-input widths (capping the panel sprawl):
- PropertiesPanel inputs: max-width 96px + tabular-nums; .row inputs
  (side-by-side pivot X/Y, transform X/Y) keep flex-share.
- ActionEditor .params input[type='number']: explicit 96px.
- ScriptFrameDialog .row input[type='number']: explicit 96px.
- DocumentDrawer + ExportDialog: already had explicit widths.

Tests (6 new under "time format — global seconds/frames toggle"):
- useTimeFormat composable roundtrips through both modes at fps=30.
- PropertiesPanel keyframe label flips on mode change.
- DocumentDrawer duration input flips step + value (2s ↔ 60fr).
- ActionEditor jumpTo flips step + value (1s ↔ 24fr at fps=24).
- Kaleido offset display switches between modes for the same stored
  value (0.5s ↔ 15fr).
- Kaleido offset write-back: typing "30" in frames mode at fps=30
  stores exactly 1.0s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-26 20:01:54 +02:00
1 parent 80e1510b53
commit 7d81a50e74
13 files changed
+373 -46

No files matched your search

+202
View File
@@ -15565,4 +15565,206 @@ export function registerAllTests(): void {
expect(layerActiveAt(a, 2)).toBe(true)
})
})
suite('time format — global seconds/frames toggle', () => {
test('formatTime + timeToInputValue + inputValueToSeconds switch with the mode', async () => {
const { useTimeFormat } = await import('@/composables/useTimeFormat')
const s = makeFreshStore()
s.scene.fps = 30
const tf = useTimeFormat()
// Seconds mode (default).
s.editor.timelineDisplayMode = 'seconds'
expect(tf.formatTime(1.5)).toBe('1.50s')
expect(tf.formatTime(2, { short: true })).toBe('2s')
expect(tf.timeToInputValue(1.4181818)).toBeCloseTo(1.42, 5)
expect(tf.inputValueToSeconds(1.42)).toBeCloseTo(1.42, 5)
expect(tf.inputStep.value).toBe('0.01')
expect(tf.unitLabel.value).toBe('seconds')
// Frames mode.
s.editor.timelineDisplayMode = 'frames'
expect(tf.formatTime(0.5)).toBe('15 fr') // 0.5 * 30 fps
expect(tf.timeToInputValue(0.5)).toBe(15)
expect(tf.inputValueToSeconds(15)).toBeCloseTo(0.5, 5)
expect(tf.inputStep.value).toBe('1')
expect(tf.unitLabel.value).toBe('frames')
// Roundtrip across the boundary holds at frame-aligned times.
const sec = 0.4 // 12 frames @ 30fps
const back = tf.inputValueToSeconds(tf.timeToInputValue(sec))
expect(back).toBeCloseTo(0.4, 5)
})
test('PropertiesPanel keyframe-here label switches with the global mode', async () => {
const s = makeFreshStore()
s.scene.fps = 30
const a = s.addAsset({ kind: 'rect', name: 'R', width: 10, height: 10, fill: '#fff' })
const layer = s.addLayer(a.id)!
s.selectedLayerId = layer.id
s.selectedLayerIds = [layer.id]
s.setPlayhead(1.5)
s.editor.timelineDisplayMode = 'seconds'
const handle = mountComponent(PropertiesPanel, {})
try {
await nextTick()
const kfLabel = handle.host.querySelector('.kf-time') as HTMLElement | null
expect(!!kfLabel).toBe(true)
expect((kfLabel!.textContent ?? '').includes('1.50s')).toBe(true)
// Flip the mode → label re-renders in frames.
s.editor.timelineDisplayMode = 'frames'
await nextTick()
expect((kfLabel!.textContent ?? '').includes('45 fr')).toBe(true)
} finally {
handle.unmount()
}
})
test('DocumentDrawer duration input flips step + value when the mode changes', async () => {
const s = makeFreshStore()
s.scene.fps = 30
s.scene.duration = 2
// The drawer must be open for its template to mount.
const drawer = (await import('@/composables/useDocumentDrawer')).useDocumentDrawer()
drawer.open.value = true
s.editor.timelineDisplayMode = 'seconds'
const handle = mountComponent(DocumentDrawer, {})
try {
await nextTick()
// Find the duration input — the only number input that holds 2.
const inputs = Array.from(handle.host.querySelectorAll<HTMLInputElement>(
'input[type="number"]',
))
const find = (val: number) => inputs.find((el) => Number(el.value) === val)
let input = find(2)!
expect(!!input).toBe(true)
expect(input.step).toBe('0.01')
// Flip global mode → re-finds the input by its new frame value.
s.editor.timelineDisplayMode = 'frames'
await nextTick()
const inputs2 = Array.from(handle.host.querySelectorAll<HTMLInputElement>(
'input[type="number"]',
))
input = inputs2.find((el) => Number(el.value) === 60)! // 2s * 30 fps
expect(!!input).toBe(true)
expect(input.step).toBe('1')
} finally {
handle.unmount()
drawer.open.value = false
}
})
test('ActionEditor jumpTo input flips step + value when the mode changes', async () => {
const s = makeFreshStore()
s.scene.fps = 24
s.editor.timelineDisplayMode = 'seconds'
const actions: Action[] = [{ type: 'jumpTo', time: 1 }]
const handle = mountComponent(ActionEditor, { actions })
try {
await nextTick()
const input = handle.host.querySelector(
'input[type="number"]',
) as HTMLInputElement | null
expect(!!input).toBe(true)
expect(Number(input!.value)).toBe(1)
expect(input!.step).toBe('0.01')
s.editor.timelineDisplayMode = 'frames'
await nextTick()
const input2 = handle.host.querySelector(
'input[type="number"]',
) as HTMLInputElement | null
expect(Number(input2!.value)).toBe(24) // 1s * 24 fps
expect(input2!.step).toBe('1')
} finally {
handle.unmount()
}
})
test('PropertiesPanel kaleido offset follows the global mode', async () => {
// Regression: the user's complaint that started this work — the
// kaleido offset was a hardcoded "frames" field while every
// other time display lived in seconds. Now it follows the
// ruler toggle like the rest of the surfaces.
const s = makeFreshStore()
s.scene.fps = 30
const clip = s.addClip('K')
const track = s.addClipTrack()
const inst = s.addClipInstance(clip.id, 0, track.id)!
s.setClipInstanceKaleido(inst.id, {
kaleido: true,
kaleidoCount: 4,
kaleidoOffset: 0.5, // 15 frames at 30fps
})
s.selectClipInstance(inst.id)
s.editor.timelineDisplayMode = 'frames'
const handle = mountComponent(PropertiesPanel, {})
try {
await nextTick()
// The Kaleido section starts collapsed — open it before
// hunting for the offset input.
const kHeader = Array.from(
handle.host.querySelectorAll<HTMLButtonElement>('.group-header'),
).find((b) => (b.textContent ?? '').toLowerCase().includes('kaleido'))
expect(!!kHeader).toBe(true)
// Open if collapsed.
if (!handle.host.querySelector('.group-body input[type="number"]')) {
kHeader!.click()
await nextTick()
}
// The offset input is the third number input in the section
// (after Repeat + Pivot inputs come AFTER it; offset is right
// after Repeat). Filter by step="1" + value matching frames.
const inputs = Array.from(
handle.host.querySelectorAll<HTMLInputElement>('.group-body input[type="number"]'),
)
const offsetInput = inputs.find((el) => Number(el.value) === 15)
expect(!!offsetInput).toBe(true)
expect(offsetInput!.step).toBe('1')
// Flip global mode → seconds. Same offset reads as 0.5.
s.editor.timelineDisplayMode = 'seconds'
await nextTick()
const inputs2 = Array.from(
handle.host.querySelectorAll<HTMLInputElement>('.group-body input[type="number"]'),
)
const offsetInput2 = inputs2.find((el) => Number(el.value) === 0.5)
expect(!!offsetInput2).toBe(true)
expect(offsetInput2!.step).toBe('0.01')
} finally {
handle.unmount()
}
})
test('PropertiesPanel kaleido offset writes back through the mode', async () => {
// Typing "30" into the input in frames mode at 30fps stores
// exactly 1.0s; switching to seconds and typing "0.5" stores 0.5.
const s = makeFreshStore()
s.scene.fps = 30
const clip = s.addClip('K')
const track = s.addClipTrack()
const inst = s.addClipInstance(clip.id, 0, track.id)!
s.setClipInstanceKaleido(inst.id, { kaleido: true, kaleidoCount: 4, kaleidoOffset: 0.5 })
s.selectClipInstance(inst.id)
s.editor.timelineDisplayMode = 'frames'
const handle = mountComponent(PropertiesPanel, {})
try {
await nextTick()
const kHeader = Array.from(
handle.host.querySelectorAll<HTMLButtonElement>('.group-header'),
).find((b) => (b.textContent ?? '').toLowerCase().includes('kaleido'))
if (!handle.host.querySelector('.group-body input[type="number"]')) {
kHeader!.click()
await nextTick()
}
const inputs = Array.from(
handle.host.querySelectorAll<HTMLInputElement>('.group-body input[type="number"]'),
)
const offsetInput = inputs.find((el) => Number(el.value) === 15)!
// Write 30 frames → 1s.
offsetInput.value = '30'
offsetInput.dispatchEvent(new Event('change'))
await nextTick()
const stored = s.scene.clipInstances!.find((c) => c.id === inst.id)!
expect(stored.kaleidoOffset).toBeCloseTo(1, 5)
} finally {
handle.unmount()
}
})
})
}