1.1.6: still image export — PNG / JPG snapshot of the current frame

Adds a "Still image" subgroup under the project column in the export
dialog. Both options render the current playhead frame via the same
drawSceneFrame path used by GIF/APNG, so the still is pixel-identical
to the matching animated frame. JPG fills transparent scenes white;
PNG keeps alpha. Honours the per-export target size for higher-res
poster frames.
This commit is contained in:
th committed 2026-04-25 09:49:32 +02:00
1 parent b0813a1d50
commit b5c5270256
6 files changed
+299 -2

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "ruwio", "name": "ruwio",
"private": true, "private": true,
"version": "1.1.5", "version": "1.1.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+54
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { import {
PhCamera,
PhCode, PhCode,
PhDownloadSimple, PhDownloadSimple,
PhEngine, PhEngine,
@@ -17,6 +18,7 @@ import { downloadSceneAsGif } from '@/lib/gifExport'
import { downloadSceneAsApng, downloadSceneAsApngPreview } from '@/lib/apngExport' import { downloadSceneAsApng, downloadSceneAsApngPreview } from '@/lib/apngExport'
import { downloadFromServer } from '@/lib/serverExport' import { downloadFromServer } from '@/lib/serverExport'
import { downloadRuwFile } from '@/lib/ruwFile' import { downloadRuwFile } from '@/lib/ruwFile'
import { downloadSceneAsStillImage } from '@/lib/stillImageExport'
import { useProjectName } from '@/composables/useProjectName' import { useProjectName } from '@/composables/useProjectName'
import type { ButtonAsset, Layer } from '@/types/scene' import type { ButtonAsset, Layer } from '@/types/scene'
import { Modal } from '@/components/ui' import { Modal } from '@/components/ui'
@@ -221,6 +223,26 @@ function exportHtml() {
emit('update:modelValue', false) emit('update:modelValue', false)
} }
async function exportStillImage(format: 'png' | 'jpg') {
// Snapshots the *current* frame using the same render path as the
// animated formats — pixel-identical to the matching frame in a
// GIF/APNG export. Honours the per-export targetSize so the user
// can pull a higher-res poster frame than the editor canvas.
const base = (projectName.value ?? 'frame').toString()
const safe = base.replace(/[^a-z0-9äöüß_-]+/gi, '-').replace(/^-+|-+$/g, '') || 'frame'
const opts = targetSizeChanged.value
? { targetWidth: targetWidth.value, targetHeight: targetHeight.value }
: undefined
await downloadSceneAsStillImage(
activeSceneClone(),
store.playhead,
format,
opts,
safe,
)
emit('update:modelValue', false)
}
async function exportWebm() { async function exportWebm() {
if (progress.value) return if (progress.value) return
const controller = new AbortController() const controller = new AbortController()
@@ -454,6 +476,32 @@ function onUpdate(v: boolean) {
</div> </div>
<PhDownloadSimple :size="14" class="opt-arrow" /> <PhDownloadSimple :size="14" class="opt-arrow" />
</button> </button>
<h4 class="col-heading sub-heading">{{ t('export.colStill') }}</h4>
<button class="opt" @click="exportStillImage('png')">
<div class="opt-icon"><PhCamera :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
{{ t('export.stillPngTitle') }}
<span class="badge">{{ t('export.stillPngBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.stillPngDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
<button class="opt" @click="exportStillImage('jpg')">
<div class="opt-icon"><PhCamera :size="20" /></div>
<div class="opt-text">
<div class="opt-title">
{{ t('export.stillJpgTitle') }}
<span class="badge">{{ t('export.stillJpgBadge') }}</span>
</div>
<div class="opt-desc">{{ t('export.stillJpgDesc') }}</div>
</div>
<PhDownloadSimple :size="14" class="opt-arrow" />
</button>
</section> </section>
<section class="export-col"> <section class="export-col">
@@ -634,6 +682,12 @@ function onUpdate(v: boolean) {
letter-spacing: 0.06em; letter-spacing: 0.06em;
} }
/* Second heading inside the same column gets breathing room so it
reads as its own subgroup rather than crowding the export above. */
.col-heading.sub-heading {
margin-top: 8px;
}
@media (max-width: 720px) { @media (max-width: 720px) {
/* Stage tight viewports: collapse to a single column so the /* Stage tight viewports: collapse to a single column so the
options stay readable instead of cramming. */ options stay readable instead of cramming. */
+139
View File
@@ -1439,6 +1439,84 @@ export function registerAllTests(): void {
}) })
}) })
suite('export — still image', () => {
// The still-image export reuses the animated render path so the
// saved PNG/JPG matches the corresponding frame in a GIF/APNG
// exactly. These checks pin the public surface (blob mime,
// dimensions, transparency handling) rather than re-testing the
// render itself.
function tinyScene() {
const s = makeFreshStore()
s.setDuration(0.25)
s.project.fps = 4
s.scene.canvas.width = 16
s.scene.canvas.height = 16
const a = s.addAsset({
kind: 'rect', name: 'P', width: 16, height: 16, fill: '#22cc88',
})
s.addLayer(a.id)!
return s
}
test('renderSceneToStillImage returns a PNG blob with the requested size', async () => {
const { renderSceneToStillImage } = await import('@/lib/stillImageExport')
const s = tinyScene()
const blob = await renderSceneToStillImage(s.scene, 0, 'png', {
targetWidth: 64, targetHeight: 64,
})
expect(blob.type).toBe('image/png')
expect(blob.size > 0).toBe(true)
})
test('renderSceneToStillImage returns a JPEG blob for format=jpg', async () => {
const { renderSceneToStillImage } = await import('@/lib/stillImageExport')
const s = tinyScene()
const blob = await renderSceneToStillImage(s.scene, 0, 'jpg', {
targetWidth: 32, targetHeight: 32,
})
expect(blob.type).toBe('image/jpeg')
expect(blob.size > 0).toBe(true)
})
test('JPG of a transparent scene gets a solid white backdrop', async () => {
// Sanity: JPG can't carry alpha; a transparent scene must be
// composited onto an opaque background or it'd come out black.
const { renderSceneToStillImage } = await import('@/lib/stillImageExport')
const s = tinyScene()
s.scene.canvas.background = 'transparent'
const blob = await renderSceneToStillImage(s.scene, 0, 'jpg', {
targetWidth: 8, targetHeight: 8,
})
// Decode the blob back into pixel data and check at least one
// pixel that should NOT have been touched by the green rect.
// Easiest: render at 1×1 covering the whole canvas, sample
// origin. Better: re-render to a small canvas so we can read
// pixels deterministically without a network round-trip.
const url = URL.createObjectURL(blob)
try {
const img = new Image()
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve()
img.onerror = (e) => reject(e)
img.src = url
})
const c = document.createElement('canvas')
c.width = 8; c.height = 8
const cx = c.getContext('2d')!
cx.drawImage(img, 0, 0)
// The whole 16×16 scene was a green rect, so every pixel of
// the JPEG is green — what we want to confirm is that it's
// NOT black. Read center pixel and assert at least one of
// R/G/B is non-zero, which rules out a pure-black backdrop.
const px = cx.getImageData(4, 4, 1, 1).data
expect(px[0] + px[1] + px[2] > 0).toBe(true)
} finally {
URL.revokeObjectURL(url)
}
})
})
suite('ui — ExportDialog', () => { suite('ui — ExportDialog', () => {
// ExportDialog teleports through Modal so query through document // ExportDialog teleports through Modal so query through document
// rather than the handle's host root. Each test snapshots the // rather than the handle's host root. Each test snapshots the
@@ -1614,6 +1692,67 @@ export function registerAllTests(): void {
} }
}) })
test('renders the Still image subgroup with PNG + JPG options', async () => {
// The third subgroup lives under the project column and is
// driven by the playhead, not the playback range. The heading
// exists and both options surface.
makeFreshStore()
const baseline = snapshotDialogs()
const handle = mountComponent(ExportDialog, { modelValue: true })
try {
await nextTick()
const dialog = dialogsAddedSince(baseline)[0]
const headings = Array.from(dialog.querySelectorAll('.col-heading')).map(
(el) => el.textContent?.trim() ?? '',
)
expect(headings).toContain(i18nT('export.colStill'))
expect(findOptionByTitle(dialog, i18nT('export.stillPngTitle'))).toBeTruthy()
expect(findOptionByTitle(dialog, i18nT('export.stillJpgTitle'))).toBeTruthy()
} finally {
handle.unmount()
}
})
test('clicking the PNG still triggers a download with .png extension', async () => {
// Stub anchor.click so the test sees the URL/download attrs
// without actually navigating. Use a minimal scene so render
// is fast. After click we assert the dialog closed.
const s = makeFreshStore()
s.setDuration(0.25)
s.project.fps = 4
s.scene.canvas.width = 8
s.scene.canvas.height = 8
const a = s.addAsset({
kind: 'rect', name: 'P', width: 8, height: 8, fill: '#22cc88',
})
s.addLayer(a.id)!
const downloads: { name: string; href: string }[] = []
const originalClick = HTMLAnchorElement.prototype.click
HTMLAnchorElement.prototype.click = function () {
const a = this as HTMLAnchorElement
downloads.push({ name: a.download, href: a.href })
}
const baseline = snapshotDialogs()
const handle = mountComponent(ExportDialog, { modelValue: true })
try {
await nextTick()
const dialog = dialogsAddedSince(baseline)[0]
const btn = findOptionByTitle(dialog, i18nT('export.stillPngTitle'))
expect(btn).toBeTruthy()
btn!.click()
// Render is async — wait for the toBlob callback chain.
for (let i = 0; i < 20 && downloads.length === 0; i++) {
await new Promise<void>((r) => setTimeout(r, 5))
}
expect(downloads.length).toBe(1)
expect(downloads[0].name.endsWith('.png')).toBe(true)
} finally {
HTMLAnchorElement.prototype.click = originalClick
handle.unmount()
}
})
test('clicking APNG preview transitions the dialog to the progress view', async () => { test('clicking APNG preview transitions the dialog to the progress view', async () => {
// Tiny scene so the async render stays short. We assert the // Tiny scene so the async render stays short. We assert the
// synchronous state transition right after the click — the // synchronous state transition right after the click — the
+14
View File
@@ -481,6 +481,13 @@ export const en = {
ruwDesc: 'Zipped bundle — project JSON + every image / audio asset as a separate file. Drop it onto the welcome screen to reopen.', ruwDesc: 'Zipped bundle — project JSON + every image / audio asset as a separate file. Drop it onto the welcome screen to reopen.',
colProject: 'Project & code', colProject: 'Project & code',
colMedia: 'Media', colMedia: 'Media',
colStill: 'Still image',
stillPngTitle: 'PNG still',
stillPngBadge: 'Current frame',
stillPngDesc: 'Snapshot the current frame as a PNG. Keeps transparency.',
stillJpgTitle: 'JPG still',
stillJpgBadge: 'Current frame',
stillJpgDesc: 'Snapshot the current frame as a JPG. Smaller files; transparency is filled white.',
}, },
tests: { tests: {
@@ -1284,6 +1291,13 @@ export const de: DeepPartial<Messages> = {
skipHidden: 'Versteckte Tracks überspringen', skipHidden: 'Versteckte Tracks überspringen',
skipHiddenDesc: 'Lässt editor-versteckte Ebenen in allen Exportformaten weg.', skipHiddenDesc: 'Lässt editor-versteckte Ebenen in allen Exportformaten weg.',
skipHiddenNone: 'In dieser Szene gibt es keine editor-versteckten Tracks.', skipHiddenNone: 'In dieser Szene gibt es keine editor-versteckten Tracks.',
colStill: 'Standbild',
stillPngTitle: 'PNG-Standbild',
stillPngBadge: 'Aktueller Frame',
stillPngDesc: 'Speichert den aktuellen Frame als PNG. Transparenz bleibt erhalten.',
stillJpgTitle: 'JPG-Standbild',
stillJpgBadge: 'Aktueller Frame',
stillJpgDesc: 'Speichert den aktuellen Frame als JPG. Kleinere Dateien; Transparenz wird weiß hinterlegt.',
}, },
tests: { tests: {
running: 'Tests laufen', running: 'Tests laufen',
+90
View File
@@ -0,0 +1,90 @@
import type { Scene } from '@/types/scene'
import { drawSceneFrame, loadSceneImages } from './render'
export type StillImageFormat = 'png' | 'jpg'
export interface StillImageExportOptions {
/** Output width in pixels. Defaults to the scene canvas width. */
targetWidth?: number
/** Output height. Caller derives from targetWidth + scene aspect to
* avoid squashing. */
targetHeight?: number
/** JPEG quality 0..1 (PNG ignores this). Defaults to 0.92 — visually
* indistinguishable from the source for most flat motion graphics. */
quality?: number
/** Background fill applied under the frame. Useful for JPG (no
* alpha) so transparent scenes don't come out as black blocks.
* Defaults to white when format is 'jpg' and the scene background
* is transparent. PNG passes alpha through unchanged. */
background?: string
}
/** Render a single scene frame at `time` to a Blob. The caller decides
* what `time` means (usually the editor playhead). Reuses the same
* `drawSceneFrame` path as the animated formats, so the still image
* is pixel-identical to the matching frame in the GIF/APNG export. */
export async function renderSceneToStillImage(
scene: Scene,
time: number,
format: StillImageFormat,
opts: StillImageExportOptions = {},
): Promise<Blob> {
const srcWidth = scene.canvas.width
const srcHeight = scene.canvas.height
const width = Math.max(1, Math.round(opts.targetWidth ?? srcWidth))
const height = Math.max(1, Math.round(opts.targetHeight ?? srcHeight))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('2D context unavailable')
// JPG can't carry alpha — paint a solid backdrop first so a
// transparent scene doesn't fall back to black. PNG keeps alpha.
const sceneBg = scene.canvas.background
const needsOpaqueBackdrop =
format === 'jpg' && (!sceneBg || sceneBg === 'transparent')
const backdrop = opts.background ?? (needsOpaqueBackdrop ? '#ffffff' : null)
if (backdrop) {
ctx.fillStyle = backdrop
ctx.fillRect(0, 0, width, height)
}
const scaleX = width / srcWidth
const scaleY = height / srcHeight
if (scaleX !== 1 || scaleY !== 1) ctx.scale(scaleX, scaleY)
const images = await loadSceneImages(scene)
drawSceneFrame(ctx, scene, Math.max(0, time), images)
const mime = format === 'jpg' ? 'image/jpeg' : 'image/png'
const quality = format === 'jpg' ? (opts.quality ?? 0.92) : undefined
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) resolve(blob)
else reject(new Error('Failed to encode still image'))
},
mime,
quality,
)
})
}
export async function downloadSceneAsStillImage(
scene: Scene,
time: number,
format: StillImageFormat,
opts: StillImageExportOptions = {},
filenameBase = 'frame',
): Promise<void> {
const blob = await renderSceneToStillImage(scene, time, format, opts)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${filenameBase}.${format === 'jpg' ? 'jpg' : 'png'}`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '1.1.5' export const APP_VERSION = '1.1.6'
export const APP_NAME = 'RUWIO' export const APP_NAME = 'RUWIO'