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:
1 parent
69de48ea8d
commit
d4c4830fe2
17 files changed
+1332
-153
No files matched your search
@@ -14,6 +14,7 @@ const canvasEl = ref<HTMLCanvasElement | null>(null)
|
||||
const frameEl = ref<HTMLDivElement | null>(null)
|
||||
const rootEl = ref<HTMLDivElement | null>(null)
|
||||
const progressTrackEl = ref<HTMLDivElement | null>(null)
|
||||
const bgVideoEl = ref<HTMLVideoElement | null>(null)
|
||||
const viewportSize = ref({ w: 0, h: 0 })
|
||||
const images = ref<Record<string, HTMLImageElement>>({})
|
||||
const localTime = ref(0)
|
||||
@@ -28,7 +29,8 @@ const aspect = computed(
|
||||
() => `${store.scene.canvas.width} / ${store.scene.canvas.height}`,
|
||||
)
|
||||
|
||||
const sceneEnd = computed(() => store.effectiveEnd)
|
||||
const rangeIn = computed(() => store.playbackIn)
|
||||
const rangeOut = computed(() => store.playbackOut)
|
||||
|
||||
let raf = 0
|
||||
let lastFrame = 0
|
||||
@@ -70,12 +72,27 @@ function draw() {
|
||||
function loop(now: number) {
|
||||
if (!props.modelValue) return
|
||||
if (playing.value) {
|
||||
const end = sceneEnd.value
|
||||
const dt = lastFrame === 0 ? 0 : (now - lastFrame) / 1000
|
||||
if (end > 0) {
|
||||
let next = localTime.value + dt
|
||||
if (next >= end) next = 0
|
||||
localTime.value = next
|
||||
const start = rangeIn.value
|
||||
const end = rangeOut.value
|
||||
if (end > start) {
|
||||
const v = bgVideoEl.value
|
||||
if (v && !v.paused && !v.seeking && !scrubbing.value && v.readyState >= 2) {
|
||||
// Slave localTime to the video element so drift can't build up and
|
||||
// cause compensating seeks — those seeks are what makes the video
|
||||
// stutter visibly.
|
||||
let vt = v.currentTime
|
||||
if (vt >= end || vt < start) {
|
||||
vt = start
|
||||
v.currentTime = vt
|
||||
}
|
||||
localTime.value = vt
|
||||
} else {
|
||||
const dt = lastFrame === 0 ? 0 : (now - lastFrame) / 1000
|
||||
let next = localTime.value + dt
|
||||
if (next >= end) next = start
|
||||
else if (next < start) next = start
|
||||
localTime.value = next
|
||||
}
|
||||
}
|
||||
}
|
||||
lastFrame = now
|
||||
@@ -95,12 +112,27 @@ function stopLoop() {
|
||||
}
|
||||
|
||||
async function startPreview() {
|
||||
localTime.value = store.playhead
|
||||
const start = rangeIn.value
|
||||
const end = rangeOut.value
|
||||
const head = store.playhead
|
||||
localTime.value = head < start || head >= end ? start : head
|
||||
playing.value = true
|
||||
showControls.value = false
|
||||
images.value = await loadSceneImages(store.scene)
|
||||
resize()
|
||||
startLoop()
|
||||
// The playing watcher may have fired before the <video> ref was bound;
|
||||
// make sure the background video is actually started now that the DOM
|
||||
// is mounted.
|
||||
const v = bgVideoEl.value
|
||||
if (v) {
|
||||
v.currentTime = localTime.value
|
||||
try {
|
||||
await v.play()
|
||||
} catch {
|
||||
/* ignore autoplay errors */
|
||||
}
|
||||
}
|
||||
await requestBrowserFullscreen()
|
||||
}
|
||||
|
||||
@@ -152,9 +184,11 @@ function onRootMouseLeave() {
|
||||
}
|
||||
|
||||
const progressPct = computed(() => {
|
||||
const end = sceneEnd.value
|
||||
if (!end) return 0
|
||||
return Math.max(0, Math.min(1, localTime.value / end)) * 100
|
||||
const start = rangeIn.value
|
||||
const end = rangeOut.value
|
||||
const len = end - start
|
||||
if (len <= 0) return 0
|
||||
return Math.max(0, Math.min(1, (localTime.value - start) / len)) * 100
|
||||
})
|
||||
|
||||
function seekFromEvent(e: PointerEvent) {
|
||||
@@ -163,8 +197,9 @@ function seekFromEvent(e: PointerEvent) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.width <= 0) return
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const end = sceneEnd.value
|
||||
localTime.value = ratio * end
|
||||
const start = rangeIn.value
|
||||
const end = rangeOut.value
|
||||
localTime.value = start + ratio * (end - start)
|
||||
draw()
|
||||
}
|
||||
|
||||
@@ -227,6 +262,31 @@ watch(
|
||||
},
|
||||
)
|
||||
|
||||
watch(playing, async (p) => {
|
||||
const v = bgVideoEl.value
|
||||
if (!v) return
|
||||
if (p) {
|
||||
v.currentTime = localTime.value
|
||||
try {
|
||||
await v.play()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
} else {
|
||||
v.pause()
|
||||
v.currentTime = localTime.value
|
||||
}
|
||||
})
|
||||
// Keep the video frame aligned with localTime when we are NOT letting the
|
||||
// video drive time (paused or actively scrubbing). During normal playback
|
||||
// the RAF loop reads v.currentTime, so we must not seek here.
|
||||
watch(localTime, (t) => {
|
||||
const v = bgVideoEl.value
|
||||
if (!v) return
|
||||
if (playing.value && !scrubbing.value) return
|
||||
v.currentTime = t
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange)
|
||||
@@ -256,7 +316,7 @@ watch(frameEl, (el, _old, onCleanup) => {
|
||||
})
|
||||
|
||||
const timeLabel = computed(() => {
|
||||
const end = sceneEnd.value || 0
|
||||
const end = rangeOut.value || 0
|
||||
return `${localTime.value.toFixed(2)}s / ${end.toFixed(2)}s`
|
||||
})
|
||||
</script>
|
||||
@@ -274,6 +334,15 @@ const timeLabel = computed(() => {
|
||||
@dblclick.self="close"
|
||||
>
|
||||
<div ref="frameEl" class="fs-frame" :style="{ aspectRatio: aspect }">
|
||||
<video
|
||||
v-if="store.scene.backgroundVideo"
|
||||
ref="bgVideoEl"
|
||||
class="fs-bg-video"
|
||||
:src="store.scene.backgroundVideo.src"
|
||||
muted
|
||||
playsinline
|
||||
preload="auto"
|
||||
></video>
|
||||
<canvas ref="canvasEl" />
|
||||
</div>
|
||||
|
||||
@@ -324,6 +393,7 @@ const timeLabel = computed(() => {
|
||||
}
|
||||
|
||||
.fs-frame {
|
||||
position: relative;
|
||||
width: min(100vw, 100vh * (16 / 9));
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
@@ -332,10 +402,22 @@ const timeLabel = computed(() => {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fs-bg-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.fs-frame canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.fs-controls {
|
||||
|
||||
Reference in new issue
Block a user