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

+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "animatr",
"private": true,
"version": "0.5.6",
"version": "0.6.0",
"type": "module",
"scripts": {
"dev": "vite",
+34 -3
View File
@@ -269,8 +269,10 @@
var lw=scene.canvas.width, lh=scene.canvas.height;
ctx.save();
ctx.beginPath(); ctx.rect(0,0,lw,lh); ctx.clip();
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh);
if(!scene.backgroundVideo){
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh);
}
var am={}; for(var i=0;i<scene.assets.length;i++) am[scene.assets[i].id]=scene.assets[i];
var layerById={};
for(var li=0;li<scene.layers.length;li++) layerById[scene.layers[li].id]=scene.layers[li];
@@ -285,10 +287,32 @@
var groupById2={}; var groups2=scene.groups||[];
for(var gi2=0;gi2<groups2.length;gi2++) groupById2[groups2[gi2].id]=groups2[gi2];
if(!rawOrder.length){ rawOrder=[]; for(var oj=0;oj<scene.layers.length;oj++) rawOrder.push(scene.layers[oj].id); }
var hasTopMask=false;
for(var hm=0;hm<rawOrder.length;hm++){ var hmL=layerById[rawOrder[hm]]; if(hmL && hmL.isMask){ hasTopMask=true; break; } }
var mainCtx=ctx, topOff=null;
if(hasTopMask){
topOff=document.createElement('canvas');
topOff.width=canvas.width; topOff.height=canvas.height;
var topOffCtx=topOff.getContext('2d');
if(topOffCtx){
var tmT=ctx.getTransform();
topOffCtx.setTransform(tmT.a,tmT.b,tmT.c,tmT.d,tmT.e,tmT.f);
ctx=topOffCtx;
} else { topOff=null; }
}
for(var oi=rawOrder.length-1;oi>=0;oi--){
var id=rawOrder[oi];
var lyr=layerById[id];
if(lyr){ drawLayerNode(lyr, am, time); continue; }
if(lyr){
if(lyr.isMask){
ctx.save();
ctx.globalCompositeOperation=(lyr.maskMode==='negative'?'destination-out':'destination-in');
drawMaskShape(ctx, lyr, am, time);
ctx.restore();
continue;
}
drawLayerNode(lyr, am, time); continue;
}
var tInsts=instByTrack[id]||[];
if(tInsts.length){
for(var ti=0;ti<tInsts.length;ti++){
@@ -341,6 +365,13 @@
}
}
}
if(topOff){
ctx=mainCtx;
ctx.save();
ctx.setTransform(1,0,0,1,0,0);
ctx.drawImage(topOff,0,0);
ctx.restore();
}
ctx.restore();
}
window.__animatrInit=function(s){
+12 -3
View File
@@ -103,10 +103,19 @@ function effectiveEnd(scene) {
return Math.min(max, scene.duration)
}
function playbackRange(scene) {
const autoEnd = effectiveEnd(scene)
const start = Math.max(0, Math.min(scene.duration, scene.inTime == null ? 0 : scene.inTime))
const rawEnd = scene.outTime == null ? autoEnd : scene.outTime
const end = Math.max(start, Math.min(scene.duration, rawEnd))
return { start, end }
}
async function runJob(job, scene, options) {
const fps = Number(options.fps || scene.fps || 60)
const sceneEnd = effectiveEnd(scene)
const duration = Number(options.duration || sceneEnd)
const { start: rangeIn, end: rangeOut } = playbackRange(scene)
const rangeLen = Math.max(0, rangeOut - rangeIn)
const duration = Number(options.duration || rangeLen)
const totalFrames = Math.max(1, Math.round(duration * fps))
const crf = String(options.crf ?? 18)
const preset = String(options.preset ?? 'fast')
@@ -162,7 +171,7 @@ async function runJob(job, scene, options) {
try { ffmpeg.kill('SIGKILL') } catch { /* noop */ }
throw new Error('aborted')
}
const t = Math.min(i / fps, duration)
const t = rangeIn + Math.min(i / fps, duration)
await page.evaluate((time) => window.__animatrSeek(time), t)
const buf = await page.screenshot({
type: 'png',
+30 -3
View File
@@ -46,7 +46,7 @@ import {
} from './lib/projectStore'
import { importImageFile } from './lib/fileLoader'
import { useClipartStore } from './stores/clipart'
import { useSceneStore } from './stores/scene'
import { createBlankScene, useSceneStore } from './stores/scene'
const settingsOpen = ref(false)
const exportOpen = ref(false)
@@ -137,7 +137,7 @@ const clipart = useClipartStore()
const history = useHistory()
function rewind() {
store.setPlayhead(0)
store.setPlayhead(store.playbackIn)
}
function stop() {
@@ -215,6 +215,15 @@ function openFromRecent(project: StoredProject) {
loadError.value = null
}
function startNewProject() {
const blank = createBlankScene()
store.loadScene(blank)
history.resetTo(blank)
currentProjectId.value = null
currentProjectName.value = null
loadError.value = null
}
function showSavedToast(msg: string) {
savedToast.value = msg
setTimeout(() => {
@@ -270,6 +279,24 @@ function onKeyDown(e: KeyboardEvent) {
return
}
}
if (!isEditable(e.target) && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
const key = e.key.toLowerCase()
if (key === 'i') {
e.preventDefault()
store.setPlaybackRange(Math.min(store.playhead, store.playbackOut), null)
return
}
if (key === 'o') {
e.preventDefault()
store.setPlaybackRange(null, Math.max(store.playhead, store.playbackIn))
return
}
if (key === 'r') {
e.preventDefault()
rewind()
return
}
}
if (e.code !== 'Space') return
if (isEditable(e.target)) return
e.preventDefault()
@@ -436,7 +463,7 @@ onBeforeUnmount(() => {
<WelcomeDialog
v-model="welcomeOpen"
@open="openFromRecent"
@new="() => { currentProjectId = null; currentProjectName = null }"
@new="startNewProject"
@import="importFromFile"
/>
<Toast :visible="!!loadError" variant="error">
+260 -72
View File
@@ -7,7 +7,7 @@ import { sampleLayer } from '@/lib/interpolate'
import { importImageFile, loadImage } from '@/lib/fileLoader'
import { useClipartStore } from '@/stores/clipart'
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
import type { AnimatableProps, Asset, Layer, Point } from '@/types/scene'
import type { AnimatableProps, Asset, Keyframe, Layer, Point } from '@/types/scene'
import { t } from '@/i18n'
import { useKfSelection } from '@/composables/useKfSelection'
import { showLocked } from '@/composables/useShowLocked'
@@ -20,6 +20,7 @@ const { selectedKfs } = useKfSelection()
const container = ref<HTMLDivElement | null>(null)
const containerSize = ref({ width: 0, height: 0 })
const stageRef = ref<{ getStage(): Konva.Stage } | null>(null)
const bgVideoEl = ref<HTMLVideoElement | null>(null)
const transformerRef = ref<{ getNode(): Konva.Transformer } | null>(null)
const zoom = ref(1)
@@ -85,14 +86,88 @@ const clipGroupConfig = computed(() => {
}
})
const backgroundConfig = computed(() => ({
x: 0,
y: 0,
width: store.scene.canvas.width,
height: store.scene.canvas.height,
fill: store.scene.canvas.background,
listening: false,
}))
const backgroundConfig = computed(() => {
// Always set `fill` — toggling it in/out of the config across scene
// reloads can leave Konva's Rect with a stale attribute. Use a
// transparent fill when a background video is present so the DOM
// video shows through.
const fill = store.scene.backgroundVideo
? 'transparent'
: store.scene.canvas.background || '#ffffff'
return {
x: 0,
y: 0,
width: store.scene.canvas.width,
height: store.scene.canvas.height,
listening: false,
fill,
}
})
const bgVideoStyle = computed(() => {
const w = store.scene.canvas.width * zoom.value
const h = store.scene.canvas.height * zoom.value
return {
left: `${viewX.value}px`,
top: `${viewY.value}px`,
width: `${w}px`,
height: `${h}px`,
}
})
// Playhead sync for the background video — only resync current time on bigger
// deltas so regular playback stays smooth.
const BG_VIDEO_SYNC_THRESHOLD = 0.12
watch(
() => store.playing,
async (playing) => {
const v = bgVideoEl.value
if (!v) return
if (playing) {
v.currentTime = store.playhead
try {
await v.play()
} catch {
/* autoplay might be blocked if the tab has no user gesture yet */
}
} else {
v.pause()
v.currentTime = store.playhead
}
},
)
watch(
() => store.playhead,
(t) => {
const v = bgVideoEl.value
if (!v) return
const diff = Math.abs(v.currentTime - t)
if (!store.playing) {
v.currentTime = t
return
}
if (diff > BG_VIDEO_SYNC_THRESHOLD) v.currentTime = t
},
)
// When the video element mounts (or the src changes) do an initial sync.
watch(
() => store.scene.backgroundVideo?.src,
async () => {
await nextTick()
const v = bgVideoEl.value
if (!v) return
v.currentTime = store.playhead
if (store.playing) {
try {
await v.play()
} catch {
/* ignored */
}
}
},
)
const canvasBorderConfig = computed(() => ({
x: 0,
@@ -1288,11 +1363,15 @@ interface PathSegment {
flat: number[]
}
interface ActiveSegment {
keyframeId: string
frames: PathFrame[]
}
interface PathOverlay {
layerId: string
activeKeyframeId: string | null
ghostSegments: PathSegment[]
activeFrames: PathFrame[]
activeSegments: ActiveSegment[]
}
function sampleSegment(layer: Layer, from: number, to: number, fps: number): number[] {
@@ -1313,26 +1392,60 @@ const animationPath = computed<PathOverlay | null>(() => {
if (sel.length === 0) return null
const first = sel[0]
if (first.kind === 'rotation') return null
const layer = store.activeLayers.find((l) => l.id === first.layerId)
if (!layer) return null
const arr = first.kind === 'position' ? layer.positionKeyframes : layer.keyframes
const origLayer = store.activeLayers.find((l) => l.id === first.layerId)
if (!origLayer) return null
const arr = first.kind === 'position' ? origLayer.positionKeyframes : origLayer.keyframes
if (!arr) return null
const sorted = [...arr].sort((a, b) => a.time - b.time)
const idx = sorted.findIndex((k) => k.id === first.keyframeId)
if (idx < 0) return null
// While the user drags this layer's object, clone its keyframes so the
// path reflects the live position without mutating the store. If a kf
// at the playhead already exists, override its x/y; otherwise splice
// in a synthetic one so the path gets a live endpoint.
const live = liveLayerPose.value
let layer = origLayer
let workingArr = arr
if (live && live.layerId === origLayer.id) {
const cloned: Keyframe[] = arr.map((k) => ({ ...k, props: { ...k.props } }))
const existing = cloned.find((k) => Math.abs(k.time - store.playhead) < 1e-3)
if (existing) {
existing.props.x = live.x
existing.props.y = live.y
} else {
cloned.push({
id: '__live__',
time: store.playhead,
props: { x: live.x, y: live.y },
easing: 'linear',
})
}
cloned.sort((a, b) => a.time - b.time)
workingArr = cloned
layer =
first.kind === 'position'
? { ...origLayer, positionKeyframes: cloned }
: { ...origLayer, keyframes: cloned }
}
// Every selected keyframe on this layer+kind gets an interactive
// active segment (the incoming curve). Other segments stay ghost.
const selectedIds = new Set<string>()
for (const s of sel) {
if (s.layerId !== first.layerId) continue
if (s.kind !== first.kind) continue
selectedIds.add(s.keyframeId)
}
const sorted = [...workingArr].sort((a, b) => a.time - b.time)
const fps = store.scene.fps || 60
const ghostSegments: PathSegment[] = []
// All segments between consecutive keyframes become ghost segments,
// except the one leading to the selected keyframe which gets interactive dots.
let activeFrames: PathFrame[] = []
let activeKeyframeId: string | null = null
const activeSegments: ActiveSegment[] = []
for (let i = 0; i < sorted.length - 1; i++) {
const a = sorted[i]
const b = sorted[i + 1]
const dt = b.time - a.time
if (dt <= 0) continue
if (b.id === first.keyframeId) {
if (selectedIds.has(b.id)) {
const n = Math.max(2, Math.min(240, Math.round(dt * fps)))
const frames: PathFrame[] = []
for (let k = 0; k <= n; k++) {
@@ -1341,19 +1454,17 @@ const animationPath = computed<PathOverlay | null>(() => {
const s = sampleLayer(layer, time)
frames.push({ index: k, x: s.x, y: s.y, u })
}
activeFrames = frames
activeKeyframeId = b.id
activeSegments.push({ keyframeId: b.id, frames })
} else {
const flat = sampleSegment(layer, a.time, b.time, fps)
if (flat.length >= 4) ghostSegments.push({ flat })
}
}
if (!ghostSegments.length && activeFrames.length === 0) return null
if (!ghostSegments.length && activeSegments.length === 0) return null
return {
layerId: layer.id,
activeKeyframeId,
ghostSegments,
activeFrames,
activeSegments,
}
})
@@ -1376,38 +1487,65 @@ const animationGhostSegments = computed(() => {
}))
})
const animationPathLineConfig = computed(() => {
const animationPathLines = computed(() => {
const path = animationPath.value
if (!path || path.activeFrames.length < 2) return null
const flat: number[] = []
for (const f of path.activeFrames) flat.push(f.x, f.y)
return {
points: flat,
stroke: '#6ea8ff',
strokeWidth: 1.25 / zoom.value,
strokeScaleEnabled: false,
listening: false,
lineCap: 'round',
lineJoin: 'round',
if (!path) return []
return path.activeSegments
.filter((s) => s.frames.length >= 2)
.map((s) => {
const flat: number[] = []
for (const f of s.frames) flat.push(f.x, f.y)
return {
key: `apl-${s.keyframeId}`,
config: {
points: flat,
stroke: '#6ea8ff',
strokeWidth: 1.25 / zoom.value,
strokeScaleEnabled: false,
listening: false,
lineCap: 'round',
lineJoin: 'round',
},
}
})
})
interface PathDot {
key: string
keyframeId: string
frameIndex: number
frameTotal: number
x: number
y: number
isEnd: boolean
}
const animationPathDots = computed<PathDot[]>(() => {
const path = animationPath.value
if (!path) return []
const out: PathDot[] = []
for (const seg of path.activeSegments) {
const last = seg.frames.length - 1
for (const f of seg.frames) {
out.push({
key: `pf-${seg.keyframeId}-${f.index}`,
keyframeId: seg.keyframeId,
frameIndex: f.index,
frameTotal: last,
x: f.x,
y: f.y,
isEnd: f.index === 0 || f.index === last,
})
}
}
return out
})
const animationPathDots = computed(() => {
const path = animationPath.value
if (!path || !path.activeKeyframeId) return []
return path.activeFrames.map((f) => ({
key: `pf-${path.activeKeyframeId}-${f.index}`,
frameIndex: f.index,
x: f.x,
y: f.y,
isEnd: f.index === 0 || f.index === path.activeFrames.length - 1,
}))
})
const hoveredPathDot = ref<string | null>(null)
const hoveredPathDot = ref<number | null>(null)
function animationDotConfig(d: { x: number; y: number; isEnd: boolean; frameIndex: number }) {
const hovered = !d.isEnd && hoveredPathDot.value === d.frameIndex
function animationDotConfig(d: PathDot) {
const hoverKey = `${d.keyframeId}:${d.frameIndex}`
const hovered = !d.isEnd && hoveredPathDot.value === hoverKey
const baseRadius = d.isEnd ? 4 : 2.5
const radius = hovered ? 6 : baseRadius
return {
@@ -1424,9 +1562,9 @@ function animationDotConfig(d: { x: number; y: number; isEnd: boolean; frameInde
}
}
function onPathDotMouseEnter(frameIndex: number, isEnd: boolean) {
if (isEnd) return
hoveredPathDot.value = frameIndex
function onPathDotMouseEnter(d: PathDot) {
if (d.isEnd) return
hoveredPathDot.value = `${d.keyframeId}:${d.frameIndex}`
const stage = stageRef.value?.getStage()
const cont = stage?.container()
if (cont) cont.style.cursor = 'grab'
@@ -1439,26 +1577,24 @@ function onPathDotMouseLeave() {
if (cont) cont.style.cursor = ''
}
function onPathDotDragMove(e: Konva.KonvaEventObject<DragEvent>, frameIndex: number) {
function onPathDotDragMove(e: Konva.KonvaEventObject<DragEvent>, d: PathDot) {
const path = animationPath.value
if (!path || !path.activeKeyframeId) return
if (!path) return
const node = e.target
const wx = node.x()
const wy = node.y()
const total = path.activeFrames.length - 1
if (total <= 0) return
const uRaw = frameIndex / total
if (d.frameTotal <= 0) return
const uRaw = d.frameIndex / d.frameTotal
if (uRaw <= 0 || uRaw >= 1) return
const layer = store.activeLayers.find((l) => l.id === path.layerId)
if (!layer) return
const arr = selectedKfs.value[0]?.kind === 'position'
? layer.positionKeyframes
: layer.keyframes
const kf = arr?.find((k) => k.id === path.activeKeyframeId)
const sel = selectedKfs.value.find((s) => s.keyframeId === d.keyframeId)
const arr = sel?.kind === 'position' ? layer.positionKeyframes : layer.keyframes
const kf = arr?.find((k) => k.id === d.keyframeId)
if (!kf) return
const easing = kf.easing
const t = applyEasing(uRaw, easing)
store.setKeyframeControlFromPoint(path.layerId, path.activeKeyframeId, t, wx, wy)
store.setKeyframeControlFromPoint(path.layerId, d.keyframeId, t, wx, wy)
}
function applyEasing(t: number, easing: string): number {
@@ -1744,7 +1880,7 @@ function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
if (!layer) return
const newX = node.x()
const newY = node.y()
if (selectedKfs.value.length > 0) {
if (selectedKfs.value.length > 0 && anySelectedKfAtPlayhead()) {
const samp = sampleLayer(layer, store.playhead)
const dx = newX - samp.x
const dy = newY - samp.y
@@ -1753,10 +1889,40 @@ function handleDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
}
} else {
store.upsertKeyframe(id, store.playhead, { x: newX, y: newY })
selectKfAtPlayhead(layer, layer.separated ? 'position' : 'main')
}
liveLayerPose.value = null
}
function anySelectedKfAtPlayhead(): boolean {
for (const k of selectedKfs.value) {
const l = store.activeLayers.find((x) => x.id === k.layerId)
if (!l) continue
const arr =
k.kind === 'position'
? l.positionKeyframes
: k.kind === 'rotation'
? l.rotationKeyframes
: l.keyframes
const kf = arr?.find((kf) => kf.id === k.keyframeId)
if (kf && Math.abs(kf.time - store.playhead) < 1e-3) return true
}
return false
}
function selectKfAtPlayhead(layer: Layer, kind: 'main' | 'position' | 'rotation') {
const arr =
kind === 'position'
? layer.positionKeyframes
: kind === 'rotation'
? layer.rotationKeyframes
: layer.keyframes
if (!arr) return
const kf = arr.find((k) => Math.abs(k.time - store.playhead) < 1e-3)
if (!kf) return
selectedKfs.value = [{ layerId: layer.id, kind, keyframeId: kf.id }]
}
function handleTransformEnd(e: Konva.KonvaEventObject<Event>) {
const node = e.target
const id = node.id()
@@ -2086,6 +2252,16 @@ onBeforeUnmount(() => {
<template>
<div ref="container" class="stage-container" :class="{ drawing: store.drawingMode === 'freeform' }">
<video
v-if="store.scene.backgroundVideo"
ref="bgVideoEl"
class="bg-video"
:style="bgVideoStyle"
:src="store.scene.backgroundVideo.src"
muted
playsinline
preload="auto"
></video>
<v-stage
ref="stageRef"
:config="stageConfig"
@@ -2217,15 +2393,19 @@ onBeforeUnmount(() => {
:key="g.key"
:config="g.config"
/>
<v-line v-if="animationPathLineConfig" :config="animationPathLineConfig" />
<v-line
v-for="line in animationPathLines"
:key="line.key"
:config="line.config"
/>
<v-circle
v-for="d in animationPathDots"
:key="d.key"
:config="animationDotConfig(d)"
@mouseenter="onPathDotMouseEnter(d.frameIndex, d.isEnd)"
@mouseenter="onPathDotMouseEnter(d)"
@mouseleave="onPathDotMouseLeave"
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d.frameIndex)"
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d.frameIndex)"
@dragmove="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d)"
@dragend="(e: Konva.KonvaEventObject<DragEvent>) => onPathDotDragMove(e, d)"
/>
<v-line v-if="drawingPreviewConfig" :config="drawingPreviewConfig" />
<v-circle
@@ -2325,6 +2505,14 @@ onBeforeUnmount(() => {
overflow: hidden;
}
.bg-video {
position: absolute;
object-fit: cover;
pointer-events: none;
user-select: none;
z-index: 0;
}
.stage-container.drawing {
cursor: crosshair;
}
+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">
+96 -14
View File
@@ -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 {
+10 -10
View File
@@ -593,21 +593,21 @@ function removeKeyframeHere() {
<button
type="button"
class="icon-toggle"
:class="{ active: (selected.maskMode ?? 'positive') === 'positive' }"
:title="t('properties.maskPositive')"
@click="store.setLayerMaskMode(selected.id, 'positive')"
>
<PhPlus :size="13" weight="bold" />
</button>
<button
type="button"
class="icon-toggle"
:class="{ active: selected.maskMode === 'negative', danger: selected.maskMode === 'negative' }"
:class="{ active: (selected.maskMode ?? 'negative') === 'negative', danger: (selected.maskMode ?? 'negative') === 'negative' }"
:title="t('properties.maskNegative')"
@click="store.setLayerMaskMode(selected.id, 'negative')"
>
<PhMinus :size="13" weight="bold" />
</button>
<button
type="button"
class="icon-toggle"
:class="{ active: selected.maskMode === 'positive' }"
:title="t('properties.maskPositive')"
@click="store.setLayerMaskMode(selected.id, 'positive')"
>
<PhPlus :size="13" weight="bold" />
</button>
</div>
</div>
<p v-if="selected.isMask && maskInGroup" class="status-active">
+251 -5
View File
@@ -15,7 +15,14 @@ import {
PhEyeSlash,
PhMagnet,
PhMaskHappy,
PhCircle,
PhImage,
PhPauseCircle,
PhPencilSimpleLine,
PhPlus,
PhPolygon,
PhSquare,
PhTextT,
PhTrash,
} from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
@@ -27,6 +34,7 @@ import { kfSelectionKey, useKfSelection, type KfSelectionKey } from '@/composabl
import { showLocked } from '@/composables/useShowLocked'
import type {
AnimatableProps,
Asset,
Clip,
ClipInstance,
Easing,
@@ -311,6 +319,51 @@ function onScrubPointerUp(e: PointerEvent) {
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
}
function bracketTimeFromEvent(e: PointerEvent, el: HTMLElement): number {
const ruler = el.parentElement as HTMLElement | null
if (!ruler) return 0
const rect = ruler.getBoundingClientRect()
if (rect.width <= 0) return 0
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
return snapTime(ratio * duration.value)
}
function onInBracketPointerDown(e: PointerEvent) {
e.stopPropagation()
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}
function onInBracketPointerMove(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (!el.hasPointerCapture(e.pointerId)) return
e.stopPropagation()
const t = bracketTimeFromEvent(e, el)
store.setPlaybackRange(Math.min(t, store.playbackOut), null)
}
function onInBracketPointerUp(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
}
function onOutBracketPointerDown(e: PointerEvent) {
e.stopPropagation()
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}
function onOutBracketPointerMove(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (!el.hasPointerCapture(e.pointerId)) return
e.stopPropagation()
const t = bracketTimeFromEvent(e, el)
store.setPlaybackRange(null, Math.max(t, store.playbackIn))
}
function onOutBracketPointerUp(e: PointerEvent) {
const el = e.currentTarget as HTMLElement
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
}
function onLaneClick(e: MouseEvent) {
if (kfDrag.value) return
const el = e.currentTarget as HTMLElement
@@ -320,6 +373,34 @@ function onLaneClick(e: MouseEvent) {
store.setPlayhead(snapTime(ratio * duration.value))
}
const canCreateKf = computed(() => {
const id = store.selectedLayerId
if (!id) return false
const layer = store.activeLayers.find((l) => l.id === id)
return !!layer && !layer.locked
})
function addKeyframeForSelected() {
const id = store.selectedLayerId
if (!id) return
const layer = store.activeLayers.find((l) => l.id === id)
if (!layer || layer.locked) return
const time = snapTime(store.playhead)
const sampled = sampleLayer(layer, time)
const props: Partial<AnimatableProps> = {
scaleX: sampled.scaleX,
scaleY: sampled.scaleY,
opacity: sampled.opacity,
}
if (sampled.fill) props.fill = sampled.fill
if (!layer.separated) {
props.x = sampled.x
props.y = sampled.y
props.rotation = sampled.rotation
}
store.upsertKeyframe(id, time, props)
}
function onLaneDblClick(e: MouseEvent, track: TimelineLayerTrack) {
e.stopPropagation()
const el = e.currentTarget as HTMLElement
@@ -392,6 +473,23 @@ function emitLayerTrack(
}
}
const assetById = computed(() => {
const m = new Map<string, Asset>()
for (const a of store.scene.assets) m.set(a.id, a)
return m
})
function assetIconFor(layer: Layer) {
const asset = assetById.value.get(layer.assetId)
if (!asset) return null
if (asset.kind === 'rect') return PhSquare
if (asset.kind === 'circle') return PhCircle
if (asset.kind === 'image') return PhImage
if (asset.kind === 'text') return PhTextT
if (asset.kind === 'polygon') return asset.closed === false ? PhPencilSimpleLine : PhPolygon
return null
}
const lockedGroupChildIds = computed(() => {
const s = new Set<string>()
for (const g of store.scene.groups ?? []) {
@@ -1453,9 +1551,25 @@ function onKeyDown(e: KeyboardEvent) {
}
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false
const tag = target.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
if (target.isContentEditable) return true
return false
}
function onDocumentKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && selectedKfs.value.length) {
selectedKfs.value = []
return
}
if ((e.key === 'Backspace' || e.key === 'Delete') && selectedKfs.value.length) {
if (isEditableTarget(e.target)) return
e.preventDefault()
const targets = [...selectedKfs.value]
selectedKfs.value = []
for (const k of targets) store.removeKeyframe(k.layerId, k.keyframeId)
}
}
@@ -1591,6 +1705,14 @@ onBeforeUnmount(() => {
>
<PhFolderPlus :size="12" />
</button>
<button
class="snap-btn add-kf-btn"
:title="t('timeline.addKeyframeTooltip')"
:disabled="!canCreateKf"
@click="addKeyframeForSelected"
>
<PhPlus :size="12" :weight="'bold'" />
</button>
</div>
<div
class="scrub-ruler"
@@ -1610,6 +1732,37 @@ onBeforeUnmount(() => {
<span class="tick-line"></span>
<span class="tick-label">{{ formatTick(t) }}</span>
</span>
<span
v-if="store.playbackIn > 0"
class="range-dim dim-before"
:style="{ width: pxFromTime(store.playbackIn) + 'px' }"
></span>
<span
v-if="store.playbackOut < duration"
class="range-dim dim-after"
:style="{
left: pxFromTime(store.playbackOut) + 'px',
width: pxFromTime(duration - store.playbackOut) + 'px',
}"
></span>
<span
class="range-bracket bracket-in"
:title="t('timeline.inPoint')"
:style="{ left: pxFromTime(store.playbackIn) + 'px' }"
@pointerdown="onInBracketPointerDown"
@pointermove="onInBracketPointerMove"
@pointerup="onInBracketPointerUp"
@pointercancel="onInBracketPointerUp"
></span>
<span
class="range-bracket bracket-out"
:title="t('timeline.outPoint')"
:style="{ left: pxFromTime(store.playbackOut) + 'px' }"
@pointerdown="onOutBracketPointerDown"
@pointermove="onOutBracketPointerMove"
@pointerup="onOutBracketPointerUp"
@pointercancel="onOutBracketPointerUp"
></span>
<span class="scrub-handle" :style="{ left: pxFromTime(store.playhead) + 'px' }">
<span class="scrub-handle-head"></span>
<span class="scrub-handle-line"></span>
@@ -1693,6 +1846,12 @@ onBeforeUnmount(() => {
class="name-badge"
:class="{ 'mask-negative': track.layer.maskMode === 'negative' }"
/>
<component
v-else-if="track.isParent && assetIconFor(track.layer)"
:is="assetIconFor(track.layer)"
:size="11"
class="name-badge"
/>
<span v-if="!track.isParent" class="sub-indent">↳</span>
<span class="track-label">{{ track.label }}</span>
<button
@@ -2184,7 +2343,7 @@ onBeforeUnmount(() => {
.name-spacer {
position: sticky;
left: 0;
z-index: 2;
z-index: 30;
width: var(--track-name-width);
background: var(--panel);
border-right: 1px solid var(--border);
@@ -2221,6 +2380,17 @@ onBeforeUnmount(() => {
border-color: var(--accent-soft);
}
.snap-btn:disabled {
opacity: 0.4;
cursor: default;
background: var(--bg);
color: var(--text-dim);
}
.snap-btn.add-kf-btn {
margin-left: auto;
}
.mode-toggle {
display: inline-flex;
border: 1px solid var(--border);
@@ -2300,6 +2470,76 @@ onBeforeUnmount(() => {
font-variant-numeric: tabular-nums;
}
.range-dim {
position: absolute;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.35);
pointer-events: none;
z-index: 1;
}
.range-dim.dim-before {
left: 0;
}
.range-bracket {
position: absolute;
top: 0;
bottom: 0;
width: 10px;
cursor: ew-resize;
z-index: 19;
touch-action: none;
}
.range-bracket::before,
.range-bracket::after {
content: '';
position: absolute;
background: var(--range-bracket);
transition: background 120ms var(--ease);
}
.range-bracket::before {
top: 0;
bottom: 0;
width: 2px;
}
.range-bracket::after {
top: 0;
width: 8px;
height: 6px;
}
.range-bracket.bracket-in::before {
left: 0;
}
.range-bracket.bracket-in::after {
left: 0;
border-radius: 0 2px 2px 0;
}
.range-bracket.bracket-in {
transform: translateX(-0.5px);
}
.range-bracket.bracket-out::before {
right: 0;
}
.range-bracket.bracket-out::after {
right: 0;
border-radius: 2px 0 0 2px;
}
.range-bracket.bracket-out {
transform: translateX(calc(0.5px - 10px));
}
.range-bracket:hover::before,
.range-bracket:hover::after {
background: var(--range-hover);
}
.scrub-handle {
position: absolute;
top: 0;
@@ -2319,6 +2559,13 @@ onBeforeUnmount(() => {
margin-top: 2px;
clip-path: polygon(0 0, 100% 0, 100% 55%, 50% 100%, 0 55%);
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5));
pointer-events: auto;
cursor: ew-resize;
transition: background 120ms var(--ease);
}
.scrub-handle-head:hover {
background: var(--range-hover);
}
.scrub-handle-line {
@@ -2519,7 +2766,7 @@ onBeforeUnmount(() => {
.track-name {
position: sticky;
left: 0;
z-index: 5;
z-index: 30;
width: var(--track-name-width);
padding: 0 12px;
color: var(--text-dim);
@@ -2632,9 +2879,7 @@ onBeforeUnmount(() => {
background: var(--accent);
pointer-events: none;
transform: translateX(-0.5px);
/* Must render above the sticky track-name column (z-index 5) and the
resize strip (z-index 3) so the playhead stays visible at time=0 or
when scrolled. */
/* Above segments/keyframes but below sticky label columns (z-index 30). */
z-index: 20;
}
@@ -2913,6 +3158,7 @@ onBeforeUnmount(() => {
font-size: 12px;
color: var(--text);
gap: 4px;
padding-left: 7px;
cursor: pointer;
}
+234 -8
View File
@@ -1,5 +1,5 @@
import { createApp, h, nextTick, type Component, type Slot } from 'vue'
import { useSceneStore } from '@/stores/scene'
import { createBlankScene, useSceneStore } from '@/stores/scene'
import { sampleLayer, lerpColor } from '@/lib/interpolate'
import { snapToCandidates } from '@/lib/timelineSnap'
import {
@@ -549,10 +549,10 @@ export function registerAllTests(): void {
const a = s.addAsset({ kind: 'rect', name: 'Rew', width: 60, height: 60, fill: '#cc5555' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 2, { x: 600 })
s.setPlayhead(s.effectiveEnd)
s.setPlayhead(s.playbackOut)
await tick()
s.togglePlay()
expect(s.playhead).toBe(0)
expect(s.playhead).toBe(s.playbackIn)
expect(s.playing).toBe(true)
s.pause()
await tick()
@@ -570,17 +570,17 @@ export function registerAllTests(): void {
await tick()
})
test('tick stops at effectiveEnd', async () => {
test('tick stops at playbackOut', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'End', width: 60, height: 60, fill: '#dd7799' })
const l = s.addLayer(a.id)
s.upsertKeyframe(l.id, 2, { x: 400 })
s.play()
await tick()
s.tick(10)
s.tick(s.scene.duration + 1)
await tick()
expect(s.playing).toBe(false)
expect(s.playhead).toBe(s.effectiveEnd)
expect(s.playhead).toBe(s.playbackOut)
})
})
@@ -1577,14 +1577,14 @@ export function registerAllTests(): void {
})
suite('mask layers — store', () => {
test('setLayerMask(true) enables positive mask by default', () => {
test('setLayerMask(true) enables negative mask by default', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)
s.setLayerMask(l.id, true)
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(true)
expect(layer.maskMode).toBe('positive')
expect(layer.maskMode).toBe('negative')
})
test('setLayerMask(true, "negative") stores negative mode', () => {
@@ -2761,4 +2761,230 @@ export function registerAllTests(): void {
expect(maskOutside[3]).toBe(0)
})
})
suite('scene store — playback range (in/out)', () => {
test('defaults: playbackIn=0, playbackOut=duration', () => {
const s = makeFreshStore()
expect(s.playbackIn).toBe(0)
expect(s.playbackOut).toBe(s.scene.duration)
expect(s.scene.inTime).toBe(undefined)
expect(s.scene.outTime).toBe(undefined)
})
test('setPlaybackRange clamps values into [0, duration]', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(-5, 20)
expect(s.playbackIn).toBe(0)
expect(s.playbackOut).toBe(10)
})
test('setPlaybackRange keeps out >= in', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(6, null)
s.setPlaybackRange(null, 3)
// out is clamped up to in (6), not down to 3
expect(s.playbackOut).toBe(6)
})
test('setPlaybackRange clamps playhead into new range', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlayhead(1)
s.setPlaybackRange(4, 8)
expect(s.playhead).toBe(4)
s.setPlayhead(9)
// tick isn't involved; setPlayhead itself doesn't clamp to range, so
// push it with setPlaybackRange again:
s.setPlaybackRange(4, 7)
expect(s.playhead).toBe(7)
})
test('setPlaybackRange null for both leaves scene alone', () => {
const s = makeFreshStore()
s.setPlaybackRange(2, 5)
s.setPlaybackRange(null, null)
expect(s.playbackIn).toBe(2)
expect(s.playbackOut).toBe(5)
})
test('togglePlay snaps playhead to playbackIn when outside range', async () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(3, 7)
s.setPlayhead(1) // before in
await tick()
s.togglePlay()
expect(s.playhead).toBe(3)
expect(s.playing).toBe(true)
s.pause()
await tick()
})
test('tick stops exactly at playbackOut within range', async () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(2, 5)
s.setPlayhead(2)
s.play()
await tick()
s.tick(4) // would put playhead at 6, past out=5
await tick()
expect(s.playhead).toBe(5)
expect(s.playing).toBe(false)
})
test('setPlaybackRange(0, null) deletes inTime when outTime is unset', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(3, null)
expect(s.scene.inTime).toBe(3)
s.setPlaybackRange(0, null)
expect(s.scene.inTime).toBe(undefined)
})
test('setPlaybackRange(null, duration) deletes outTime when inTime is unset', () => {
const s = makeFreshStore()
s.setDuration(10)
s.setPlaybackRange(null, 7)
expect(s.scene.outTime).toBe(7)
s.setPlaybackRange(null, 10)
expect(s.scene.outTime).toBe(undefined)
})
})
suite('scene store — blank scene & normalization', () => {
test('createBlankScene has 1280x720 white bg and 10s duration', () => {
const s = createBlankScene()
expect(s.version).toBe(1)
expect(s.canvas.width).toBe(1280)
expect(s.canvas.height).toBe(720)
expect(s.canvas.background).toBe('#ffffff')
expect(s.duration).toBe(10)
expect(s.fps).toBe(60)
expect(s.assets).toHaveLength(0)
expect(s.layers).toHaveLength(0)
})
test('loadScene(createBlankScene()) resets selection, playhead, playing', async () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'X', width: 10, height: 10, fill: '#f00' })
s.addLayer(a.id)
s.setPlayhead(3)
s.play()
await tick()
s.loadScene(createBlankScene())
expect(s.scene.layers).toHaveLength(0)
expect(s.scene.assets).toHaveLength(0)
expect(s.scene.canvas.background).toBe('#ffffff')
expect(s.playhead).toBe(0)
expect(s.playing).toBe(false)
expect(s.selectedLayerId).toBe(null)
})
test('loadScene fills in canvas.background when missing', () => {
const s = makeFreshStore()
const broken = {
version: 1 as const,
canvas: { width: 640, height: 480, background: '' },
duration: 5,
fps: 30,
assets: [],
layers: [],
}
s.loadScene(broken)
expect(s.scene.canvas.background).toBe('#ffffff')
})
})
suite('scene store — mask defaults', () => {
test('setLayerMask(true) defaults to negative mask mode', () => {
const s = makeFreshStore()
const a = s.addAsset({ kind: 'rect', name: 'M', width: 40, height: 40, fill: '#fff' })
const l = s.addLayer(a.id)
s.setLayerMask(l.id, true)
const layer = s.scene.layers.find((x) => x.id === l.id)!
expect(layer.isMask).toBe(true)
expect(layer.maskMode).toBe('negative')
})
})
suite('render — top-level masks cascade', () => {
test('top-level positive mask clears content outside the mask shape', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
// Full-stage red rect
const content = s.addAsset({
kind: 'rect',
name: 'BG',
width: 100,
height: 100,
fill: '#ff0000',
})
const contentLayer = s.addLayer(content.id)
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
// Mask layer above it in the stack (top-level, NO group)
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 20,
fill: '#ffffff',
})
const maskLayer = s.addLayer(mask.id)
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(maskLayer.id, true, 'positive')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// Inside mask circle → red kept
const inside = ctx.getImageData(50, 50, 1, 1).data
expect(inside[0] > 200).toBe(true)
expect(inside[3] > 200).toBe(true)
// Outside mask circle → cleared
const outside = ctx.getImageData(5, 5, 1, 1).data
expect(outside[3]).toBe(0)
})
test('top-level negative mask cuts a hole in the content below', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = 'transparent'
const content = s.addAsset({
kind: 'rect',
name: 'BG',
width: 100,
height: 100,
fill: '#ff0000',
})
const contentLayer = s.addLayer(content.id)
s.upsertKeyframe(contentLayer.id, 0, { x: 50, y: 50 })
const mask = s.addAsset({
kind: 'circle',
name: 'M',
radius: 15,
fill: '#ffffff',
})
const maskLayer = s.addLayer(mask.id)
s.upsertKeyframe(maskLayer.id, 0, { x: 50, y: 50 })
s.setLayerMask(maskLayer.id, true, 'negative')
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
drawSceneFrame(ctx, s.scene, 0, {})
// Inside the circle → hole (cleared)
const inside = ctx.getImageData(50, 50, 1, 1).data
expect(inside[3]).toBe(0)
// Outside circle → red kept
const outside = ctx.getImageData(5, 5, 1, 1).data
expect(outside[0] > 200).toBe(true)
expect(outside[3] > 200).toBe(true)
})
})
}
+24
View File
@@ -160,6 +160,7 @@ export const en = {
dropNewTrackHint: 'Drop a clip here to add a new track',
addGroup: 'Add group',
addGroupTooltip: 'Create a new track group',
addKeyframeTooltip: 'Add keyframe for the selected track at the playhead',
groupChildCount: '{n} tracks',
groupEmpty: 'Drop tracks here',
renameGroupDialog: {
@@ -175,6 +176,8 @@ export const en = {
snapOff: 'Snapping off',
hideLocked: 'Hide locked tracks',
showLocked: 'Show locked tracks',
inPoint: 'In point — drag to set where playback and export start',
outPoint: 'Out point — drag to set where playback and export end',
stillFrameTooltip: 'Still frame {time} — holds until the next keyframe',
keyframeTooltip: 'Keyframe {time} — drag to move, right-click for options',
trimTooltip: '{name} · {duration}s{trims} · Drag edges to trim, double-click to open',
@@ -229,6 +232,27 @@ export const en = {
clipart: 'Clipart',
theme: 'UI Theme',
},
bgVideoLabel: 'Video',
bgVideoNone: 'No background video',
bgVideoPickFile: 'Pick video file…',
bgVideoPickUrl: 'Set URL…',
bgVideoClear: 'Remove background video',
bgVideoLocalFile: 'Local video file',
bgVideoHint:
'The video plays behind all layers and tracks the playhead. For best results use MP4 (H.264). The source is stored with the project (data URL for local files) or referenced by URL. In the export the video is not embedded — the exported HTML references the same URL.',
bgVideoStageSizeTitle: 'Apply video dimensions?',
bgVideoStageSizeMessage:
'The video is {w}×{h}. Your canvas is currently {cw}×{ch}. Resize the canvas to match the video?',
bgVideoStageSizeConfirm: 'Resize canvas',
bgVideoStageSizeCancel: 'Keep current size',
bgVideoDurationTitle: 'Extend scene duration?',
bgVideoDurationMessage:
'The video is {d}s. Scene duration is {cd}s. Extend the scene to match the video length?',
bgVideoDurationConfirm: 'Extend duration',
bgVideoDurationCancel: 'Keep current duration',
bgVideoUrlTitle: 'Background video URL',
bgVideoUrlMessage: 'Paste the video URL (must be reachable by the browser):',
bgVideoUrlConfirm: 'Load',
canvas: 'Canvas',
width: 'Width',
height: 'Height',
+80 -10
View File
@@ -1,6 +1,6 @@
import type { Scene } from '@/types/scene'
const RUNTIME_VERSION = '1'
const RUNTIME_VERSION = '2'
const ENGINE_SOURCE = `(function(){
if (window.AnimatrPlayer && window.AnimatrPlayer.__v === '${RUNTIME_VERSION}') return;
@@ -140,11 +140,26 @@ const ENGINE_SOURCE = `(function(){
if(!container)return;
opts=opts||{};
var loop=opts.loop!==false;
var sceneEnd=effectiveEnd(scene);
// Honour explicit in/out points; fall back to 0 and the animation's own end.
var rangeIn=Math.max(0, Math.min(scene.duration, scene.inTime==null?0:scene.inTime));
var autoEnd=effectiveEnd(scene);
var rangeOut=Math.max(rangeIn, Math.min(scene.duration, scene.outTime==null?autoEnd:scene.outTime));
var sceneEnd=rangeOut;
var cs=getComputedStyle(container);
if(cs.position==='static')container.style.position='relative';
var bgVideo=null;
if(scene.backgroundVideo && scene.backgroundVideo.src){
bgVideo=document.createElement('video');
bgVideo.src=scene.backgroundVideo.src;
bgVideo.muted=true;
bgVideo.playsInline=true;
bgVideo.loop=!!loop;
bgVideo.preload='auto';
bgVideo.style.cssText='position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none;z-index:0;';
container.appendChild(bgVideo);
}
var canvas=document.createElement('canvas');
canvas.style.cssText='display:block;width:100%;height:100%;';
canvas.style.cssText='display:block;width:100%;height:100%;position:relative;z-index:1;';
container.appendChild(canvas);
var ctx=canvas.getContext('2d');
var dpr=window.devicePixelRatio||1;
@@ -162,13 +177,28 @@ const ENGINE_SOURCE = `(function(){
scene.assets.forEach(function(a){assetMap[a.id]=a;});
var imgs={};
var start=0;
var bgStarted=false;
function syncBgVideo(t){
if(!bgVideo) return;
if(!bgStarted){
bgStarted=true;
try{ bgVideo.play(); }catch(e){}
}
if(Math.abs(bgVideo.currentTime-t)>0.2){
bgVideo.currentTime=t;
}
}
var rangeLen=Math.max(0, rangeOut-rangeIn);
function frame(now){
if(!start)start=now;
var t=(now-start)/1000;
if(sceneEnd<=0){ draw(0); requestAnimationFrame(frame); return; }
if(loop) t=t%sceneEnd;
else if(t>sceneEnd) t=sceneEnd;
var elapsed=(now-start)/1000;
var t=rangeIn;
if(rangeLen<=0){ draw(t); syncBgVideo(t); requestAnimationFrame(frame); return; }
if(loop) t=rangeIn+(elapsed%rangeLen);
else if(elapsed>=rangeLen) t=rangeOut;
else t=rangeIn+elapsed;
draw(t);
syncBgVideo(t);
requestAnimationFrame(frame);
}
function tracePolyPath(tctx,sp,ap,offX,offY,closed){
@@ -364,8 +394,10 @@ const ENGINE_SOURCE = `(function(){
ctx.beginPath();
ctx.rect(0,0,lw,lh);
ctx.clip();
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh);
if(!scene.backgroundVideo){
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh);
}
var layerById={};
for(var li=0;li<scene.layers.length;li++)layerById[scene.layers[li].id]=scene.layers[li];
var clipById2={};
@@ -381,10 +413,32 @@ const ENGINE_SOURCE = `(function(){
var groupById2={}; var groups2=scene.groups||[];
for(var gi2=0;gi2<groups2.length;gi2++) groupById2[groups2[gi2].id]=groups2[gi2];
if(!rawOrder.length){ rawOrder=[]; for(var lj=0;lj<scene.layers.length;lj++) rawOrder.push(scene.layers[lj].id); }
var hasTopMask=false;
for(var hm=0;hm<rawOrder.length;hm++){ var hmL=layerById[rawOrder[hm]]; if(hmL && hmL.isMask){ hasTopMask=true; break; } }
var mainCtx=ctx, topOff=null;
if(hasTopMask){
topOff=document.createElement('canvas');
topOff.width=canvas.width; topOff.height=canvas.height;
var topOffCtx=topOff.getContext('2d');
if(topOffCtx){
var tmT=ctx.getTransform();
topOffCtx.setTransform(tmT.a,tmT.b,tmT.c,tmT.d,tmT.e,tmT.f);
ctx=topOffCtx;
} else { topOff=null; }
}
for(var oi=rawOrder.length-1;oi>=0;oi--){
var id=rawOrder[oi];
var lyr=layerById[id];
if(lyr){ drawLayer(lyr,time); continue; }
if(lyr){
if(lyr.isMask){
ctx.save();
ctx.globalCompositeOperation=(lyr.maskMode==='negative'?'destination-out':'destination-in');
drawMaskShape(ctx,lyr,time);
ctx.restore();
continue;
}
drawLayer(lyr,time); continue;
}
var tInsts=instByTrack[id]||[];
if(tInsts.length){
for(var ti=0;ti<tInsts.length;ti++){
@@ -437,6 +491,13 @@ const ENGINE_SOURCE = `(function(){
}
}
}
if(topOff){
ctx=mainCtx;
ctx.save();
ctx.setTransform(1,0,0,1,0,0);
ctx.drawImage(topOff,0,0);
ctx.restore();
}
ctx.restore();
}
loadImages(scene.assets).then(function(loaded){
@@ -476,6 +537,15 @@ const SCENE_BOOT_SOURCE = `(function(){
`
function jsonForExport(scene: Scene): string {
// Data-URL background videos are stripped from the export — local video
// files can't be embedded reasonably. The user needs to host the media
// externally and set a URL in settings before exporting.
const bg = scene.backgroundVideo
if (bg && bg.src && bg.src.startsWith('data:')) {
const { backgroundVideo, ...rest } = scene
void backgroundVideo
return JSON.stringify(rest)
}
return JSON.stringify(scene)
}
+52 -5
View File
@@ -345,8 +345,12 @@ export function drawSceneFrame(
ctx.rect(0, 0, lw, lh)
ctx.clip()
}
ctx.fillStyle = scene.canvas.background
ctx.fillRect(0, 0, lw, lh)
// A background video takes over the backdrop — keep the stage area
// transparent so the DOM video underneath is visible.
if (!scene.backgroundVideo) {
ctx.fillStyle = scene.canvas.background
ctx.fillRect(0, 0, lw, lh)
}
const assetMap = new Map<string, Asset>()
for (const a of scene.assets) assetMap.set(a.id, a)
@@ -367,11 +371,47 @@ export function drawSceneFrame(
const rawOrder = scene.trackOrder?.length
? scene.trackOrder
: scene.layers.map((l) => l.id)
// Top-level mask layers (i.e. not inside a group) mask everything below
// them in the stack until the very bottom. Render everything into an
// offscreen buffer so we can apply each mask to whatever has been drawn
// so far; we only pay for this when at least one top-level mask exists.
const hasTopLevelMask = rawOrder.some((id) => {
const l = layerById.get(id)
return !!l && !!l.isMask
})
let drawCtx: CanvasRenderingContext2D = ctx
let off: HTMLCanvasElement | null = null
if (hasTopLevelMask) {
off = document.createElement('canvas')
off.width = ctx.canvas.width
off.height = ctx.canvas.height
const oc = off.getContext('2d')
if (oc) {
const m = ctx.getTransform()
oc.setTransform(m.a, m.b, m.c, m.d, m.e, m.f)
drawCtx = oc
} else {
off = null
}
}
for (let i = rawOrder.length - 1; i >= 0; i--) {
const id = rawOrder[i]
const layer = layerById.get(id)
if (layer) {
drawLayers(ctx, [layer], assetMap, images, time)
if (layer.isMask) {
// Apply this mask to everything already drawn into the offscreen
// buffer (the entire stack below it).
drawCtx.save()
drawCtx.globalCompositeOperation =
layer.maskMode === 'negative' ? 'destination-out' : 'destination-in'
drawMaskShape(drawCtx, layer, assetMap, time)
drawCtx.restore()
} else {
drawLayers(drawCtx, [layer], assetMap, images, time)
}
continue
}
const instances = instancesByTrack.get(id) ?? []
@@ -384,7 +424,7 @@ export function drawSceneFrame(
const visible = Math.max(0, clip.duration - trimStart - trimEnd)
const offsetIntoBlock = time - inst.startTime
if (offsetIntoBlock < 0 || offsetIntoBlock > visible) continue
drawLayers(ctx, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
drawLayers(drawCtx, clip.layers, assetMap, images, trimStart + offsetIntoBlock)
}
continue
}
@@ -395,9 +435,16 @@ export function drawSceneFrame(
const cl = layerById.get(group.childIds[k])
if (cl) childLayers.push(cl)
}
drawMaskedGroup(ctx, childLayers, assetMap, images, time)
drawMaskedGroup(drawCtx, childLayers, assetMap, images, time)
}
}
if (off) {
ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.drawImage(off, 0, 0)
ctx.restore()
}
ctx.restore()
}
+14 -5
View File
@@ -12,6 +12,14 @@ function effectiveEnd(scene: Scene): number {
return Math.min(max, scene.duration)
}
function playbackRange(scene: Scene): { start: number; end: number } {
const autoEnd = effectiveEnd(scene)
const start = Math.max(0, Math.min(scene.duration, scene.inTime ?? 0))
const rawEnd = scene.outTime == null ? autoEnd : scene.outTime
const end = Math.max(start, Math.min(scene.duration, rawEnd))
return { start, end }
}
export interface VideoExportResult {
blob: Blob
mimeType: string
@@ -56,8 +64,10 @@ export async function renderSceneToVideo(
if (!ctx) throw new Error('2D context unavailable')
const images = await loadSceneImages(scene)
const { start: rangeIn, end: rangeOut } = playbackRange(scene)
const rangeLen = Math.max(0, rangeOut - rangeIn)
drawSceneFrame(ctx, scene, 0, images)
drawSceneFrame(ctx, scene, rangeIn, images)
const stream = canvas.captureStream(scene.fps)
const recorder = new MediaRecorder(stream, {
@@ -76,7 +86,6 @@ export async function renderSceneToVideo(
recorder.start()
const startedAt = performance.now()
const renderDuration = effectiveEnd(scene)
const finished = new Promise<void>((resolve, reject) => {
let raf = 0
@@ -93,10 +102,10 @@ export async function renderSceneToVideo(
return
}
const elapsed = (performance.now() - startedAt) / 1000
const t = Math.min(elapsed, renderDuration)
const t = rangeIn + Math.min(elapsed, rangeLen)
drawSceneFrame(ctx, scene, t, images)
opts.onProgress?.(renderDuration > 0 ? Math.min(1, t / renderDuration) : 1)
if (elapsed >= renderDuration) {
opts.onProgress?.(rangeLen > 0 ? Math.min(1, elapsed / rangeLen) : 1)
if (elapsed >= rangeLen) {
try {
recorder.stop()
} catch {
+57 -13
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import type {
AnimatableProps,
Asset,
BackgroundVideo,
Clip,
ClipInstance,
ClipTrack,
@@ -111,6 +112,7 @@ function normalizeScene(scene: Scene): Scene {
scene.clipTracks ||= []
scene.clipInstances ||= []
scene.groups ||= []
if (!scene.canvas.background) scene.canvas.background = '#ffffff'
const known = new Set<string>()
for (const l of scene.layers) known.add(l.id)
@@ -154,16 +156,20 @@ function normalizeScene(scene: Scene): Scene {
}
export function createBlankScene(): Scene {
return normalizeScene({
version: 1,
canvas: { width: 1280, height: 720, background: '#ffffff' },
duration: 10,
fps: 60,
assets: [],
layers: [],
})
}
export const useSceneStore = defineStore('scene', {
state: (): State => ({
scene: normalizeScene({
version: 1,
canvas: { width: 1280, height: 720, background: '#ffffff' },
duration: 10,
fps: 60,
assets: [],
layers: [],
}),
scene: createBlankScene(),
selectedLayerId: null,
playhead: 0,
playing: false,
@@ -219,6 +225,18 @@ export const useSceneStore = defineStore('scene', {
if (max <= 0) return this.activeDuration
return Math.min(max, this.activeDuration)
},
/** Effective in-point for playback/export. Defaults to 0, clamped to duration. */
playbackIn(): number {
if (this.activeClipId) return 0
const v = this.scene.inTime ?? 0
return Math.max(0, Math.min(this.scene.duration, v))
},
/** Effective out-point for playback/export. Defaults to duration, clamped >=in. */
playbackOut(): number {
if (this.activeClipId) return this.activeDuration
const v = this.scene.outTime ?? this.scene.duration
return Math.max(this.playbackIn, Math.min(this.scene.duration, v))
},
},
actions: {
addAsset(asset: AssetInit): Asset {
@@ -325,6 +343,30 @@ export const useSceneStore = defineStore('scene', {
setCanvasBackground(color: string) {
this.scene.canvas.background = color
},
setBackgroundVideo(video: BackgroundVideo | null) {
if (video && video.src) this.scene.backgroundVideo = { ...video }
else delete this.scene.backgroundVideo
},
setPlaybackRange(inTime: number | null, outTime: number | null) {
const dur = this.scene.duration
const hasIn = inTime !== null && inTime !== undefined
const hasOut = outTime !== null && outTime !== undefined
if (hasIn) {
const v = Math.max(0, Math.min(dur, inTime!))
if (v === 0 && this.scene.outTime === undefined) delete this.scene.inTime
else this.scene.inTime = v
}
if (hasOut) {
const v = Math.max(this.scene.inTime ?? 0, Math.min(dur, outTime!))
if (v === dur && this.scene.inTime === undefined) delete this.scene.outTime
else this.scene.outTime = v
}
// Re-clamp playhead into the new range
const lo = this.scene.inTime ?? 0
const hi = this.scene.outTime ?? dur
if (this.playhead < lo) this.playhead = lo
else if (this.playhead > hi) this.playhead = hi
},
upsertKeyframe(layerId: string, time: number, props: Partial<AnimatableProps>) {
const layer = this.activeLayers.find((l) => l.id === layerId)
if (!layer) return
@@ -935,7 +977,7 @@ export const useSceneStore = defineStore('scene', {
if (isMask) {
layer.isMask = true
if (mode) layer.maskMode = mode
else if (!layer.maskMode) layer.maskMode = 'positive'
else if (!layer.maskMode) layer.maskMode = 'negative'
} else {
delete layer.isMask
delete layer.maskMode
@@ -1219,15 +1261,17 @@ export const useSceneStore = defineStore('scene', {
togglePlay() {
if (this.playing) this.pause()
else {
const end = this.effectiveEnd
if (end <= 0) return
if (this.playhead >= end) this.playhead = 0
const end = this.playbackOut
const start = this.playbackIn
if (end <= start) return
// If the playhead sits outside the in/out range, snap to the in-point.
if (this.playhead < start || this.playhead >= end) this.playhead = start
this.play()
}
},
tick(deltaSeconds: number) {
if (!this.playing) return
const end = this.effectiveEnd
const end = this.playbackOut
const next = this.playhead + deltaSeconds
if (next >= end) {
this.playhead = end
+4
View File
@@ -13,6 +13,8 @@
--accent-soft-strong: rgba(110, 168, 255, 0.22);
--danger: #ff7a7a;
--danger-soft: rgba(255, 122, 122, 0.12);
--range-bracket: #f59e0b;
--range-hover: #ffffff;
--radius: 6px;
--radius-sm: 4px;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
@@ -36,6 +38,8 @@
--accent-soft-strong: rgba(43, 106, 245, 0.18);
--danger: #d63c3c;
--danger-soft: rgba(214, 60, 60, 0.09);
--range-bracket: #d97706;
--range-hover: #111111;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06);
--shadow-md: 0 8px 24px rgba(20, 25, 40, 0.12);
color-scheme: light;
+13
View File
@@ -154,6 +154,14 @@ export interface ClipInstance {
trimEnd?: number
}
export interface BackgroundVideo {
src: string
offsetTime?: number
loop?: boolean
muted?: boolean
opacity?: number
}
export interface Scene {
version: 1
canvas: {
@@ -163,6 +171,10 @@ export interface Scene {
}
duration: number
fps: number
/** Optional in-point for playback & export. Defaults to 0. */
inTime?: number
/** Optional out-point for playback & export. Defaults to `duration`. */
outTime?: number
assets: Asset[]
layers: Layer[]
clips?: Clip[]
@@ -170,6 +182,7 @@ export interface Scene {
clipInstances?: ClipInstance[]
trackOrder?: string[]
groups?: TrackGroup[]
backgroundVideo?: BackgroundVideo
}
export const DEFAULT_BASE_PROPS: AnimatableProps = {