1.0.1: target size for GIF / APNG / APNG preview
Frame-based exports (GIF, APNG, APNG-preview) accept an optional target output size. ExportDialog exposes it as a single width input above the three image-export rows; height is derived live from the scene's aspect ratio so the user can't squash the output. The preview HTML mirrors the target dims so a downsized export previews at its real pixel size. renderFrames takes targetWidth / targetHeight, sizes its canvas accordingly and scales the ctx once; drawSceneFrame's own save/restore preserves the transform across frames, including the top-level-mask offscreen compositing path. Target width on dialog open is inherited from the current scene.canvas — watched via modelValue so resizing the stage and reopening the dialog picks up the new size (instead of the stale value from whenever ExportDialog first instantiated). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c2b81b0fb4
commit
cd9cf91ef0
7 files changed
+374
-26
No files matched your search
+169
-17
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import {
|
||||
PhCode,
|
||||
PhDownloadSimple,
|
||||
@@ -19,7 +19,7 @@ import type { ButtonAsset, Layer } from '@/types/scene'
|
||||
import { Modal } from '@/components/ui'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
defineProps<{ modelValue: boolean }>()
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
const store = useSceneStore()
|
||||
@@ -40,6 +40,52 @@ const videoSupported = pickMimeType() !== null
|
||||
// explicitly asks for it. Applies to every format below.
|
||||
const skipHidden = ref(false)
|
||||
|
||||
// Target output size for frame-based formats (GIF, APNG, APNG
|
||||
// preview). The user enters a width; the height is derived from the
|
||||
// scene's aspect ratio so the export never squashes the content.
|
||||
const targetWidth = ref(Math.max(1, Math.round(store.scene.canvas.width)))
|
||||
|
||||
const targetHeight = computed(() => {
|
||||
const sw = store.scene.canvas.width
|
||||
const sh = store.scene.canvas.height
|
||||
if (sw <= 0 || sh <= 0) return targetWidth.value
|
||||
return Math.max(1, Math.round((targetWidth.value * sh) / sw))
|
||||
})
|
||||
|
||||
const targetSizeChanged = computed(
|
||||
() =>
|
||||
targetWidth.value !== Math.round(store.scene.canvas.width) ||
|
||||
targetHeight.value !== Math.round(store.scene.canvas.height),
|
||||
)
|
||||
|
||||
function resetTargetSize() {
|
||||
targetWidth.value = Math.max(1, Math.round(store.scene.canvas.width))
|
||||
}
|
||||
|
||||
// Every time the dialog re-opens, inherit the CURRENT document
|
||||
// canvas size. Without this, a user who resized the stage after
|
||||
// first opening the dialog would keep seeing the stale value from
|
||||
// whenever ExportDialog originally instantiated.
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (open) resetTargetSize()
|
||||
},
|
||||
)
|
||||
|
||||
/** Shared render options for GIF / APNG / APNG-preview. Only sends
|
||||
* target dimensions when they actually differ from the scene's own
|
||||
* size so a same-size export keeps the old no-scaling code path. */
|
||||
function frameExportOpts(controller: AbortController, onProg: (p: number) => void) {
|
||||
const base = { signal: controller.signal, onProgress: onProg }
|
||||
if (!targetSizeChanged.value) return base
|
||||
return {
|
||||
...base,
|
||||
targetWidth: targetWidth.value,
|
||||
targetHeight: targetHeight.value,
|
||||
}
|
||||
}
|
||||
|
||||
const hasHiddenLayers = computed(() => {
|
||||
if (store.scene.layers.some((l) => l.editorHidden)) return true
|
||||
if (store.scene.clipTracks?.some((t) => t.editorHidden)) return true
|
||||
@@ -174,12 +220,12 @@ async function exportGif() {
|
||||
}
|
||||
store.pause()
|
||||
try {
|
||||
await downloadSceneAsGif(activeSceneClone(), {
|
||||
signal: controller.signal,
|
||||
onProgress: (p) => {
|
||||
await downloadSceneAsGif(
|
||||
activeSceneClone(),
|
||||
frameExportOpts(controller, (p) => {
|
||||
if (progress.value) progress.value.progress = p
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
progress.value = null
|
||||
emit('update:modelValue', false)
|
||||
} catch (err) {
|
||||
@@ -202,12 +248,12 @@ async function exportApng() {
|
||||
}
|
||||
store.pause()
|
||||
try {
|
||||
await downloadSceneAsApng(activeSceneClone(), {
|
||||
signal: controller.signal,
|
||||
onProgress: (p) => {
|
||||
await downloadSceneAsApng(
|
||||
activeSceneClone(),
|
||||
frameExportOpts(controller, (p) => {
|
||||
if (progress.value) progress.value.progress = p
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
progress.value = null
|
||||
emit('update:modelValue', false)
|
||||
} catch (err) {
|
||||
@@ -230,12 +276,12 @@ async function exportApngPreview() {
|
||||
}
|
||||
store.pause()
|
||||
try {
|
||||
await downloadSceneAsApngPreview(activeSceneClone(), {
|
||||
signal: controller.signal,
|
||||
onProgress: (p) => {
|
||||
await downloadSceneAsApngPreview(
|
||||
activeSceneClone(),
|
||||
frameExportOpts(controller, (p) => {
|
||||
if (progress.value) progress.value.progress = p
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
progress.value = null
|
||||
emit('update:modelValue', false)
|
||||
} catch (err) {
|
||||
@@ -379,6 +425,33 @@ function onUpdate(v: boolean) {
|
||||
<PhDownloadSimple :size="14" class="opt-arrow" />
|
||||
</button>
|
||||
|
||||
<div class="target-size">
|
||||
<div class="target-size-label">{{ t('export.targetSize') }}</div>
|
||||
<label class="target-size-input">
|
||||
<span class="target-size-tag">W</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
:value="targetWidth"
|
||||
@input="targetWidth = Math.max(1, Math.round(Number(($event.target as HTMLInputElement).value) || 1))"
|
||||
/>
|
||||
</label>
|
||||
<span class="target-size-times">×</span>
|
||||
<div class="target-size-derived">
|
||||
{{ targetHeight }}
|
||||
<span class="target-size-tag">H</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="targetSizeChanged"
|
||||
class="target-size-reset"
|
||||
v-tooltip="t('export.targetSizeReset')"
|
||||
@click="resetTargetSize"
|
||||
>
|
||||
{{ t('export.targetSizeResetLabel') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button class="opt" @click="exportGif">
|
||||
<div class="opt-icon"><PhGif :size="20" /></div>
|
||||
<div class="opt-text">
|
||||
@@ -531,6 +604,85 @@ function onUpdate(v: boolean) {
|
||||
transform: translateX(14px);
|
||||
}
|
||||
|
||||
.target-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--panel-alt);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.target-size-label {
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.target-size-input,
|
||||
.target-size-derived {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
min-width: 82px;
|
||||
}
|
||||
|
||||
.target-size-input input {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-variant-numeric: tabular-nums;
|
||||
width: 56px;
|
||||
text-align: right;
|
||||
padding: 0;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.target-size-input input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.target-size-tag {
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.target-size-times {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.target-size-derived {
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.target-size-reset {
|
||||
margin-left: auto;
|
||||
padding: 4px 8px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.target-size-reset:hover {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in new issue
Block a user