Update fullscreen preview and camera output
This commit is contained in:
1 parent
9b314ad817
commit
7093e1f6d2
9 files changed
+188
-17
No files matched your search
+15
-4
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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'")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user