Skip to content

Commit 73fd75b

Browse files
committed
feat: 工具页:图片转DDS
1 parent 328ec47 commit 73fd75b

6 files changed

Lines changed: 220 additions & 1 deletion

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
using BCnEncoder.Encoder;
2+
using BCnEncoder.ImageSharp;
3+
using BCnEncoder.Shared;
4+
using Microsoft.AspNetCore.Mvc;
5+
using SixLabors.ImageSharp;
6+
using SixLabors.ImageSharp.PixelFormats;
7+
using SixLabors.ImageSharp.Processing;
8+
9+
namespace ChuChartManager.Controllers;
10+
11+
[ApiController]
12+
[Route("api/[controller]/[action]")]
13+
public class ToolsController : ControllerBase
14+
{
15+
public class ConvertImageToDdsRequest
16+
{
17+
public string SourcePath { get; set; } = "";
18+
public string? Format { get; set; }
19+
public int? Width { get; set; }
20+
public int? Height { get; set; }
21+
public bool GenerateMipMaps { get; set; }
22+
}
23+
24+
[HttpPost]
25+
public IActionResult ConvertImageToDds([FromBody] ConvertImageToDdsRequest request)
26+
{
27+
if (string.IsNullOrWhiteSpace(request.SourcePath) || !System.IO.File.Exists(request.SourcePath))
28+
return BadRequest("源文件不存在");
29+
30+
using var image = SixLabors.ImageSharp.Image.Load<Rgba32>(request.SourcePath);
31+
32+
var width = request.Width ?? image.Width;
33+
var height = request.Height ?? image.Height;
34+
if (width != image.Width || height != image.Height)
35+
image.Mutate(x => x.Resize(width, height));
36+
37+
var compressionFormat = request.Format?.ToLowerInvariant() switch
38+
{
39+
"bc3" => CompressionFormat.Bc3,
40+
"bc7" => CompressionFormat.Bc7,
41+
_ => CompressionFormat.Bc1,
42+
};
43+
44+
var encoder = new BcEncoder(compressionFormat)
45+
{
46+
OutputOptions =
47+
{
48+
GenerateMipMaps = request.GenerateMipMaps,
49+
FileFormat = OutputFileFormat.Dds,
50+
Quality = CompressionQuality.BestQuality,
51+
},
52+
};
53+
54+
var fileName = System.IO.Path.GetFileNameWithoutExtension(request.SourcePath) + ".dds";
55+
56+
using var ms = new System.IO.MemoryStream();
57+
encoder.EncodeToStream(image, ms);
58+
59+
return File(ms.ToArray(), "application/octet-stream", fileName);
60+
}
61+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { apiClient } from './index'
2+
3+
export interface ConvertImgToDdsRequest {
4+
sourcePath: string
5+
format?: 'bc1' | 'bc3' | 'bc7'
6+
width?: number
7+
height?: number
8+
generateMipMaps?: boolean
9+
}
10+
11+
export async function convertImgToDds(params: ConvertImgToDdsRequest): Promise<void> {
12+
const response = await apiClient.post('/api/Tools/ConvertImageToDds', params, {
13+
responseType: 'blob',
14+
})
15+
16+
const contentDisposition = response.headers['content-disposition']
17+
let fileName = 'output.dds'
18+
if (contentDisposition) {
19+
const match = contentDisposition.match(/filename\*?=(?:UTF-8'')?([^;\s]+)/i)
20+
if (match) fileName = decodeURIComponent(match[1])
21+
}
22+
23+
const blob = new Blob([response.data], { type: 'application/octet-stream' })
24+
const url = URL.createObjectURL(blob)
25+
const a = document.createElement('a')
26+
a.href = url
27+
a.download = fileName
28+
document.body.appendChild(a)
29+
a.click()
30+
document.body.removeChild(a)
31+
URL.revokeObjectURL(url)
32+
}

ChuChartManager/Front/src/locales/en.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,23 @@ ddsExtractor:
457457
extract: Extract
458458
done: "Extraction complete: {count} DDS from {files} file(s)"
459459
failed: Extraction failed
460+
imgToDds:
461+
title: Image to DDS
462+
desc: Convert common image formats to DDS texture
463+
selectImage: Select Image
464+
noImage: No image selected
465+
format: Compression Format
466+
bc1: BC1 / DXT1
467+
bc3: BC3 / DXT5
468+
bc7: BC7
469+
width: Output Width
470+
height: Output Height
471+
auto: Auto
472+
generateMipMaps: Generate Mipmaps
473+
convert: Convert
474+
converting: Converting…
475+
success: Conversion successful
476+
failed: Conversion failed
460477
emote:
461478
title: Emote Models
462479
data: Emote Models

ChuChartManager/Front/src/locales/ja.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,23 @@ ddsExtractor:
448448
extract: 抽出
449449
done: "抽出完了:{count} DDS、{files} ファイルから"
450450
failed: 抽出失敗
451+
imgToDds:
452+
title: 画像を DDS に変換
453+
desc: 一般的な画像形式を DDS テクスチャに変換します
454+
selectImage: 画像を選択
455+
noImage: 画像が選択されていません
456+
format: 圧縮形式
457+
bc1: BC1 / DXT1
458+
bc3: BC3 / DXT5
459+
bc7: BC7
460+
width: 出力幅
461+
height: 出力高さ
462+
auto: 自動
463+
generateMipMaps: Mipmap を生成
464+
convert: 変換
465+
converting: 変換中…
466+
success: 変換成功
467+
failed: 変換失敗
451468
emote:
452469
title: Emote モデル
453470
data: Emote モデル

ChuChartManager/Front/src/locales/zh.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -457,6 +457,23 @@ ddsExtractor:
457457
extract: 提取
458458
done: "提取完成:{count} 个 DDS,来自 {files} 个文件"
459459
failed: 提取失败
460+
imgToDds:
461+
title: 图片转 DDS
462+
desc: 将常用图片格式转换为 DDS 纹理
463+
selectImage: 选择图片
464+
noImage: 未选择图片
465+
format: 压缩格式
466+
bc1: BC1 / DXT1
467+
bc3: BC3 / DXT5
468+
bc7: BC7
469+
width: 输出宽度
470+
height: 输出高度
471+
auto: 自动
472+
generateMipMaps: 生成 Mipmap
473+
convert: 转换
474+
converting: 转换中…
475+
success: 转换成功
476+
failed: 转换失败
460477
emote:
461478
title: Emote 模型
462479
data: Emote 模型

ChuChartManager/Front/src/views/Tools/index.tsx

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import DirSelect from '@/components/DirSelect'
77
import { openImageFileDialog, createTrophy, createNamePlate, createAvatarAccessory, createMapIcon, getResourceList, getLocalImagePreviewUrl } from '@/api/customResource'
88
import { openAfbFileDialog, openAfbFolderDialog, extractDds } from '@/api/ddsExtractor'
99
import type { ExtractResult } from '@/api/ddsExtractor'
10+
import { convertImgToDds } from '@/api/imgToDds'
1011
import CharaCreator from './CharaCreator'
1112

12-
type ModalType = null | 'trophy' | 'namePlate' | 'avatarAccessory' | 'mapIcon' | 'ddsExtractor'
13+
type ModalType = null | 'trophy' | 'namePlate' | 'avatarAccessory' | 'mapIcon' | 'ddsExtractor' | 'imgToDds'
1314

1415
export default defineComponent({
1516
setup() {
@@ -22,6 +23,14 @@ export default defineComponent({
2223
const ddsExtracting = ref(false)
2324
const ddsResults = ref<ExtractResult[]>([])
2425

26+
// imgtodds
27+
const imgPath = ref('')
28+
const converting = ref(false)
29+
const imgFormat = ref<'bc1' | 'bc3' | 'bc7'>('bc1')
30+
const imgWidth = ref(0)
31+
const imgHeight = ref(0)
32+
const generateMipMaps = ref(false)
33+
2534
const targetDir = ref('')
2635
const resourceId = ref(9000)
2736
const resourceName = ref('')
@@ -67,8 +76,16 @@ export default defineComponent({
6776
{ icon: 'i-mdi-hanger', labelKey: 'tools.createAvatarAccessory', action: () => openModal('avatarAccessory'), experimental: true },
6877
{ icon: 'i-mdi-map-marker', labelKey: 'tools.createMapIcon', action: () => openModal('mapIcon'), experimental: true },
6978
{ icon: 'i-mdi-account', labelKey: 'tools.createChara', action: () => { showCharaCreator.value = true }, experimental: true },
79+
{ icon: 'i-mdi-image-multiple', labelKey: 'imgToDds.title', action: () => openModal('imgToDds'), experimental: false },
80+
])
81+
82+
const formatOptions = computed<SelectOption[]>(() => [
83+
{ label: t('imgToDds.bc1'), value: 'bc1' },
84+
{ label: t('imgToDds.bc3'), value: 'bc3' },
85+
{ label: t('imgToDds.bc7'), value: 'bc7' },
7086
])
7187

88+
7289
function openModal(type: ModalType) {
7390
activeModal.value = type
7491
resourceId.value = 9000
@@ -80,6 +97,7 @@ export default defineComponent({
8097
textureImagePath.value = ''
8198
accessoryCategory.value = 1
8299
idConflict.value = false
100+
imgPath.value = ''
83101
const custom = optionDirs.value.filter(d => d.dirName !== 'A000')
84102
if (custom.length > 0 && !targetDir.value)
85103
targetDir.value = custom[0].dirName
@@ -94,6 +112,11 @@ export default defineComponent({
94112
if (path) imagePath.value = path
95113
}
96114

115+
async function selectImgToDdsImage() {
116+
const path = await openImageFileDialog()
117+
if (path) imgPath.value = path
118+
}
119+
97120
async function selectIconImage() {
98121
const path = await openImageFileDialog()
99122
if (path) iconImagePath.value = path
@@ -134,6 +157,30 @@ export default defineComponent({
134157
}
135158
}
136159

160+
async function handleConvert() {
161+
if (!imgPath.value) {
162+
addToast({ message: t('imgToDds.noImage'), type: 'error' })
163+
return
164+
}
165+
converting.value = true
166+
try {
167+
await convertImgToDds({
168+
sourcePath: imgPath.value,
169+
format: imgFormat.value,
170+
width: imgWidth.value || undefined,
171+
height: imgHeight.value || undefined,
172+
generateMipMaps: generateMipMaps.value,
173+
})
174+
addToast({ message: t('imgToDds.success'), type: 'success' })
175+
closeModal()
176+
} catch (e: any) {
177+
const msg = e?.response?.data || e?.message || t('imgToDds.failed')
178+
addToast({ message: String(msg), type: 'error' })
179+
} finally {
180+
converting.value = false
181+
}
182+
}
183+
137184
async function checkIdConflict() {
138185
if (!activeModal.value) return
139186
idChecking.value = true
@@ -222,6 +269,7 @@ export default defineComponent({
222269
case 'namePlate': return t('tools.createNamePlate')
223270
case 'avatarAccessory': return t('tools.createAvatarAccessory')
224271
case 'mapIcon': return t('tools.createMapIcon')
272+
case 'imgToDds': return t('imgToDds.title')
225273
default: return ''
226274
}
227275
})
@@ -317,6 +365,33 @@ export default defineComponent({
317365
<Button onClick={handleExtractDds} ing={ddsExtracting.value}>{t('ddsExtractor.extract')}</Button>
318366
</div>
319367
</div>
368+
) : activeModal.value === 'imgToDds' ? (
369+
<div class="flex flex-col gap-3 p-2">
370+
<p class="text-sm op-50">{t('imgToDds.desc')}</p>
371+
{renderImageSelector(imgPath, selectImgToDdsImage, t('imgToDds.selectImage'))}
372+
<div>
373+
<label class="block text-sm op-60 mb-1">{t('imgToDds.format')}</label>
374+
<Select options={formatOptions.value} v-model:value={imgFormat.value} />
375+
</div>
376+
<div class="flex gap-3">
377+
<div class="flex-1">
378+
<label class="block text-sm op-60 mb-1">{t('imgToDds.width')}</label>
379+
<NumberInput v-model:value={imgWidth.value} min={0} max={8192} placeholder={t('imgToDds.auto')} />
380+
</div>
381+
<div class="flex-1">
382+
<label class="block text-sm op-60 mb-1">{t('imgToDds.height')}</label>
383+
<NumberInput v-model:value={imgHeight.value} min={0} max={8192} placeholder={t('imgToDds.auto')} />
384+
</div>
385+
</div>
386+
<label class="flex items-center gap-2 text-sm cursor-pointer">
387+
<input type="checkbox" v-model={generateMipMaps.value} class="checkbox checkbox-primary" />
388+
<span>{t('imgToDds.generateMipMaps')}</span>
389+
</label>
390+
<div class="flex justify-end gap-2 mt-2">
391+
<Button onClick={closeModal}>{t('common.cancel')}</Button>
392+
<Button onClick={handleConvert} ing={converting.value}>{t('imgToDds.convert')}</Button>
393+
</div>
394+
</div>
320395
) : (
321396
<div class="flex flex-col gap-3 p-2">
322397
<div>

0 commit comments

Comments
 (0)