0.6.0: background video, in/out range, top-level masks, track icons

- background video layer (document-level), syncs to playhead in editor and fullscreen
- playback in/out brackets on the scrub ruler; i/o set them, r rewinds to in-point
- togglePlay, tick, fullscreen loop, WebM + server FFmpeg export all honor [in, out]
- top-level mask layers cascade over everything below until a group boundary
- fullscreen preview slaves localTime to the bg video so it stops stuttering
- welcome "new project" now actually loads a blank scene (was a no-op)
- default canvas background hardened to white via normalizeScene
- new keyframe button (+) in the name-spacer, backspace/delete removes selected kfs
- drag-end auto-selects the created/shifted keyframe so the path is visible
- animation path updates live during drag and spans every selected keyframe
- track rows show an icon per asset kind (rect/circle/image/text/polygon/freeform)
- default mask mode is negative, buttons read − then +
- sticky track-name columns raised above playhead/brackets; orange bracket styling
- tests for playback range, blank scene, mask defaults, cascading top-level masks

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-20 20:19:34 +02:00
1 parent 69de48ea8d
commit d4c4830fe2
17 files changed
+1332 -153

No files matched your search

+160 -1
View File
@@ -2,17 +2,19 @@
import { computed, ref, watch } from 'vue'
import {
PhClock,
PhFilmReel,
PhFolder,
PhFrameCorners,
PhMoon,
PhPalette,
PhSun,
PhX,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
import { useClipartStore } from '@/stores/clipart'
import { useTheme } from '@/composables/useTheme'
import type { Theme } from '@/composables/useTheme'
import { Modal } from '@/components/ui'
import { Modal, confirmDialog, promptDialog } from '@/components/ui'
import { t } from '@/i18n'
const props = defineProps<{ modelValue: boolean }>()
@@ -43,6 +45,126 @@ const tabs: { id: TabId; label: string; icon: typeof PhFrameCorners }[] = [
const activeTab = ref<TabId>('document')
const bgVideoError = ref<string | null>(null)
const bgFileInput = ref<HTMLInputElement | null>(null)
interface ProbedVideo {
src: string
width: number
height: number
duration: number
name: string
}
function probeVideo(src: string, name: string): Promise<ProbedVideo> {
return new Promise((resolve, reject) => {
const v = document.createElement('video')
v.preload = 'metadata'
v.muted = true
v.src = src
v.onloadedmetadata = () => {
resolve({
src,
width: v.videoWidth || 1280,
height: v.videoHeight || 720,
duration: Number.isFinite(v.duration) ? v.duration : 0,
name,
})
}
v.onerror = () => reject(new Error('Could not read video metadata.'))
})
}
async function applyProbedVideo(probe: ProbedVideo) {
const stageMatches =
probe.width === store.scene.canvas.width && probe.height === store.scene.canvas.height
if (!stageMatches) {
const ok = await confirmDialog({
title: t('settings.bgVideoStageSizeTitle'),
message: t('settings.bgVideoStageSizeMessage', {
w: probe.width,
h: probe.height,
cw: store.scene.canvas.width,
ch: store.scene.canvas.height,
}),
confirmLabel: t('settings.bgVideoStageSizeConfirm'),
cancelLabel: t('settings.bgVideoStageSizeCancel'),
})
if (ok) store.setCanvasSize(probe.width, probe.height)
}
if (probe.duration > 0 && probe.duration > store.scene.duration) {
const ok = await confirmDialog({
title: t('settings.bgVideoDurationTitle'),
message: t('settings.bgVideoDurationMessage', {
d: probe.duration.toFixed(2),
cd: store.scene.duration,
}),
confirmLabel: t('settings.bgVideoDurationConfirm'),
cancelLabel: t('settings.bgVideoDurationCancel'),
})
if (ok) store.setDuration(probe.duration)
}
store.setBackgroundVideo({ src: probe.src, muted: true })
}
async function pickBgVideoFile() {
bgVideoError.value = null
bgFileInput.value?.click()
}
async function onBgFileSelected(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
try {
const reader = new FileReader()
const dataUrl = await new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
})
const probe = await probeVideo(dataUrl, file.name)
await applyProbedVideo(probe)
} catch (err) {
bgVideoError.value = err instanceof Error ? err.message : String(err)
}
}
async function pickBgVideoUrl() {
bgVideoError.value = null
const url = await promptDialog({
title: t('settings.bgVideoUrlTitle'),
message: t('settings.bgVideoUrlMessage'),
defaultValue: store.scene.backgroundVideo?.src ?? '',
confirmLabel: t('settings.bgVideoUrlConfirm'),
})
if (!url) return
try {
const probe = await probeVideo(url.trim(), url.trim())
await applyProbedVideo(probe)
} catch (err) {
bgVideoError.value = err instanceof Error ? err.message : String(err)
}
}
function clearBgVideo() {
store.setBackgroundVideo(null)
bgVideoError.value = null
}
function bgVideoDisplayName(): string {
const v = store.scene.backgroundVideo
if (!v) return ''
if (v.src.startsWith('data:')) return t('settings.bgVideoLocalFile')
try {
const u = new URL(v.src)
return u.pathname.split('/').filter(Boolean).pop() || u.hostname
} catch {
return v.src.length > 48 ? v.src.slice(0, 48) + '…' : v.src
}
}
const width = computed({
get: () => store.scene.canvas.width,
set: (v: number) => store.setCanvasSize(v, store.scene.canvas.height),
@@ -146,6 +268,42 @@ function applyTheme(t: Theme) {
<span class="hex">{{ background.toUpperCase() }}</span>
</div>
</label>
<div class="clipart-row">
<div class="clipart-label">{{ t('settings.bgVideoLabel') }}</div>
<div class="clipart-value">
<PhFilmReel :size="13" />
<span v-if="store.scene.backgroundVideo" class="clipart-folder-name">
{{ bgVideoDisplayName() }}
</span>
<span v-else class="clipart-folder-empty">{{ t('settings.bgVideoNone') }}</span>
<button
v-if="store.scene.backgroundVideo"
class="ghost-btn inline-clear"
:title="t('settings.bgVideoClear')"
@click="clearBgVideo"
>
<PhX :size="12" />
</button>
</div>
</div>
<div class="clipart-actions">
<button class="primary-btn" @click="pickBgVideoFile">
{{ t('settings.bgVideoPickFile') }}
</button>
<button class="ghost-btn" @click="pickBgVideoUrl">
{{ t('settings.bgVideoPickUrl') }}
</button>
</div>
<input
ref="bgFileInput"
type="file"
accept="video/*"
style="display: none"
@change="onBgFileSelected"
/>
<p v-if="bgVideoError" class="error-line">{{ bgVideoError }}</p>
<p class="hint">{{ t('settings.bgVideoHint') }}</p>
</section>
<section v-else-if="activeTab === 'timeline'">
@@ -165,6 +323,7 @@ function applyTheme(t: Theme) {
</p>
</section>
<section v-else-if="activeTab === 'clipart'">
<h3>{{ t('settings.tab.clipart') }}</h3>
<div class="clipart-row">