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:
1 parent
b0813a1d50
commit
b5c5270256
6 files changed
+299
-2
No files matched your search
+1
-1
@@ -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",
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
Reference in new issue
Block a user