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

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "ruwio",
"private": true,
"version": "1.2.8",
"version": "1.2.9",
"type": "module",
"scripts": {
"dev": "vite",
+3 -1
View File
@@ -44,6 +44,7 @@ import { useHistory } from './composables/useHistory'
import { useAudioRuntime } from './composables/useAudioRuntime'
import { useDocumentDrawer } from './composables/useDocumentDrawer'
import { usePlaybackLoop } from './composables/usePlaybackLoop'
import { useTimeFormat } from './composables/useTimeFormat'
import { useProjectName } from './composables/useProjectName'
import { shouldChromeClickDeselect } from './lib/chromeClick'
import { renderSceneToStillImage } from './lib/stillImageExport'
@@ -165,6 +166,7 @@ const isDirty = ref(false)
usePlaybackLoop()
useAudioRuntime()
const store = useSceneStore()
const { formatTime } = useTimeFormat()
const clipart = useClipartStore()
const history = useHistory()
const deselectAll = useDeselectAll()
@@ -631,7 +633,7 @@ onBeforeUnmount(() => {
:title="t('app.stop')"
@click="stop"
/>
<span class="time">{{ store.playhead.toFixed(2) }}s / {{ store.scene.duration.toFixed(2) }}s</span>
<span class="time">{{ formatTime(store.playhead) }} / {{ formatTime(store.scene.duration) }}</span>
</div>
</div>
<div class="topbar-right">
+12 -4
View File
@@ -9,6 +9,9 @@ import {
import { useSceneStore } from '@/stores/scene'
import { t } from '@/i18n'
import type { Action } from '@/types/scene'
import { useTimeFormat } from '@/composables/useTimeFormat'
const { timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
const props = defineProps<{ actions: Action[] }>()
const emit = defineEmits<{ 'update:actions': [value: Action[]] }>()
@@ -154,13 +157,13 @@ function onTypeChange(index: number, newType: Action['type']) {
</div>
<div v-if="a.type === 'jumpTo'" class="params">
<label>
{{ t('actions.param.time') }}
{{ t('actions.param.time') }} ({{ unitLabel }})
<input
type="number"
step="0.01"
:step="inputStep"
min="0"
:value="a.time"
@input="setAt(i, { type: 'jumpTo', time: Number(($event.target as HTMLInputElement).value) })"
:value="timeToInputValue(a.time)"
@input="setAt(i, { type: 'jumpTo', time: inputValueToSeconds(Number(($event.target as HTMLInputElement).value)) })"
/>
</label>
</div>
@@ -363,6 +366,11 @@ function onTypeChange(index: number, newType: Action['type']) {
font-family: inherit;
}
.params input[type='number'] {
width: 96px;
font-variant-numeric: tabular-nums;
}
.params textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
+4 -2
View File
@@ -21,6 +21,7 @@ import {
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { useTimeFormat } from '@/composables/useTimeFormat'
import { importAudioFile, importImageFile, isAudioFile } from '@/lib/fileLoader'
import {
clearAudioDragSource,
@@ -42,6 +43,7 @@ const fileInput = ref<HTMLInputElement | null>(null)
const uploadError = ref<string | null>(null)
const { enabled: showThumbs } = useAssetThumbsPref()
const { formatTime } = useTimeFormat()
/** Per-section collapsed state, persisted so the user's layout
* survives reloads. Each key is false (expanded) by default —
@@ -782,7 +784,7 @@ function onAssetDragStart(e: DragEvent, asset: ImageOrSvgAsset) {
<div class="clip-meta">
<span class="clip-name" v-tooltip="clip.name">{{ clip.name }}</span>
<span class="clip-sub">
{{ t('sidebar.clipLayerCount', { n: clip.layers.length }) }} · {{ clip.duration.toFixed(1) }}s
{{ t('sidebar.clipLayerCount', { n: clip.layers.length }) }} · {{ formatTime(clip.duration, { short: true, decimals: 1 }) }}
</span>
</div>
<div class="clip-actions">
@@ -882,7 +884,7 @@ function onAssetDragStart(e: DragEvent, asset: ImageOrSvgAsset) {
</div>
<div class="clip-meta">
<span class="clip-name" v-tooltip="asset.name">{{ asset.name }}</span>
<span class="clip-sub">{{ asset.duration.toFixed(2) }}s</span>
<span class="clip-sub">{{ formatTime(asset.duration) }}</span>
</div>
<div class="clip-actions">
<button
+13 -7
View File
@@ -5,6 +5,7 @@ import { useSceneStore } from '@/stores/scene'
import { confirmDialog, promptDialog, dialogState } from '@/components/ui'
import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
import { useProjectName } from '@/composables/useProjectName'
import { useTimeFormat } from '@/composables/useTimeFormat'
import {
parsePresetInput,
presetLabel,
@@ -17,6 +18,7 @@ const store = useSceneStore()
const { open } = useDocumentDrawer()
const { projectName, renameProject } = useProjectName()
const { presets, addPreset, removePreset } = useCanvasPresets()
const { mode: timeMode, timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
const rootEl = ref<HTMLElement | null>(null)
const nameInput = ref<HTMLInputElement | null>(null)
@@ -272,12 +274,11 @@ const bgTransparent = computed({
},
})
// Duration input follows the global seconds/frames toggle. Storage
// stays in seconds; conversion happens here at the UI boundary.
const duration = computed({
// 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),
get: () => timeToInputValue(store.scene.duration),
set: (v: number) => store.setDuration(inputValueToSeconds(v)),
})
const fps = computed({
@@ -364,8 +365,13 @@ async function onRemovePreset(p: CanvasPreset) {
<input type="number" min="1" step="1" v-model.number="height" />
</label>
<label class="field">
<span>{{ t('settings.duration') }}</span>
<input type="number" min="0.1" step="0.1" v-model.number="duration" />
<span>{{ t('settings.duration') }} ({{ unitLabel }})</span>
<input
type="number"
:min="timeMode === 'frames' ? 1 : 0.1"
:step="inputStep"
v-model.number="duration"
/>
</label>
<label class="field">
<span>{{ t('settings.fps') }}</span>
+4 -1
View File
@@ -4,6 +4,9 @@ import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { mountPlayer, type PlayerHandle } from '@/lib/exportPlayer'
import { t } from '@/i18n'
import { useTimeFormat } from '@/composables/useTimeFormat'
const { formatTime } = useTimeFormat()
const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -240,7 +243,7 @@ onBeforeUnmount(() => {
const timeLabel = computed(() => {
const end = rangeOut.value || 0
return `${localTime.value.toFixed(2)}s / ${end.toFixed(2)}s`
return `${formatTime(localTime.value)} / ${formatTime(end)}`
})
</script>
+37 -20
View File
@@ -21,6 +21,9 @@ import RangeSlider from './RangeSlider.vue'
import ActionEditor from './ActionEditor.vue'
import FontPicker from './FontPicker.vue'
import { t } from '@/i18n'
import { useTimeFormat } from '@/composables/useTimeFormat'
const { formatTime, timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
const SECTION_STORAGE_KEY = 'animatr:propSections'
@@ -177,16 +180,18 @@ const selectedClipInstance = computed(() => {
const ciKaleidoOn = computed(() => !!selectedClipInstance.value?.kaleido)
const ciKaleidoCount = computed(() => selectedClipInstance.value?.kaleidoCount ?? 6)
// Offset is stored as seconds (the renderer's unit) but presented to
// the user as frames — that's how animators reason about staggered
// playback. Conversion uses the scene's fps.
const ciKaleidoOffsetFrames = computed(() => {
const sec = selectedClipInstance.value?.kaleidoOffset ?? 0
const fps = Math.max(1, store.scene.fps || 30)
return Math.round(sec * fps)
})
const ciPivotX = computed(() => selectedClipInstance.value?.kaleidoPivotX ?? selectedClipInstance.value?.x ?? 0)
const ciPivotY = computed(() => selectedClipInstance.value?.kaleidoPivotY ?? selectedClipInstance.value?.y ?? 0)
// Offset is stored as seconds (the renderer's unit) but presented in
// whichever unit the user picked globally (Timeline ruler toggle) —
// frames or seconds. Conversion at the UI boundary via useTimeFormat.
const ciKaleidoOffsetInput = computed(() =>
timeToInputValue(selectedClipInstance.value?.kaleidoOffset ?? 0),
)
const ciPivotX = computed(() =>
Math.round(selectedClipInstance.value?.kaleidoPivotX ?? selectedClipInstance.value?.x ?? 0),
)
const ciPivotY = computed(() =>
Math.round(selectedClipInstance.value?.kaleidoPivotY ?? selectedClipInstance.value?.y ?? 0),
)
const ciRotateCopies = computed(() => selectedClipInstance.value?.kaleidoRotateCopies !== false)
function setCiKaleido(patch: Parameters<typeof store.setClipInstanceKaleido>[1]) {
@@ -195,10 +200,8 @@ function setCiKaleido(patch: Parameters<typeof store.setClipInstanceKaleido>[1])
store.setClipInstanceKaleido(inst.id, patch)
}
function setCiKaleidoOffsetFrames(frames: number) {
if (!Number.isFinite(frames)) return
const fps = Math.max(1, store.scene.fps || 30)
setCiKaleido({ kaleidoOffset: Math.round(frames) / fps })
function setCiKaleidoOffset(value: number) {
setCiKaleido({ kaleidoOffset: inputValueToSeconds(value) })
}
const kaleidoOn = computed(() => !!selected.value?.kaleido)
@@ -523,7 +526,7 @@ function deleteSelection() {
<PhDiamond :size="11" :weight="hasKeyframeHere ? 'fill' : 'regular'" class="kf-dot" />
<span class="kf-label">
{{ hasKeyframeHere ? t('properties.keyframeLabel') : t('properties.interpolated') }}
<span class="kf-time">@ {{ store.playhead.toFixed(2) }}s</span>
<span class="kf-time">@ {{ formatTime(store.playhead) }}</span>
</span>
<button v-if="hasKeyframeHere" class="kf-btn" v-tooltip="t('properties.removeKeyframe')" @click="removeKeyframeHere">
<PhX :size="12" weight="bold" />
@@ -646,7 +649,7 @@ function deleteSelection() {
:min="0"
:max="32"
:step="0.5"
:format="(v: number) => `${v}px`"
:format="(v: number) => `${Math.round(v * 2) / 2}px`"
/>
</label>
<label class="slider-label">
@@ -859,12 +862,12 @@ function deleteSelection() {
/>
</label>
<label v-if="ciKaleidoOn">
{{ t('properties.kaleido.offset') }}
{{ t('properties.kaleido.offset') }} ({{ unitLabel }})
<input
type="number"
step="1"
:value="ciKaleidoOffsetFrames"
@change="setCiKaleidoOffsetFrames(Number(($event.target as HTMLInputElement).value))"
:step="inputStep"
:value="ciKaleidoOffsetInput"
@change="setCiKaleidoOffset(Number(($event.target as HTMLInputElement).value))"
/>
</label>
<div v-if="ciKaleidoOn" class="row">
@@ -946,6 +949,20 @@ input {
letter-spacing: 0;
}
/* Number inputs hold short numeric values. Capping the width keeps
them from stretching to the panel's full ~260px and aligns the
panel visually with the more compact slider controls. Inputs
inside a `.row` (two side-by-side labels) still split that row
evenly because their label parent is `flex: 1 1 0`. */
input[type='number'] {
max-width: 96px;
font-variant-numeric: tabular-nums;
}
.row input[type='number'] {
max-width: none;
}
.row {
display: flex;
gap: 8px;
+12 -4
View File
@@ -6,6 +6,9 @@ import { t } from '@/i18n'
import { Modal } from '@/components/ui'
import ActionEditor from './ActionEditor.vue'
import type { Action } from '@/types/scene'
import { useTimeFormat } from '@/composables/useTimeFormat'
const { timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
const props = defineProps<{
modelValue: boolean
@@ -73,13 +76,13 @@ function removeFrame() {
<div v-if="frame" class="body">
<div class="row">
<label>
{{ t('scriptFrameDialog.time') }}
{{ t('scriptFrameDialog.time') }} ({{ unitLabel }})
<input
type="number"
step="0.01"
:step="inputStep"
min="0"
:value="localTime"
@input="localTime = Number(($event.target as HTMLInputElement).value)"
:value="timeToInputValue(localTime)"
@input="localTime = inputValueToSeconds(Number(($event.target as HTMLInputElement).value))"
/>
</label>
<label class="grow">
@@ -144,6 +147,11 @@ function removeFrame() {
font-family: inherit;
}
.row input[type='number'] {
width: 96px;
font-variant-numeric: tabular-nums;
}
.btn {
display: inline-flex;
align-items: center;
+4 -4
View File
@@ -2169,7 +2169,7 @@ function audioClipBlockStyle(clip: AudioClip) {
function audioClipTooltip(clip: AudioClip): string {
const asset = audioAssetById.value.get(clip.audioAssetId)
if (!asset) return ''
return `${asset.name} · ${audibleDurationOf(clip).toFixed(2)}s`
return `${asset.name} · ${formatKfTime(audibleDurationOf(clip))}`
}
/** Resolved peaks for a clip's asset — undefined while the decoder
@@ -2664,10 +2664,10 @@ function onScriptLaneClick(e: MouseEvent, trackId: string) {
function trimTooltip(ci: ClipInstance): string {
const name = clipById.value.get(ci.clipId)?.name ?? '?'
const dur = visibleDurationOf(ci).toFixed(2)
const dur = formatKfTime(visibleDurationOf(ci))
const trims: string[] = []
if ((ci.trimStart ?? 0) > 0) trims.push(t('timeline.trimStart', { value: (ci.trimStart ?? 0).toFixed(2) }))
if ((ci.trimEnd ?? 0) > 0) trims.push(t('timeline.trimEnd', { value: (ci.trimEnd ?? 0).toFixed(2) }))
if ((ci.trimStart ?? 0) > 0) trims.push(t('timeline.trimStart', { value: formatKfTime(ci.trimStart ?? 0) }))
if ((ci.trimEnd ?? 0) > 0) trims.push(t('timeline.trimEnd', { value: formatKfTime(ci.trimEnd ?? 0) }))
const trimsStr = trims.length ? ` · ${trims.join(' · ')}` : ''
return t('timeline.trimTooltip', { name, duration: dur, trims: trimsStr })
}
+79
View File
@@ -0,0 +1,79 @@
// Single source of truth for "how do we format / parse a time?".
// The Timeline ruler already exposes a seconds/frames toggle backed
// by `store.editor.timelineDisplayMode`. This composable surfaces
// that mode globally so headers, dialogs, properties, tooltips and
// every other time display follows the user's choice — keeping them
// in sync with the ruler instead of forcing the user to think in two
// units at once.
//
// Storage stays in seconds everywhere (renderer + .ruw files).
// Conversion happens at the UI boundary using `scene.fps`.
import { computed } from 'vue'
import { useSceneStore } from '@/stores/scene'
export type TimeDisplayMode = 'seconds' | 'frames'
export function useTimeFormat() {
const store = useSceneStore()
const mode = computed<TimeDisplayMode>(() => store.editor.timelineDisplayMode)
const fps = computed(() => (store.scene.fps > 0 ? store.scene.fps : 60))
/** seconds → frames (rounded to int). */
const secondsToFrames = (s: number): number => Math.round(s * fps.value)
/** frames → seconds. */
const framesToSeconds = (f: number): number => f / fps.value
/** Format a time for display. In frames mode emits e.g. `"42 fr"`,
* in seconds mode `"1.42s"`. Pass `{ short: true }` to drop the
* trailing zeros for ruler-style ticks ("1s" instead of "1.00s"). */
function formatTime(
seconds: number,
opts: { short?: boolean; decimals?: number } = {},
): string {
if (mode.value === 'frames') return `${secondsToFrames(seconds)} fr`
const decimals = opts.decimals ?? 2
if (opts.short) {
const fixed = seconds.toFixed(decimals)
return `${fixed.replace(/0+$/, '').replace(/\.$/, '')}s`
}
return `${seconds.toFixed(decimals)}s`
}
/** Number suitable to plug into an `<input type="number" :value>`.
* In frames mode returns an integer (frames). In seconds mode
* returns a 2-decimal-rounded number so the input doesn't show
* e.g. `1.4181818181`. */
function timeToInputValue(seconds: number): number {
if (mode.value === 'frames') return secondsToFrames(seconds)
return Math.round(seconds * 100) / 100
}
/** Inverse of `timeToInputValue` — read back what the user typed
* into a time input and convert to seconds for storage. */
function inputValueToSeconds(value: number): number {
if (!Number.isFinite(value)) return 0
if (mode.value === 'frames') return framesToSeconds(Math.round(value))
return value
}
/** Sensible step + decimal placement for an `<input type="number">`
* bound to a time. Frames mode: step 1, integer. Seconds: 0.01. */
const inputStep = computed(() => (mode.value === 'frames' ? '1' : '0.01'))
/** Unit suffix for labels (`"frames"` or `"seconds"`). */
const unitLabel = computed(() => (mode.value === 'frames' ? 'frames' : 'seconds'))
return {
mode,
fps,
secondsToFrames,
framesToSeconds,
formatTime,
timeToInputValue,
inputValueToSeconds,
inputStep,
unitLabel,
}
}
+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()
}
})
})
}
+1 -1
View File
@@ -211,7 +211,7 @@ export const en = {
kaleido: {
toggle: 'Kaleido',
repeat: 'Repeat',
offset: 'Start offset (frames)',
offset: 'Start offset',
rotateCopies: 'Rotate copies outward',
pivotX: 'Pivot X',
pivotY: 'Pivot Y',
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.2.8'
export const APP_VERSION = '1.2.9'
export const APP_NAME = 'RUWIO'