Skip to content

Commit cbba7e0

Browse files
committed
feat: support adding blank frames in split previews
1 parent 4c92e43 commit cbba7e0

3 files changed

Lines changed: 110 additions & 1 deletion

File tree

frontend/src/components/GifFrameConverter.tsx

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect, useMemo, useState } from 'react'
22
import { Button, InputNumber, message, Radio, Slider, Space, Tabs, Tooltip, Typography, Upload } from 'antd'
3-
import { CaretLeftOutlined, CaretRightOutlined, DeleteOutlined, DownloadOutlined, DragOutlined, FileImageOutlined, LayoutOutlined, PictureOutlined, MergeCellsOutlined } from '@ant-design/icons'
3+
import { CaretLeftOutlined, CaretRightOutlined, DeleteOutlined, DownloadOutlined, DragOutlined, FileImageOutlined, LayoutOutlined, PictureOutlined, MergeCellsOutlined, PlusOutlined } from '@ant-design/icons'
44
import type { UploadFile } from 'antd'
55
import { parseGIF, decompressFrames } from 'gifuct-js'
66
// @ts-expect-error gifenc has no types
@@ -62,6 +62,26 @@ function compositeFrame(
6262
return buf
6363
}
6464

65+
function loadImageElement(src: string): Promise<HTMLImageElement> {
66+
return new Promise((resolve, reject) => {
67+
const img = new Image()
68+
img.onload = () => resolve(img)
69+
img.onerror = () => reject(new Error('load'))
70+
img.src = src
71+
})
72+
}
73+
74+
function createTransparentPngBlob(width: number, height: number): Promise<Blob> {
75+
const canvas = document.createElement('canvas')
76+
canvas.width = Math.max(1, Math.floor(width))
77+
canvas.height = Math.max(1, Math.floor(height))
78+
const ctx = canvas.getContext('2d')
79+
ctx?.clearRect(0, 0, canvas.width, canvas.height)
80+
return new Promise((resolve, reject) => {
81+
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error('canvas'))), 'image/png')
82+
})
83+
}
84+
6585
export default function GifFrameConverter() {
6686
const { t } = useLanguage()
6787
const [activeTab, setActiveTab] = useState<'gif2frames' | 'frames2gif' | 'images2single' | 'simpleStitch'>('images2single')
@@ -108,6 +128,7 @@ export default function GifFrameConverter() {
108128
const [cropRight, setCropRight] = useState(0)
109129
const [cropPreviewIndex, setCropPreviewIndex] = useState(0)
110130
const [firstImageSize, setFirstImageSize] = useState<{ w: number; h: number } | null>(null)
131+
const [addingBlankFrame, setAddingBlankFrame] = useState(false)
111132

112133
const [stitchFiles, setStitchFiles] = useState<File[]>([])
113134
const [stitchInputUrls, setStitchInputUrls] = useState<string[]>([])
@@ -323,6 +344,32 @@ export default function GifFrameConverter() {
323344
}
324345
}
325346

347+
const addBlankCombineFrame = async () => {
348+
if (combineFiles.length === 0) return
349+
setAddingBlankFrame(true)
350+
try {
351+
const refSize =
352+
firstImageSize ??
353+
(await (async () => {
354+
const url = URL.createObjectURL(combineFiles[0]!)
355+
try {
356+
const img = await loadImageElement(url)
357+
return { w: img.naturalWidth, h: img.naturalHeight }
358+
} finally {
359+
URL.revokeObjectURL(url)
360+
}
361+
})())
362+
const blob = await createTransparentPngBlob(refSize.w, refSize.h)
363+
const nextIndex = combineFiles.length + 1
364+
const blankFile = new File([blob], `blank_${String(nextIndex).padStart(3, '0')}.png`, { type: 'image/png' })
365+
setCombineFiles((prev) => [...prev, blankFile])
366+
} catch (e) {
367+
message.error(t('imagesToSingleAddBlankFailed') + ': ' + String(e))
368+
} finally {
369+
setAddingBlankFrame(false)
370+
}
371+
}
372+
326373
const downloadCombined = () => {
327374
if (!combinedUrl) return
328375
const a = document.createElement('a')
@@ -926,6 +973,14 @@ export default function GifFrameConverter() {
926973
<Space wrap align="center" style={{ marginBottom: 12 }}>
927974
<Text type="secondary">{t('imagesToSingleCols')}:</Text>
928975
<InputNumber min={1} max={64} value={combineCols} onChange={(v) => setCombineCols(v ?? 4)} style={{ width: 72 }} />
976+
<Button
977+
icon={<PlusOutlined />}
978+
onClick={addBlankCombineFrame}
979+
disabled={combineFiles.length === 0}
980+
loading={addingBlankFrame}
981+
>
982+
{t('imagesToSingleAddBlank')}
983+
</Button>
929984
</Space>
930985
<Space wrap align="center" style={{ marginBottom: 12 }}>
931986
<Text type="secondary">{t('imagesToSingleInputMode')}:</Text>

frontend/src/components/SpriteSheetAdjust.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,20 @@ function loadImageElement(src: string): Promise<HTMLImageElement> {
240240
})
241241
}
242242

243+
function createTransparentFrameUrl(width: number, height: number): Promise<string> {
244+
const canvas = document.createElement('canvas')
245+
canvas.width = Math.max(1, Math.floor(width))
246+
canvas.height = Math.max(1, Math.floor(height))
247+
const ctx = canvas.getContext('2d')
248+
ctx?.clearRect(0, 0, canvas.width, canvas.height)
249+
return new Promise((resolve, reject) => {
250+
canvas.toBlob((blob) => {
251+
if (blob) resolve(URL.createObjectURL(blob))
252+
else reject(new Error('canvas'))
253+
}, 'image/png')
254+
})
255+
}
256+
243257
/** RoninPro:单图网格拆分与整图均分功能重叠且易混淆;暂隐藏第三项,改为 true 恢复 */
244258
const SHEET_PRO_GRID_SPLIT_VISIBLE = false
245259

@@ -330,6 +344,7 @@ export default function SpriteSheetAdjust({ integratedSplit = false }: SpriteShe
330344
const [recombinedParams, setRecombinedParams] = useState<{ cellW: number; cellH: number } | null>(null)
331345
const [recombining, setRecombining] = useState(false)
332346
const [applyProgress, setApplyProgress] = useState<number | null>(null)
347+
const [addingBlankFrame, setAddingBlankFrame] = useState(false)
333348

334349
const gridCols = integratedSplit ? layoutCols : cols
335350
const gridRows = integratedSplit ? layoutRows : rows
@@ -1358,6 +1373,32 @@ export default function SpriteSheetAdjust({ integratedSplit = false }: SpriteShe
13581373
[frameUrls.length, sheetInputMode],
13591374
)
13601375

1376+
const addIntegratedBlankFrame = useCallback(async () => {
1377+
if (!integratedSplit || frameUrls.length === 0) return
1378+
setAddingBlankFrame(true)
1379+
try {
1380+
const refSize =
1381+
naturalSizes[0] ??
1382+
(await (async () => {
1383+
const img = await loadImageElement(frameUrls[0]!)
1384+
return { w: img.naturalWidth, h: img.naturalHeight }
1385+
})())
1386+
const blankUrl = await createTransparentFrameUrl(refSize.w, refSize.h)
1387+
pendingIntrinsicSizesRef.current =
1388+
naturalSizes.length === frameUrls.length ? [...naturalSizes, refSize] : null
1389+
setFrameUrls((prev) => [...prev, blankUrl])
1390+
setFrameOffsets((prev) => [...prev, { dx: 0, dy: 0 }])
1391+
setFrameCrops((prev) => [...prev, emptyCrop()])
1392+
setSelected((prev) => [...prev, true])
1393+
setFramePressCounts((prev) => [...prev, { up: 0, down: 0, left: 0, right: 0 }])
1394+
setNaturalSizes((prev) => (prev.length === frameUrls.length ? [...prev, refSize] : prev))
1395+
} catch (e) {
1396+
message.error(t('imagesToSingleAddBlankFailed') + ': ' + String(e))
1397+
} finally {
1398+
setAddingBlankFrame(false)
1399+
}
1400+
}, [integratedSplit, frameUrls, naturalSizes, t])
1401+
13611402
return (
13621403
<div className="sprite-adjust-module">
13631404
<Space direction="vertical" size="large" style={{ width: '100%' }}>
@@ -1627,6 +1668,13 @@ export default function SpriteSheetAdjust({ integratedSplit = false }: SpriteShe
16271668
onChange={(v) => onIntegratedPreviewColsChange(v ?? layoutCols)}
16281669
style={{ width: 72 }}
16291670
/>
1671+
<Button
1672+
icon={<PlusOutlined />}
1673+
onClick={addIntegratedBlankFrame}
1674+
loading={addingBlankFrame}
1675+
>
1676+
{t('imagesToSingleAddBlank')}
1677+
</Button>
16301678
<Text type="secondary">{t('sheetProRowsAuto', { rows: layoutRows, n: frameUrls.length })}</Text>
16311679
</Space>
16321680
<Text type="secondary" style={{ fontSize: 12 }}>{t('sheetProSplitPreviewManageHint')}</Text>

frontend/src/i18n/locales.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,6 +1055,8 @@ export const locales: Record<Lang, Record<string, string>> = {
10551055
imagesToSingleCropPreviewN: '图 {current} / {total}',
10561056
imagesToSingleCropNegativeHint: '负数为该边缘增加透明像素',
10571057
imagesToSingleDeleteRow: '删除整行',
1058+
imagesToSingleAddBlank: '添加空白帧',
1059+
imagesToSingleAddBlankFailed: '添加空白帧失败',
10581060
simpleStitch: '简易拼接',
10591061
simpleStitchHint: '上传多张图片,按上下、左右或合成重叠拼接成一张',
10601062
simpleStitchDirection: '拼接方向',
@@ -2216,6 +2218,8 @@ export const locales: Record<Lang, Record<string, string>> = {
22162218
imagesToSingleCropPreviewN: 'Image {current} / {total}',
22172219
imagesToSingleCropNegativeHint: 'Negative values add transparent pixels at that edge',
22182220
imagesToSingleDeleteRow: 'Delete row',
2221+
imagesToSingleAddBlank: 'Add blank frame',
2222+
imagesToSingleAddBlankFailed: 'Failed to add blank frame',
22192223
simpleStitch: 'Simple Stitch',
22202224
simpleStitchHint: 'Upload multiple images, stitch vertically, horizontally, or overlay',
22212225
simpleStitchDirection: 'Direction',
@@ -3378,6 +3382,8 @@ export const locales: Record<Lang, Record<string, string>> = {
33783382
imagesToSingleCropPreviewN: '画像 {current} / {total}',
33793383
imagesToSingleCropNegativeHint: '負の値はその端に透明ピクセルを追加',
33803384
imagesToSingleDeleteRow: '行を削除',
3385+
imagesToSingleAddBlank: '空白フレームを追加',
3386+
imagesToSingleAddBlankFailed: '空白フレームの追加に失敗しました',
33813387
simpleStitch: '簡易連結',
33823388
simpleStitchHint: '複数画像をアップロードし、上下・左右・重ね合成で連結',
33833389
simpleStitchDirection: '方向',

0 commit comments

Comments
 (0)