From 7093e1f6d2b5994a5b5e49e6cd9a467a122d2072 Mon Sep 17 00:00:00 2001 From: th Date: Mon, 8 Jun 2026 18:15:27 +0200 Subject: [PATCH] Update fullscreen preview and camera output --- src/App.vue | 19 +++- src/components/DocumentSettings.vue | 13 +++ src/components/FullscreenPreview.vue | 8 +- .../useFullscreenPreviewAutoplay.ts | 27 ++++++ src/devtools/tests.ts | 91 ++++++++++++++++++- src/i18n/messages.ts | 11 +++ src/lib/camera.ts | 11 ++- src/lib/exportPlayer.ts | 12 ++- src/lib/render.ts | 13 ++- 9 files changed, 188 insertions(+), 17 deletions(-) create mode 100644 src/composables/useFullscreenPreviewAutoplay.ts diff --git a/src/App.vue b/src/App.vue index 4f4b5e1..f029baa 100644 --- a/src/App.vue +++ b/src/App.vue @@ -12,6 +12,7 @@ import { PhFloppyDisk, PhGearSix, PhImageSquare, + PhMonitorPlay, PhPause, PhPencilSimpleLine, PhPlay, @@ -195,6 +196,11 @@ function stop() { store.pause() } +function openFullscreenPreview() { + store.pause() + fullscreenOpen.value = true +} + async function saveProject() { // projectForSave stitches the current editor-session UI state // (zoom, pan, overlay toggles, timeline display) onto the @@ -508,8 +514,7 @@ function onKeyDown(e: KeyboardEvent) { if (isEditable(e.target)) return e.preventDefault() if (e.shiftKey) { - store.pause() - fullscreenOpen.value = true + openFullscreenPreview() return } store.togglePlay() @@ -595,8 +600,7 @@ async function runDesktopMenuCommand(command: string) { rewind() break case 'fullscreen-preview': - store.pause() - fullscreenOpen.value = true + openFullscreenPreview() break case 'toggle-panels': panelsHidden.value = !panelsHidden.value @@ -706,6 +710,13 @@ onBeforeUnmount(() => { @click="store.togglePlay()" />
+ + +
{{ t('settings.appearanceTitle') }}
{{ t('settings.themeHint') }}
diff --git a/src/components/FullscreenPreview.vue b/src/components/FullscreenPreview.vue index 981bd99..dd3b76a 100644 --- a/src/components/FullscreenPreview.vue +++ b/src/components/FullscreenPreview.vue @@ -6,8 +6,10 @@ import { cameraAspect, isCameraEnabled } from '@/lib/camera' import { mountPlayer, type PlayerHandle } from '@/lib/exportPlayer' import { t } from '@/i18n' import { useTimeFormat } from '@/composables/useTimeFormat' +import { useFullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay' const { formatTime } = useTimeFormat() +const { enabled: autoplay } = useFullscreenPreviewAutoplay() const props = defineProps<{ modelValue: boolean }>() const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>() @@ -51,14 +53,14 @@ async function startPreview() { // stage. Otherwise the two runtimes fight — editor audio pool + // preview audio pool would both emit the same mp3s at once. store.pause() - playing.value = true + playing.value = autoplay.value showControls.value = false const start = rangeIn.value const end = rangeOut.value const head = store.playhead const initialTime = head < start || head >= end ? start : head localTime.value = initialTime - mountAt(el, initialTime, true) + mountAt(el, initialTime, autoplay.value) requestBrowserFullscreen() } @@ -103,7 +105,7 @@ watch( if (!props.modelValue) return const still = frameEl.value if (!still) return - mountAt(still, keepTime, true) + mountAt(still, keepTime, playing.value) }) }, ) diff --git a/src/composables/useFullscreenPreviewAutoplay.ts b/src/composables/useFullscreenPreviewAutoplay.ts new file mode 100644 index 0000000..d0dbff1 --- /dev/null +++ b/src/composables/useFullscreenPreviewAutoplay.ts @@ -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(read()) + +watch(fullscreenPreviewAutoplay, (v) => { + try { + localStorage.setItem(STORAGE_KEY, String(v)) + } catch { + /* ignore */ + } +}) + +export function useFullscreenPreviewAutoplay() { + return { enabled: fullscreenPreviewAutoplay } +} diff --git a/src/devtools/tests.ts b/src/devtools/tests.ts index 4d87fd1..bc59f0a 100644 --- a/src/devtools/tests.ts +++ b/src/devtools/tests.ts @@ -34,6 +34,7 @@ import { drawButtonFrame, drawSceneFrame, renderButtonThumbnail } from '@/lib/re import { t as i18nT } from '@/i18n' import { expandedTrackOrder } from '@/lib/sceneOrder' import { CLIP_FALLBACK_BOUNDS, computeClipBounds } from '@/lib/clipBounds' +import { renderSceneFrameForOutput } from '@/lib/camera' import { deleteStoredProject, getStoredProject, @@ -43,6 +44,7 @@ import { import Modal from '@/components/ui/Modal.vue' import IconButton from '@/components/ui/IconButton.vue' import Toast from '@/components/ui/Toast.vue' +import App from '@/App.vue' import PropertiesPanel from '@/components/PropertiesPanel.vue' import ClipartDrawer from '@/components/ClipartDrawer.vue' import SceneDrawer from '@/components/SceneDrawer.vue' @@ -55,6 +57,7 @@ import ExportDialog from '@/components/ExportDialog.vue' import WelcomeDialog from '@/components/WelcomeDialog.vue' import FullscreenPreview from '@/components/FullscreenPreview.vue' import HelpDialog from '@/components/HelpDialog.vue' +import { fullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay' import { createAudioRuntime } from '@/composables/useAudioRuntime' import { clearWaveformCache, @@ -1428,6 +1431,88 @@ export function registerAllTests(): void { expect(frame?.querySelector('canvas')).toBeTruthy() 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', () => { @@ -15897,7 +15982,7 @@ export function registerAllTests(): void { expect(src).toContain('clipKaleidoPivot') expect(src).toContain('clipArmLocalT') // 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 () => { @@ -16275,7 +16360,7 @@ export function registerAllTests(): void { expect(src).toContain('function wiggleSeeds(') // Runtime version got bumped through the wiggle work and the // later layer-kaleido-pivot landing. - expect(src).toContain("'35'") + expect(src).toContain("'36'") }) }) @@ -16807,7 +16892,7 @@ export function registerAllTests(): void { const src = generateRuntimeScript() expect(src).toContain('function layerArmTf(') // Bumped on this work. - expect(src).toContain("'35'") + expect(src).toContain("'36'") }) }) } diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 9fb19eb..c0288de 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -6,6 +6,7 @@ export const en = { pause: 'Pause (Space)', play: 'Play (Space)', stop: 'Stop', + fullscreenPlayback: 'Fullscreen playback (Shift+Space)', recentProjects: 'Recent projects', copyStill: { label: 'Copy current frame', @@ -467,6 +468,9 @@ export const en = { welcomeOnStartLabel: 'Show welcome screen on start', 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.', + fullscreenAutoplayLabel: 'Autoplay in fullscreen preview', + fullscreenAutoplayDesc: + 'Start playback immediately when fullscreen preview opens. When off, fullscreen opens paused and Space starts it.', bgVideoLabel: 'Video', bgVideoNone: 'No background video', bgVideoPickFile: 'Pick video file…', @@ -1140,6 +1144,7 @@ export const de: DeepPartial = { pause: 'Pause (Leertaste)', play: 'Abspielen (Leertaste)', stop: 'Stop', + fullscreenPlayback: 'Fullscreen-Wiedergabe (Shift+Leertaste)', recentProjects: 'Letzte Projekte', copyStill: { label: 'Aktuellen Frame kopieren', @@ -1334,6 +1339,12 @@ export const de: DeepPartial = { assetThumbsLabel: 'Asset-Vorschaubilder', assetThumbsDesc: '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', bgVideoNone: 'Kein Hintergrundvideo', bgVideoPickFile: 'Videodatei wählen…', diff --git a/src/lib/camera.ts b/src/lib/camera.ts index 81aa57a..7d719d5 100644 --- a/src/lib/camera.ts +++ b/src/lib/camera.ts @@ -182,11 +182,20 @@ export function renderSceneFrameForOutput( ctx.save() ctx.setTransform(1, 0, 0, 1, 0, 0) 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.save() ctx.scale(outputWidth / camera.width, outputHeight / camera.height) 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() } diff --git a/src/lib/exportPlayer.ts b/src/lib/exportPlayer.ts index ca8d83e..7c6c7ca 100644 --- a/src/lib/exportPlayer.ts +++ b/src/lib/exportPlayer.ts @@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project { return Array.isArray((p as Partial).scenes) } -const RUNTIME_VERSION = '35' +const RUNTIME_VERSION = '36' /** Materialize the engine source. The font map is rebuilt on every * call so user-uploaded custom fonts (which live in localStorage @@ -1178,10 +1178,16 @@ const ENGINE_TEMPLATE = `(function(){ ctx.beginPath(); ctx.rect(0,0,vw,vh); 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(!scene.backgroundVideo){ - ctx.fillStyle=scene.canvas.background; - ctx.fillRect(0,0,lw,lh); + if(!useCamera){ + ctx.fillStyle=scene.canvas.background; + ctx.fillRect(0,0,lw,lh); + } } var layerById={}; for(var li=0;li, - options?: { crop?: boolean; buttonStates?: Map }, + options?: { + crop?: boolean + clear?: boolean + background?: boolean + buttonStates?: Map + }, ) { const lw = scene.canvas.width const lh = scene.canvas.height const crop = options?.crop !== false + const clear = options?.clear !== false + const paintBackground = options?.background !== false const buttonStates = options?.buttonStates ctx.save() @@ -583,13 +590,13 @@ export function drawSceneFrame( // Mandatory for transparent-background scenes so subsequent frames // don't accumulate alpha; harmless for opaque ones since `fillRect` // 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 // transparent so the DOM video underneath is visible. Same treatment // when the canvas background is explicitly 'transparent' so // APNG/WebM exports carry the alpha channel out to the file. const bg = scene.canvas.background - if (!scene.backgroundVideo && bg && bg !== 'transparent') { + if (paintBackground && !scene.backgroundVideo && bg && bg !== 'transparent') { ctx.fillStyle = bg ctx.fillRect(0, 0, lw, lh) }