Update fullscreen preview and camera output

This commit is contained in:
th committed 2026-06-08 18:15:27 +02:00
1 parent 9b314ad817
commit 7093e1f6d2
9 files changed
+186 -15

No files matched your search

+15 -4
View File
@@ -12,6 +12,7 @@ import {
PhFloppyDisk, PhFloppyDisk,
PhGearSix, PhGearSix,
PhImageSquare, PhImageSquare,
PhMonitorPlay,
PhPause, PhPause,
PhPencilSimpleLine, PhPencilSimpleLine,
PhPlay, PhPlay,
@@ -195,6 +196,11 @@ function stop() {
store.pause() store.pause()
} }
function openFullscreenPreview() {
store.pause()
fullscreenOpen.value = true
}
async function saveProject() { async function saveProject() {
// projectForSave stitches the current editor-session UI state // projectForSave stitches the current editor-session UI state
// (zoom, pan, overlay toggles, timeline display) onto the // (zoom, pan, overlay toggles, timeline display) onto the
@@ -508,8 +514,7 @@ function onKeyDown(e: KeyboardEvent) {
if (isEditable(e.target)) return if (isEditable(e.target)) return
e.preventDefault() e.preventDefault()
if (e.shiftKey) { if (e.shiftKey) {
store.pause() openFullscreenPreview()
fullscreenOpen.value = true
return return
} }
store.togglePlay() store.togglePlay()
@@ -595,8 +600,7 @@ async function runDesktopMenuCommand(command: string) {
rewind() rewind()
break break
case 'fullscreen-preview': case 'fullscreen-preview':
store.pause() openFullscreenPreview()
fullscreenOpen.value = true
break break
case 'toggle-panels': case 'toggle-panels':
panelsHidden.value = !panelsHidden.value panelsHidden.value = !panelsHidden.value
@@ -706,6 +710,13 @@ onBeforeUnmount(() => {
@click="store.togglePlay()" @click="store.togglePlay()"
/> />
<div class="transport-side transport-right"> <div class="transport-side transport-right">
<IconButton
:icon="PhMonitorPlay"
icon-weight="regular"
size="md"
:title="t('app.fullscreenPlayback')"
@click="openFullscreenPreview"
/>
<IconButton <IconButton
:icon="PhStop" :icon="PhStop"
icon-weight="fill" icon-weight="fill"
+13
View File
@@ -14,6 +14,7 @@ import type { Theme } from '@/composables/useTheme'
import { useTooltipPref } from '@/composables/useTooltipPref' import { useTooltipPref } from '@/composables/useTooltipPref'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref' import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { useShowWelcomeOnStart } from '@/composables/useShowWelcomeOnStart' import { useShowWelcomeOnStart } from '@/composables/useShowWelcomeOnStart'
import { useFullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay'
import { showLocked } from '@/composables/useShowLocked' import { showLocked } from '@/composables/useShowLocked'
import { Modal, ToggleSwitch } from '@/components/ui' import { Modal, ToggleSwitch } from '@/components/ui'
import { setLocale, t, useI18n } from '@/i18n' import { setLocale, t, useI18n } from '@/i18n'
@@ -27,6 +28,7 @@ const { theme, setTheme } = useTheme()
const { enabled: tooltipsEnabled } = useTooltipPref() const { enabled: tooltipsEnabled } = useTooltipPref()
const { enabled: thumbsEnabled } = useAssetThumbsPref() const { enabled: thumbsEnabled } = useAssetThumbsPref()
const { enabled: welcomeOnStart } = useShowWelcomeOnStart() const { enabled: welcomeOnStart } = useShowWelcomeOnStart()
const { enabled: fullscreenAutoplay } = useFullscreenPreviewAutoplay()
const { locale } = useI18n() const { locale } = useI18n()
// Re-check the stored clipart folder whenever settings opens — covers the // Re-check the stored clipart folder whenever settings opens — covers the
@@ -288,6 +290,17 @@ function applyTheme(t: Theme) {
</div> </div>
</label> </label>
<label class="row-toggle">
<ToggleSwitch
v-model="fullscreenAutoplay"
:aria-label="t('settings.fullscreenAutoplayLabel')"
/>
<div class="row-toggle-text">
<div class="row-toggle-label">{{ t('settings.fullscreenAutoplayLabel') }}</div>
<div class="row-toggle-desc">{{ t('settings.fullscreenAutoplayDesc') }}</div>
</div>
</label>
<div class="setting-block"> <div class="setting-block">
<div class="setting-heading">{{ t('settings.appearanceTitle') }}</div> <div class="setting-heading">{{ t('settings.appearanceTitle') }}</div>
<div class="setting-desc">{{ t('settings.themeHint') }}</div> <div class="setting-desc">{{ t('settings.themeHint') }}</div>
+5 -3
View File
@@ -6,8 +6,10 @@ import { cameraAspect, isCameraEnabled } from '@/lib/camera'
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' import { useTimeFormat } from '@/composables/useTimeFormat'
import { useFullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay'
const { formatTime } = useTimeFormat() const { formatTime } = useTimeFormat()
const { enabled: autoplay } = useFullscreenPreviewAutoplay()
const props = defineProps<{ modelValue: boolean }>() const props = defineProps<{ modelValue: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>() const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
@@ -51,14 +53,14 @@ async function startPreview() {
// stage. Otherwise the two runtimes fight — editor audio pool + // stage. Otherwise the two runtimes fight — editor audio pool +
// preview audio pool would both emit the same mp3s at once. // preview audio pool would both emit the same mp3s at once.
store.pause() store.pause()
playing.value = true playing.value = autoplay.value
showControls.value = false showControls.value = false
const start = rangeIn.value const start = rangeIn.value
const end = rangeOut.value const end = rangeOut.value
const head = store.playhead const head = store.playhead
const initialTime = head < start || head >= end ? start : head const initialTime = head < start || head >= end ? start : head
localTime.value = initialTime localTime.value = initialTime
mountAt(el, initialTime, true) mountAt(el, initialTime, autoplay.value)
requestBrowserFullscreen() requestBrowserFullscreen()
} }
@@ -103,7 +105,7 @@ watch(
if (!props.modelValue) return if (!props.modelValue) return
const still = frameEl.value const still = frameEl.value
if (!still) return if (!still) return
mountAt(still, keepTime, true) mountAt(still, keepTime, playing.value)
}) })
}, },
) )
@@ -0,0 +1,27 @@
import { ref, watch } from 'vue'
const STORAGE_KEY = 'animatr.fullscreenPreviewAutoplay'
function read(): boolean {
try {
return localStorage.getItem(STORAGE_KEY) === 'true'
} catch {
return false
}
}
// Module-level shared ref so settings and fullscreen preview use the
// same preference without prop drilling through the app shell.
export const fullscreenPreviewAutoplay = ref<boolean>(read())
watch(fullscreenPreviewAutoplay, (v) => {
try {
localStorage.setItem(STORAGE_KEY, String(v))
} catch {
/* ignore */
}
})
export function useFullscreenPreviewAutoplay() {
return { enabled: fullscreenPreviewAutoplay }
}
+88 -3
View File
@@ -34,6 +34,7 @@ import { drawButtonFrame, drawSceneFrame, renderButtonThumbnail } from '@/lib/re
import { t as i18nT } from '@/i18n' import { t as i18nT } from '@/i18n'
import { expandedTrackOrder } from '@/lib/sceneOrder' import { expandedTrackOrder } from '@/lib/sceneOrder'
import { CLIP_FALLBACK_BOUNDS, computeClipBounds } from '@/lib/clipBounds' import { CLIP_FALLBACK_BOUNDS, computeClipBounds } from '@/lib/clipBounds'
import { renderSceneFrameForOutput } from '@/lib/camera'
import { import {
deleteStoredProject, deleteStoredProject,
getStoredProject, getStoredProject,
@@ -43,6 +44,7 @@ import {
import Modal from '@/components/ui/Modal.vue' import Modal from '@/components/ui/Modal.vue'
import IconButton from '@/components/ui/IconButton.vue' import IconButton from '@/components/ui/IconButton.vue'
import Toast from '@/components/ui/Toast.vue' import Toast from '@/components/ui/Toast.vue'
import App from '@/App.vue'
import PropertiesPanel from '@/components/PropertiesPanel.vue' import PropertiesPanel from '@/components/PropertiesPanel.vue'
import ClipartDrawer from '@/components/ClipartDrawer.vue' import ClipartDrawer from '@/components/ClipartDrawer.vue'
import SceneDrawer from '@/components/SceneDrawer.vue' import SceneDrawer from '@/components/SceneDrawer.vue'
@@ -55,6 +57,7 @@ import ExportDialog from '@/components/ExportDialog.vue'
import WelcomeDialog from '@/components/WelcomeDialog.vue' import WelcomeDialog from '@/components/WelcomeDialog.vue'
import FullscreenPreview from '@/components/FullscreenPreview.vue' import FullscreenPreview from '@/components/FullscreenPreview.vue'
import HelpDialog from '@/components/HelpDialog.vue' import HelpDialog from '@/components/HelpDialog.vue'
import { fullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay'
import { createAudioRuntime } from '@/composables/useAudioRuntime' import { createAudioRuntime } from '@/composables/useAudioRuntime'
import { import {
clearWaveformCache, clearWaveformCache,
@@ -1428,6 +1431,88 @@ export function registerAllTests(): void {
expect(frame?.querySelector('canvas')).toBeTruthy() expect(frame?.querySelector('canvas')).toBeTruthy()
handle.unmount() handle.unmount()
}) })
test('fullscreen preview autoplay preference controls the initial transport state', async () => {
makeFreshStore()
const before = fullscreenPreviewAutoplay.value
const primaryButton = () =>
document.body.querySelector('.fs-toolbar .fs-btn') as HTMLButtonElement | null
try {
fullscreenPreviewAutoplay.value = false
const pausedHandle = mountComponent(FullscreenPreview, { modelValue: true })
await nextTick()
await nextTick()
expect(primaryButton()?.getAttribute('aria-label')).toBe('Play')
pausedHandle.unmount()
await nextTick()
fullscreenPreviewAutoplay.value = true
const playingHandle = mountComponent(FullscreenPreview, { modelValue: true })
await nextTick()
await nextTick()
expect(primaryButton()?.getAttribute('aria-label')).toBe('Pause')
playingHandle.unmount()
} finally {
fullscreenPreviewAutoplay.value = before
}
})
test('top transport fullscreen button opens the preview and pauses editor playback', async () => {
const s = makeFreshStore()
s.play()
expect(s.playing).toBe(true)
const handle = mountComponent(App, {})
try {
await nextTick()
await nextTick()
const btn = handle.host.querySelector(
`[aria-label="${i18nT('app.fullscreenPlayback')}"]`,
) as HTMLButtonElement | null
expect(btn).toBeTruthy()
btn!.click()
await nextTick()
await nextTick()
expect(s.playing).toBe(false)
expect(document.body.querySelector('.fs-root')).toBeTruthy()
expect(document.body.querySelector('.fs-frame')).toBeTruthy()
} finally {
handle.unmount()
}
})
test('camera output renders off-stage content over the scene background', () => {
const s = makeFreshStore()
s.scene.canvas.width = 100
s.scene.canvas.height = 100
s.scene.canvas.background = '#123456'
const rect = s.addAsset({
kind: 'rect',
name: 'Outside',
width: 10,
height: 10,
fill: '#ff0000',
})
const layer = s.addLayer(rect.id)!
s.upsertKeyframe(layer.id, 0, { x: -15, y: 50 })
s.enableCamera()
s.setCameraBaseProps({ x: -50, y: 0, width: 100, height: 100 })
const canvas = document.createElement('canvas')
canvas.width = 100
canvas.height = 100
const ctx = canvas.getContext('2d')!
renderSceneFrameForOutput(ctx, s.scene, 0, {}, { width: 100, height: 100 })
const outsideStageBg = ctx.getImageData(10, 50, 1, 1).data
expect(Array.from(outsideStageBg.slice(0, 3))).toEqual([0x12, 0x34, 0x56])
expect(outsideStageBg[3]).toBe(255)
const outsideStageLayer = ctx.getImageData(35, 50, 1, 1).data
expect(outsideStageLayer[0]).toBeGreaterThan(200)
expect(outsideStageLayer[1]).toBeLessThan(40)
expect(outsideStageLayer[2]).toBeLessThan(40)
expect(outsideStageLayer[3]).toBe(255)
})
}) })
suite('export — target size scaling', () => { suite('export — target size scaling', () => {
@@ -15897,7 +15982,7 @@ export function registerAllTests(): void {
expect(src).toContain('clipKaleidoPivot') expect(src).toContain('clipKaleidoPivot')
expect(src).toContain('clipArmLocalT') expect(src).toContain('clipArmLocalT')
// Runtime version got bumped during the kaleido + wiggle work. // Runtime version got bumped during the kaleido + wiggle work.
expect(src).toContain("'35'") expect(src).toContain("'36'")
}) })
test('exported runtime layerActiveAt is unaffected by kaleido fields', async () => { test('exported runtime layerActiveAt is unaffected by kaleido fields', async () => {
@@ -16275,7 +16360,7 @@ export function registerAllTests(): void {
expect(src).toContain('function wiggleSeeds(') expect(src).toContain('function wiggleSeeds(')
// Runtime version got bumped through the wiggle work and the // Runtime version got bumped through the wiggle work and the
// later layer-kaleido-pivot landing. // later layer-kaleido-pivot landing.
expect(src).toContain("'35'") expect(src).toContain("'36'")
}) })
}) })
@@ -16807,7 +16892,7 @@ export function registerAllTests(): void {
const src = generateRuntimeScript() const src = generateRuntimeScript()
expect(src).toContain('function layerArmTf(') expect(src).toContain('function layerArmTf(')
// Bumped on this work. // Bumped on this work.
expect(src).toContain("'35'") expect(src).toContain("'36'")
}) })
}) })
} }
+11
View File
@@ -6,6 +6,7 @@ export const en = {
pause: 'Pause (Space)', pause: 'Pause (Space)',
play: 'Play (Space)', play: 'Play (Space)',
stop: 'Stop', stop: 'Stop',
fullscreenPlayback: 'Fullscreen playback (Shift+Space)',
recentProjects: 'Recent projects', recentProjects: 'Recent projects',
copyStill: { copyStill: {
label: 'Copy current frame', label: 'Copy current frame',
@@ -467,6 +468,9 @@ export const en = {
welcomeOnStartLabel: 'Show welcome screen on start', welcomeOnStartLabel: 'Show welcome screen on start',
welcomeOnStartDesc: welcomeOnStartDesc:
'Open the welcome / recents dialog when RUWIO launches. Off → boot straight into the last edited project. The last file is always restored either way; the welcome dialog just gives you a chance to switch.', 'Open the welcome / recents dialog when RUWIO launches. Off → boot straight into the last edited project. The last file is always restored either way; the welcome dialog just gives you a chance to switch.',
fullscreenAutoplayLabel: 'Autoplay in fullscreen preview',
fullscreenAutoplayDesc:
'Start playback immediately when fullscreen preview opens. When off, fullscreen opens paused and Space starts it.',
bgVideoLabel: 'Video', bgVideoLabel: 'Video',
bgVideoNone: 'No background video', bgVideoNone: 'No background video',
bgVideoPickFile: 'Pick video file…', bgVideoPickFile: 'Pick video file…',
@@ -1140,6 +1144,7 @@ export const de: DeepPartial<Messages> = {
pause: 'Pause (Leertaste)', pause: 'Pause (Leertaste)',
play: 'Abspielen (Leertaste)', play: 'Abspielen (Leertaste)',
stop: 'Stop', stop: 'Stop',
fullscreenPlayback: 'Fullscreen-Wiedergabe (Shift+Leertaste)',
recentProjects: 'Letzte Projekte', recentProjects: 'Letzte Projekte',
copyStill: { copyStill: {
label: 'Aktuellen Frame kopieren', label: 'Aktuellen Frame kopieren',
@@ -1334,6 +1339,12 @@ export const de: DeepPartial<Messages> = {
assetThumbsLabel: 'Asset-Vorschaubilder', assetThumbsLabel: 'Asset-Vorschaubilder',
assetThumbsDesc: assetThumbsDesc:
'Zeigt kleine Vorschauen in Assets, Clips und Buttons. Aus ist kompakter, an ist übersichtlicher.', 'Zeigt kleine Vorschauen in Assets, Clips und Buttons. Aus ist kompakter, an ist übersichtlicher.',
welcomeOnStartLabel: 'Willkommensfenster beim Start anzeigen',
welcomeOnStartDesc:
'Öffnet beim Start von RUWIO den Willkommen-/Letzte-Projekte-Dialog. Aus startet direkt mit dem zuletzt bearbeiteten Projekt.',
fullscreenAutoplayLabel: 'Autoplay in Fullscreen-Vorschau',
fullscreenAutoplayDesc:
'Startet die Wiedergabe sofort, wenn die Fullscreen-Vorschau geöffnet wird. Wenn aus, öffnet sie pausiert und Leertaste startet sie.',
bgVideoLabel: 'Video', bgVideoLabel: 'Video',
bgVideoNone: 'Kein Hintergrundvideo', bgVideoNone: 'Kein Hintergrundvideo',
bgVideoPickFile: 'Videodatei wählen…', bgVideoPickFile: 'Videodatei wählen…',
+10 -1
View File
@@ -182,11 +182,20 @@ export function renderSceneFrameForOutput(
ctx.save() ctx.save()
ctx.setTransform(1, 0, 0, 1, 0, 0) ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, outputWidth, outputHeight) ctx.clearRect(0, 0, outputWidth, outputHeight)
const bg = scene.canvas.background
if (!scene.backgroundVideo && bg && bg !== 'transparent') {
ctx.fillStyle = bg
ctx.fillRect(0, 0, outputWidth, outputHeight)
}
ctx.restore() ctx.restore()
ctx.save() ctx.save()
ctx.scale(outputWidth / camera.width, outputHeight / camera.height) ctx.scale(outputWidth / camera.width, outputHeight / camera.height)
ctx.translate(-camera.x, -camera.y) ctx.translate(-camera.x, -camera.y)
drawSceneFrame(ctx, scene, time, images, { crop: options?.cropStage }) drawSceneFrame(ctx, scene, time, images, {
crop: options?.cropStage ?? false,
clear: false,
background: false,
})
ctx.restore() ctx.restore()
} }
+7 -1
View File
@@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project {
return Array.isArray((p as Partial<Project>).scenes) return Array.isArray((p as Partial<Project>).scenes)
} }
const RUNTIME_VERSION = '35' const RUNTIME_VERSION = '36'
/** Materialize the engine source. The font map is rebuilt on every /** Materialize the engine source. The font map is rebuilt on every
* call so user-uploaded custom fonts (which live in localStorage * call so user-uploaded custom fonts (which live in localStorage
@@ -1178,11 +1178,17 @@ const ENGINE_TEMPLATE = `(function(){
ctx.beginPath(); ctx.beginPath();
ctx.rect(0,0,vw,vh); ctx.rect(0,0,vw,vh);
ctx.clip(); ctx.clip();
if(useCamera && !scene.backgroundVideo && scene.canvas.background && scene.canvas.background !== 'transparent'){
ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,vw,vh);
}
if(useCamera) ctx.translate(-cam.x,-cam.y); if(useCamera) ctx.translate(-cam.x,-cam.y);
if(!scene.backgroundVideo){ if(!scene.backgroundVideo){
if(!useCamera){
ctx.fillStyle=scene.canvas.background; ctx.fillStyle=scene.canvas.background;
ctx.fillRect(0,0,lw,lh); ctx.fillRect(0,0,lw,lh);
} }
}
var layerById={}; var layerById={};
for(var li=0;li<scene.layers.length;li++)layerById[scene.layers[li].id]=scene.layers[li]; for(var li=0;li<scene.layers.length;li++)layerById[scene.layers[li].id]=scene.layers[li];
var clipById2={}; var clipById2={};
+10 -3
View File
@@ -566,11 +566,18 @@ export function drawSceneFrame(
scene: Scene, scene: Scene,
time: number, time: number,
images: Record<string, HTMLImageElement>, images: Record<string, HTMLImageElement>,
options?: { crop?: boolean; buttonStates?: Map<string, ButtonState> }, options?: {
crop?: boolean
clear?: boolean
background?: boolean
buttonStates?: Map<string, ButtonState>
},
) { ) {
const lw = scene.canvas.width const lw = scene.canvas.width
const lh = scene.canvas.height const lh = scene.canvas.height
const crop = options?.crop !== false const crop = options?.crop !== false
const clear = options?.clear !== false
const paintBackground = options?.background !== false
const buttonStates = options?.buttonStates const buttonStates = options?.buttonStates
ctx.save() ctx.save()
@@ -583,13 +590,13 @@ export function drawSceneFrame(
// Mandatory for transparent-background scenes so subsequent frames // Mandatory for transparent-background scenes so subsequent frames
// don't accumulate alpha; harmless for opaque ones since `fillRect` // don't accumulate alpha; harmless for opaque ones since `fillRect`
// below paints over everything. // below paints over everything.
ctx.clearRect(0, 0, lw, lh) if (clear) ctx.clearRect(0, 0, lw, lh)
// A background video takes over the backdrop — leave the stage area // A background video takes over the backdrop — leave the stage area
// transparent so the DOM video underneath is visible. Same treatment // transparent so the DOM video underneath is visible. Same treatment
// when the canvas background is explicitly 'transparent' so // when the canvas background is explicitly 'transparent' so
// APNG/WebM exports carry the alpha channel out to the file. // APNG/WebM exports carry the alpha channel out to the file.
const bg = scene.canvas.background const bg = scene.canvas.background
if (!scene.backgroundVideo && bg && bg !== 'transparent') { if (paintBackground && !scene.backgroundVideo && bg && bg !== 'transparent') {
ctx.fillStyle = bg ctx.fillStyle = bg
ctx.fillRect(0, 0, lw, lh) ctx.fillRect(0, 0, lw, lh)
} }