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:
1 parent
80e1510b53
commit
7d81a50e74
13 files changed
+373
-46
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "ruwio",
|
||||
"private": true,
|
||||
"version": "1.2.8",
|
||||
"version": "1.2.9",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+3
-1
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -1,2 +1,2 @@
|
||||
export const APP_VERSION = '1.2.8'
|
||||
export const APP_VERSION = '1.2.9'
|
||||
export const APP_NAME = 'RUWIO'
|
||||
Reference in new issue
Block a user