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",
|
"name": "ruwio",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.8",
|
"version": "1.2.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+3
-1
@@ -44,6 +44,7 @@ import { useHistory } from './composables/useHistory'
|
|||||||
import { useAudioRuntime } from './composables/useAudioRuntime'
|
import { useAudioRuntime } from './composables/useAudioRuntime'
|
||||||
import { useDocumentDrawer } from './composables/useDocumentDrawer'
|
import { useDocumentDrawer } from './composables/useDocumentDrawer'
|
||||||
import { usePlaybackLoop } from './composables/usePlaybackLoop'
|
import { usePlaybackLoop } from './composables/usePlaybackLoop'
|
||||||
|
import { useTimeFormat } from './composables/useTimeFormat'
|
||||||
import { useProjectName } from './composables/useProjectName'
|
import { useProjectName } from './composables/useProjectName'
|
||||||
import { shouldChromeClickDeselect } from './lib/chromeClick'
|
import { shouldChromeClickDeselect } from './lib/chromeClick'
|
||||||
import { renderSceneToStillImage } from './lib/stillImageExport'
|
import { renderSceneToStillImage } from './lib/stillImageExport'
|
||||||
@@ -165,6 +166,7 @@ const isDirty = ref(false)
|
|||||||
usePlaybackLoop()
|
usePlaybackLoop()
|
||||||
useAudioRuntime()
|
useAudioRuntime()
|
||||||
const store = useSceneStore()
|
const store = useSceneStore()
|
||||||
|
const { formatTime } = useTimeFormat()
|
||||||
const clipart = useClipartStore()
|
const clipart = useClipartStore()
|
||||||
const history = useHistory()
|
const history = useHistory()
|
||||||
const deselectAll = useDeselectAll()
|
const deselectAll = useDeselectAll()
|
||||||
@@ -631,7 +633,7 @@ onBeforeUnmount(() => {
|
|||||||
:title="t('app.stop')"
|
:title="t('app.stop')"
|
||||||
@click="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>
|
</div>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ import {
|
|||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import type { Action } from '@/types/scene'
|
import type { Action } from '@/types/scene'
|
||||||
|
import { useTimeFormat } from '@/composables/useTimeFormat'
|
||||||
|
|
||||||
|
const { timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
|
||||||
|
|
||||||
const props = defineProps<{ actions: Action[] }>()
|
const props = defineProps<{ actions: Action[] }>()
|
||||||
const emit = defineEmits<{ 'update:actions': [value: Action[]] }>()
|
const emit = defineEmits<{ 'update:actions': [value: Action[]] }>()
|
||||||
@@ -154,13 +157,13 @@ function onTypeChange(index: number, newType: Action['type']) {
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="a.type === 'jumpTo'" class="params">
|
<div v-if="a.type === 'jumpTo'" class="params">
|
||||||
<label>
|
<label>
|
||||||
{{ t('actions.param.time') }}
|
{{ t('actions.param.time') }} ({{ unitLabel }})
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
:step="inputStep"
|
||||||
min="0"
|
min="0"
|
||||||
:value="a.time"
|
:value="timeToInputValue(a.time)"
|
||||||
@input="setAt(i, { type: 'jumpTo', time: Number(($event.target as HTMLInputElement).value) })"
|
@input="setAt(i, { type: 'jumpTo', time: inputValueToSeconds(Number(($event.target as HTMLInputElement).value)) })"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -363,6 +366,11 @@ function onTypeChange(index: number, newType: Action['type']) {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.params input[type='number'] {
|
||||||
|
width: 96px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.params textarea {
|
.params textarea {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from '@phosphor-icons/vue'
|
} from '@phosphor-icons/vue'
|
||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
|
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
|
||||||
|
import { useTimeFormat } from '@/composables/useTimeFormat'
|
||||||
import { importAudioFile, importImageFile, isAudioFile } from '@/lib/fileLoader'
|
import { importAudioFile, importImageFile, isAudioFile } from '@/lib/fileLoader'
|
||||||
import {
|
import {
|
||||||
clearAudioDragSource,
|
clearAudioDragSource,
|
||||||
@@ -42,6 +43,7 @@ const fileInput = ref<HTMLInputElement | null>(null)
|
|||||||
const uploadError = ref<string | null>(null)
|
const uploadError = ref<string | null>(null)
|
||||||
|
|
||||||
const { enabled: showThumbs } = useAssetThumbsPref()
|
const { enabled: showThumbs } = useAssetThumbsPref()
|
||||||
|
const { formatTime } = useTimeFormat()
|
||||||
|
|
||||||
/** Per-section collapsed state, persisted so the user's layout
|
/** Per-section collapsed state, persisted so the user's layout
|
||||||
* survives reloads. Each key is false (expanded) by default —
|
* survives reloads. Each key is false (expanded) by default —
|
||||||
@@ -782,7 +784,7 @@ function onAssetDragStart(e: DragEvent, asset: ImageOrSvgAsset) {
|
|||||||
<div class="clip-meta">
|
<div class="clip-meta">
|
||||||
<span class="clip-name" v-tooltip="clip.name">{{ clip.name }}</span>
|
<span class="clip-name" v-tooltip="clip.name">{{ clip.name }}</span>
|
||||||
<span class="clip-sub">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="clip-actions">
|
<div class="clip-actions">
|
||||||
@@ -882,7 +884,7 @@ function onAssetDragStart(e: DragEvent, asset: ImageOrSvgAsset) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="clip-meta">
|
<div class="clip-meta">
|
||||||
<span class="clip-name" v-tooltip="asset.name">{{ asset.name }}</span>
|
<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>
|
||||||
<div class="clip-actions">
|
<div class="clip-actions">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useSceneStore } from '@/stores/scene'
|
|||||||
import { confirmDialog, promptDialog, dialogState } from '@/components/ui'
|
import { confirmDialog, promptDialog, dialogState } from '@/components/ui'
|
||||||
import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
|
import { useDocumentDrawer } from '@/composables/useDocumentDrawer'
|
||||||
import { useProjectName } from '@/composables/useProjectName'
|
import { useProjectName } from '@/composables/useProjectName'
|
||||||
|
import { useTimeFormat } from '@/composables/useTimeFormat'
|
||||||
import {
|
import {
|
||||||
parsePresetInput,
|
parsePresetInput,
|
||||||
presetLabel,
|
presetLabel,
|
||||||
@@ -17,6 +18,7 @@ const store = useSceneStore()
|
|||||||
const { open } = useDocumentDrawer()
|
const { open } = useDocumentDrawer()
|
||||||
const { projectName, renameProject } = useProjectName()
|
const { projectName, renameProject } = useProjectName()
|
||||||
const { presets, addPreset, removePreset } = useCanvasPresets()
|
const { presets, addPreset, removePreset } = useCanvasPresets()
|
||||||
|
const { mode: timeMode, timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
|
||||||
|
|
||||||
const rootEl = ref<HTMLElement | null>(null)
|
const rootEl = ref<HTMLElement | null>(null)
|
||||||
const nameInput = ref<HTMLInputElement | 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({
|
const duration = computed({
|
||||||
// Round display to 2 decimals so a frame-aligned duration like
|
get: () => timeToInputValue(store.scene.duration),
|
||||||
// 4.733333… shows as 4.73 in the input, matching the header
|
set: (v: number) => store.setDuration(inputValueToSeconds(v)),
|
||||||
// format. Setter passes the user-typed value through unchanged.
|
|
||||||
get: () => Math.round(store.scene.duration * 100) / 100,
|
|
||||||
set: (v: number) => store.setDuration(v),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const fps = computed({
|
const fps = computed({
|
||||||
@@ -364,8 +365,13 @@ async function onRemovePreset(p: CanvasPreset) {
|
|||||||
<input type="number" min="1" step="1" v-model.number="height" />
|
<input type="number" min="1" step="1" v-model.number="height" />
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>{{ t('settings.duration') }}</span>
|
<span>{{ t('settings.duration') }} ({{ unitLabel }})</span>
|
||||||
<input type="number" min="0.1" step="0.1" v-model.number="duration" />
|
<input
|
||||||
|
type="number"
|
||||||
|
:min="timeMode === 'frames' ? 1 : 0.1"
|
||||||
|
:step="inputStep"
|
||||||
|
v-model.number="duration"
|
||||||
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>{{ t('settings.fps') }}</span>
|
<span>{{ t('settings.fps') }}</span>
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { PhPause, PhPlay, PhSkipBack, PhX } from '@phosphor-icons/vue'
|
|||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
import { mountPlayer, type PlayerHandle } from '@/lib/exportPlayer'
|
import { mountPlayer, type PlayerHandle } from '@/lib/exportPlayer'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
import { useTimeFormat } from '@/composables/useTimeFormat'
|
||||||
|
|
||||||
|
const { formatTime } = useTimeFormat()
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: boolean }>()
|
const props = defineProps<{ modelValue: boolean }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
@@ -240,7 +243,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
const timeLabel = computed(() => {
|
const timeLabel = computed(() => {
|
||||||
const end = rangeOut.value || 0
|
const end = rangeOut.value || 0
|
||||||
return `${localTime.value.toFixed(2)}s / ${end.toFixed(2)}s`
|
return `${formatTime(localTime.value)} / ${formatTime(end)}`
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import RangeSlider from './RangeSlider.vue'
|
|||||||
import ActionEditor from './ActionEditor.vue'
|
import ActionEditor from './ActionEditor.vue'
|
||||||
import FontPicker from './FontPicker.vue'
|
import FontPicker from './FontPicker.vue'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
import { useTimeFormat } from '@/composables/useTimeFormat'
|
||||||
|
|
||||||
|
const { formatTime, timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
|
||||||
|
|
||||||
const SECTION_STORAGE_KEY = 'animatr:propSections'
|
const SECTION_STORAGE_KEY = 'animatr:propSections'
|
||||||
|
|
||||||
@@ -177,16 +180,18 @@ const selectedClipInstance = computed(() => {
|
|||||||
|
|
||||||
const ciKaleidoOn = computed(() => !!selectedClipInstance.value?.kaleido)
|
const ciKaleidoOn = computed(() => !!selectedClipInstance.value?.kaleido)
|
||||||
const ciKaleidoCount = computed(() => selectedClipInstance.value?.kaleidoCount ?? 6)
|
const ciKaleidoCount = computed(() => selectedClipInstance.value?.kaleidoCount ?? 6)
|
||||||
// Offset is stored as seconds (the renderer's unit) but presented to
|
// Offset is stored as seconds (the renderer's unit) but presented in
|
||||||
// the user as frames — that's how animators reason about staggered
|
// whichever unit the user picked globally (Timeline ruler toggle) —
|
||||||
// playback. Conversion uses the scene's fps.
|
// frames or seconds. Conversion at the UI boundary via useTimeFormat.
|
||||||
const ciKaleidoOffsetFrames = computed(() => {
|
const ciKaleidoOffsetInput = computed(() =>
|
||||||
const sec = selectedClipInstance.value?.kaleidoOffset ?? 0
|
timeToInputValue(selectedClipInstance.value?.kaleidoOffset ?? 0),
|
||||||
const fps = Math.max(1, store.scene.fps || 30)
|
)
|
||||||
return Math.round(sec * fps)
|
const ciPivotX = computed(() =>
|
||||||
})
|
Math.round(selectedClipInstance.value?.kaleidoPivotX ?? selectedClipInstance.value?.x ?? 0),
|
||||||
const ciPivotX = computed(() => selectedClipInstance.value?.kaleidoPivotX ?? selectedClipInstance.value?.x ?? 0)
|
)
|
||||||
const ciPivotY = computed(() => selectedClipInstance.value?.kaleidoPivotY ?? selectedClipInstance.value?.y ?? 0)
|
const ciPivotY = computed(() =>
|
||||||
|
Math.round(selectedClipInstance.value?.kaleidoPivotY ?? selectedClipInstance.value?.y ?? 0),
|
||||||
|
)
|
||||||
const ciRotateCopies = computed(() => selectedClipInstance.value?.kaleidoRotateCopies !== false)
|
const ciRotateCopies = computed(() => selectedClipInstance.value?.kaleidoRotateCopies !== false)
|
||||||
|
|
||||||
function setCiKaleido(patch: Parameters<typeof store.setClipInstanceKaleido>[1]) {
|
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)
|
store.setClipInstanceKaleido(inst.id, patch)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCiKaleidoOffsetFrames(frames: number) {
|
function setCiKaleidoOffset(value: number) {
|
||||||
if (!Number.isFinite(frames)) return
|
setCiKaleido({ kaleidoOffset: inputValueToSeconds(value) })
|
||||||
const fps = Math.max(1, store.scene.fps || 30)
|
|
||||||
setCiKaleido({ kaleidoOffset: Math.round(frames) / fps })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const kaleidoOn = computed(() => !!selected.value?.kaleido)
|
const kaleidoOn = computed(() => !!selected.value?.kaleido)
|
||||||
@@ -523,7 +526,7 @@ function deleteSelection() {
|
|||||||
<PhDiamond :size="11" :weight="hasKeyframeHere ? 'fill' : 'regular'" class="kf-dot" />
|
<PhDiamond :size="11" :weight="hasKeyframeHere ? 'fill' : 'regular'" class="kf-dot" />
|
||||||
<span class="kf-label">
|
<span class="kf-label">
|
||||||
{{ hasKeyframeHere ? t('properties.keyframeLabel') : t('properties.interpolated') }}
|
{{ 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>
|
</span>
|
||||||
<button v-if="hasKeyframeHere" class="kf-btn" v-tooltip="t('properties.removeKeyframe')" @click="removeKeyframeHere">
|
<button v-if="hasKeyframeHere" class="kf-btn" v-tooltip="t('properties.removeKeyframe')" @click="removeKeyframeHere">
|
||||||
<PhX :size="12" weight="bold" />
|
<PhX :size="12" weight="bold" />
|
||||||
@@ -646,7 +649,7 @@ function deleteSelection() {
|
|||||||
:min="0"
|
:min="0"
|
||||||
:max="32"
|
:max="32"
|
||||||
:step="0.5"
|
:step="0.5"
|
||||||
:format="(v: number) => `${v}px`"
|
:format="(v: number) => `${Math.round(v * 2) / 2}px`"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="slider-label">
|
<label class="slider-label">
|
||||||
@@ -859,12 +862,12 @@ function deleteSelection() {
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label v-if="ciKaleidoOn">
|
<label v-if="ciKaleidoOn">
|
||||||
{{ t('properties.kaleido.offset') }}
|
{{ t('properties.kaleido.offset') }} ({{ unitLabel }})
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="1"
|
:step="inputStep"
|
||||||
:value="ciKaleidoOffsetFrames"
|
:value="ciKaleidoOffsetInput"
|
||||||
@change="setCiKaleidoOffsetFrames(Number(($event.target as HTMLInputElement).value))"
|
@change="setCiKaleidoOffset(Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div v-if="ciKaleidoOn" class="row">
|
<div v-if="ciKaleidoOn" class="row">
|
||||||
@@ -946,6 +949,20 @@ input {
|
|||||||
letter-spacing: 0;
|
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 {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import { t } from '@/i18n'
|
|||||||
import { Modal } from '@/components/ui'
|
import { Modal } from '@/components/ui'
|
||||||
import ActionEditor from './ActionEditor.vue'
|
import ActionEditor from './ActionEditor.vue'
|
||||||
import type { Action } from '@/types/scene'
|
import type { Action } from '@/types/scene'
|
||||||
|
import { useTimeFormat } from '@/composables/useTimeFormat'
|
||||||
|
|
||||||
|
const { timeToInputValue, inputValueToSeconds, inputStep, unitLabel } = useTimeFormat()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
@@ -73,13 +76,13 @@ function removeFrame() {
|
|||||||
<div v-if="frame" class="body">
|
<div v-if="frame" class="body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label>
|
<label>
|
||||||
{{ t('scriptFrameDialog.time') }}
|
{{ t('scriptFrameDialog.time') }} ({{ unitLabel }})
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
:step="inputStep"
|
||||||
min="0"
|
min="0"
|
||||||
:value="localTime"
|
:value="timeToInputValue(localTime)"
|
||||||
@input="localTime = Number(($event.target as HTMLInputElement).value)"
|
@input="localTime = inputValueToSeconds(Number(($event.target as HTMLInputElement).value))"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label class="grow">
|
<label class="grow">
|
||||||
@@ -144,6 +147,11 @@ function removeFrame() {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.row input[type='number'] {
|
||||||
|
width: 96px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -2169,7 +2169,7 @@ function audioClipBlockStyle(clip: AudioClip) {
|
|||||||
function audioClipTooltip(clip: AudioClip): string {
|
function audioClipTooltip(clip: AudioClip): string {
|
||||||
const asset = audioAssetById.value.get(clip.audioAssetId)
|
const asset = audioAssetById.value.get(clip.audioAssetId)
|
||||||
if (!asset) return ''
|
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
|
/** 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 {
|
function trimTooltip(ci: ClipInstance): string {
|
||||||
const name = clipById.value.get(ci.clipId)?.name ?? '?'
|
const name = clipById.value.get(ci.clipId)?.name ?? '?'
|
||||||
const dur = visibleDurationOf(ci).toFixed(2)
|
const dur = formatKfTime(visibleDurationOf(ci))
|
||||||
const trims: string[] = []
|
const trims: string[] = []
|
||||||
if ((ci.trimStart ?? 0) > 0) trims.push(t('timeline.trimStart', { value: (ci.trimStart ?? 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: (ci.trimEnd ?? 0).toFixed(2) }))
|
if ((ci.trimEnd ?? 0) > 0) trims.push(t('timeline.trimEnd', { value: formatKfTime(ci.trimEnd ?? 0) }))
|
||||||
const trimsStr = trims.length ? ` · ${trims.join(' · ')}` : ''
|
const trimsStr = trims.length ? ` · ${trims.join(' · ')}` : ''
|
||||||
return t('timeline.trimTooltip', { name, duration: dur, trims: trimsStr })
|
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)
|
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: {
|
kaleido: {
|
||||||
toggle: 'Kaleido',
|
toggle: 'Kaleido',
|
||||||
repeat: 'Repeat',
|
repeat: 'Repeat',
|
||||||
offset: 'Start offset (frames)',
|
offset: 'Start offset',
|
||||||
rotateCopies: 'Rotate copies outward',
|
rotateCopies: 'Rotate copies outward',
|
||||||
pivotX: 'Pivot X',
|
pivotX: 'Pivot X',
|
||||||
pivotY: 'Pivot Y',
|
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'
|
export const APP_NAME = 'RUWIO'
|
||||||
Reference in new issue
Block a user