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
+188 -17

No files matched your search

+15 -4
View File
@@ -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()"
/>
<div class="transport-side transport-right">
<IconButton
:icon="PhMonitorPlay"
icon-weight="regular"
size="md"
:title="t('app.fullscreenPlayback')"
@click="openFullscreenPreview"
/>
<IconButton
:icon="PhStop"
icon-weight="fill"
+13
View File
@@ -14,6 +14,7 @@ import type { Theme } from '@/composables/useTheme'
import { useTooltipPref } from '@/composables/useTooltipPref'
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
import { useShowWelcomeOnStart } from '@/composables/useShowWelcomeOnStart'
import { useFullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay'
import { showLocked } from '@/composables/useShowLocked'
import { Modal, ToggleSwitch } from '@/components/ui'
import { setLocale, t, useI18n } from '@/i18n'
@@ -27,6 +28,7 @@ const { theme, setTheme } = useTheme()
const { enabled: tooltipsEnabled } = useTooltipPref()
const { enabled: thumbsEnabled } = useAssetThumbsPref()
const { enabled: welcomeOnStart } = useShowWelcomeOnStart()
const { enabled: fullscreenAutoplay } = useFullscreenPreviewAutoplay()
const { locale } = useI18n()
// Re-check the stored clipart folder whenever settings opens — covers the
@@ -288,6 +290,17 @@ function applyTheme(t: Theme) {
</div>
</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-heading">{{ t('settings.appearanceTitle') }}</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 { 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)
})
},
)
@@ -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 { 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'")
})
})
}
+11
View File
@@ -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<Messages> = {
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<Messages> = {
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…',
+10 -1
View File
@@ -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()
}
+9 -3
View File
@@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project {
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
* 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<scene.layers.length;li++)layerById[scene.layers[li].id]=scene.layers[li];
+10 -3
View File
@@ -566,11 +566,18 @@ export function drawSceneFrame(
scene: Scene,
time: number,
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 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)
}