完成Web ASM

This commit is contained in:
Misaki
2026-06-08 22:09:35 +08:00
parent e565b80bc2
commit 36660519c1
28 changed files with 1148 additions and 178 deletions
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NCM Dump</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<script src="/wasm_exec.js"></script>
</head>
<body class="bg-slate-950 text-slate-100 font-sans antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "ncmdump-web",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.2.3"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.6",
"typescript": "^5.5.3",
"vite": "^5.3.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+200
View File
@@ -0,0 +1,200 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import Header from './components/Header'
import FolderPicker from './components/FolderPicker'
import FileList from './components/FileList'
import StatusBar from './components/StatusBar'
import { initWasm, convertNcm, embedMetadata, isWasmSupported } from './wasm'
import type { FileItem } from './types'
export default function App() {
const [wasmReady, setWasmReady] = useState(false)
const [wasmError, setWasmError] = useState<string | null>(null)
const [inputFiles, setInputFiles] = useState<Map<string, { file: File; name: string }>>(new Map())
const [outputHandle, setOutputHandle] = useState<any>(null)
const [fileItems, setFileItems] = useState<FileItem[]>([])
const [converting, setConverting] = useState(false)
const [progress, setProgress] = useState({ done: 0, total: 0 })
const abortRef = useRef(false)
useEffect(() => {
if (isWasmSupported()) {
initWasm()
.then(() => setWasmReady(true))
.catch((e) => setWasmError(`WASM 加载失败: ${e.message}`))
} else {
setWasmError('浏览器不支持 WebAssembly')
}
}, [])
const handleInputFolder = useCallback(async (dirHandle: any) => {
const files = new Map<string, { file: File; name: string }>()
for await (const entry of dirHandle.values()) {
if (entry.kind === 'file' && entry.name.toLowerCase().endsWith('.ncm')) {
const file = await entry.getFile()
files.set(entry.name, { file, name: entry.name })
}
}
setInputFiles(files)
setFileItems(
Array.from(files.values()).map((f) => ({
name: f.name,
status: 'pending' as const,
}))
)
}, [])
const handleOutputFolder = useCallback((handle: any) => {
setOutputHandle(handle)
}, [])
const handleConvert = useCallback(async () => {
if (!outputHandle || inputFiles.size === 0 || !wasmReady) return
abortRef.current = false
setConverting(true)
setProgress({ done: 0, total: inputFiles.size })
const entries = Array.from(inputFiles.values())
for (let i = 0; i < entries.length; i++) {
if (abortRef.current) break
const entry = entries[i]
const setStatus = (status: FileItem['status'], extra?: Partial<FileItem>) =>
setFileItems((prev) => prev.map((item, idx) =>
idx === i ? { ...item, status, ...extra } : item
))
setStatus('converting')
try {
const buf = await entry.file.arrayBuffer()
const result = await convertNcm(buf)
let audio = result.data
// Fetch cover image if available
let coverData: Uint8Array | undefined
if (result.coverUrl) {
setStatus('fetching_cover')
try {
const resp = await fetch(result.coverUrl)
if (resp.ok) {
const coverBuf = await resp.arrayBuffer()
coverData = new Uint8Array(coverBuf)
}
} catch {
// cover fetch failed, proceed without it
}
}
// Embed metadata + cover
if (result.title || result.artist || result.album || coverData) {
setStatus('writing')
audio = await embedMetadata(
audio,
result.format,
{ title: result.title, artist: result.artist, album: result.album },
coverData
)
} else {
setStatus('writing')
}
const base = entry.name.replace(/\.ncm$/i, '')
const outName = `${base}.${result.format}`
const fileHandle = await outputHandle.getFileHandle(outName, { create: true })
const writable = await fileHandle.createWritable()
await writable.write(audio)
await writable.close()
setStatus('done', { format: result.format })
} catch (e: any) {
setStatus('error', { error: e.message })
}
setProgress({ done: i + 1, total: entries.length })
}
setConverting(false)
}, [inputFiles, outputHandle, wasmReady])
const handleAbort = useCallback(() => {
abortRef.current = true
setConverting(false)
}, [])
const canConvert = inputFiles.size > 0 && outputHandle !== null && wasmReady && !converting
return (
<div className="min-h-screen bg-grid">
<div className="mx-auto max-w-2xl px-4 py-8 sm:py-12">
<Header />
{wasmError && (
<div className="mt-4 animate-fade-in rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
{wasmError}
</div>
)}
{!wasmError && !wasmReady && (
<div className="mt-4 animate-fade-in rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-3 text-center text-sm text-slate-400">
<svg className="mx-auto mb-2 h-5 w-5 animate-spin text-indigo-400" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
WASM ...
</div>
)}
{wasmReady && (
<>
<div className="mt-4 grid grid-cols-2 gap-3 animate-slide-up">
<FolderPicker
label="输入文件夹"
description="选择包含 .ncm 文件的文件夹"
onSelect={handleInputFolder}
disabled={converting}
/>
<FolderPicker
label="输出文件夹"
description="选择转换后文件的保存位置"
onSelect={handleOutputFolder}
disabled={converting}
/>
</div>
{inputFiles.size > 0 && (
<div className="mt-3 animate-fade-in flex items-center gap-3">
<StatusBar progress={progress} />
<button
onClick={handleConvert}
disabled={!canConvert}
className="shrink-0 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white
hover:bg-indigo-500 active:bg-indigo-700 transition-colors
disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
{converting && (
<button
onClick={handleAbort}
className="shrink-0 rounded-lg bg-slate-700 px-4 py-2.5 text-sm font-medium text-slate-300
hover:bg-slate-600 transition-colors"
>
</button>
)}
</div>
)}
{fileItems.length > 0 && (
<FileList items={fileItems} />
)}
</>
)}
</div>
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import type { FileItem as FileItemType } from '../types'
import FileRow from './FileRow'
interface Props {
items: FileItemType[]
}
export default function FileList({ items }: Props) {
const done = items.filter((f) => f.status === 'done').length
const error = items.filter((f) => f.status === 'error').length
const total = items.length
return (
<div className="mt-6 animate-fade-in">
<div className="mb-3 flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-300"></h3>
<span className="rounded-full bg-slate-800 px-2 py-0.5 text-xs text-slate-400">
{done + error}/{total}
</span>
</div>
<div className="space-y-2">
{items.map((item, idx) => (
<FileRow key={idx} item={item} />
))}
</div>
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import type { FileItem } from '../types'
interface Props {
item: FileItem
}
export default function FileRow({ item }: Props) {
const ext = item.name.replace(/\.ncm$/i, item.format ? `.${item.format}` : '')
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-3 transition-colors hover:border-slate-700">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-200">
{item.name}
</p>
{item.status === 'done' && item.format && (
<p className="mt-0.5 truncate text-xs text-slate-500">
{'→ '}{ext}
</p>
)}
{item.status === 'error' && item.error && (
<p className="mt-0.5 truncate text-xs text-red-400">
{item.error}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
{item.status === 'pending' && (
<span className="flex items-center gap-1.5 text-xs text-slate-500">
<span className="h-1.5 w-1.5 rounded-full bg-slate-600" />
</span>
)}
{item.status === 'converting' && (
<span className="flex items-center gap-1.5 text-xs text-indigo-400">
<svg className="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</span>
)}
{item.status === 'fetching_cover' && (
<span className="flex items-center gap-1.5 text-xs text-cyan-400">
<svg className="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</span>
)}
{item.status === 'writing' && (
<span className="flex items-center gap-1.5 text-xs text-amber-400">
<svg className="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
</span>
)}
{item.status === 'done' && (
<span className="flex items-center gap-1.5 text-xs text-emerald-400">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</span>
)}
{item.status === 'error' && (
<span className="flex items-center gap-1.5 text-xs text-red-400">
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</span>
)}
</div>
</div>
</div>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { useState, useCallback } from 'react'
interface Props {
label: string
description: string
onSelect: (handle: any) => void
disabled: boolean
}
export default function FolderPicker({ label, description, onSelect, disabled }: Props) {
const [selected, setSelected] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const pick = useCallback(async () => {
setError(null)
try {
if (typeof (window as any).showDirectoryPicker !== 'function') {
setError('浏览器不支持此功能,请使用 Chrome/Edge 并通过 localhost 或 HTTPS 访问')
return
}
const handle = await (window as any).showDirectoryPicker()
setSelected(handle.name)
onSelect(handle)
} catch (e: any) {
if (e.name !== 'AbortError') {
setError(`选择文件夹失败: ${e.message || '请通过 localhost 或 HTTPS 访问'}`)
}
}
}, [onSelect])
return (
<div>
<button
onClick={pick}
disabled={disabled}
className={`
flex flex-col items-center gap-2 rounded-2xl border-2 border-dashed p-6 text-center w-full
transition-all duration-200
${selected
? 'border-indigo-500/50 bg-indigo-500/10'
: 'border-slate-700 bg-slate-900/50 hover:border-slate-600 hover:bg-slate-900/80'
}
disabled:opacity-40 disabled:cursor-not-allowed
`}
>
{selected ? (
<svg className="h-7 w-7 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
) : (
<svg className="h-7 w-7 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10.5v6m3-3H9m4.06-7.19l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
</svg>
)}
<div>
<p className="text-sm font-medium text-slate-300">{label}</p>
<p className="mt-0.5 text-xs text-slate-500">
{selected ? selected : description}
</p>
</div>
</button>
{error && (
<p className="mt-2 text-xs text-amber-400 text-center">{error}</p>
)}
</div>
)
}
+19
View File
@@ -0,0 +1,19 @@
export default function Header() {
return (
<header className="mb-8 text-center animate-fade-in">
<div className="inline-flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-indigo-500/20 ring-1 ring-indigo-500/30">
<svg className="h-5 w-5 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
</svg>
</div>
<h1 className="text-2xl font-bold tracking-tight text-white">
NCM Dump
</h1>
</div>
<p className="mt-2 text-sm text-slate-400">
.ncm
</p>
</header>
)
}
+22
View File
@@ -0,0 +1,22 @@
interface Props {
progress: { done: number; total: number }
}
export default function StatusBar({ progress }: Props) {
const pct = progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0
return (
<div className="flex-1">
<div className="flex items-center justify-between text-xs text-slate-400 mb-1">
<span></span>
<span>{progress.done}/{progress.total} ({pct}%)</span>
</div>
<div className="h-1.5 rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full rounded-full bg-indigo-500 transition-all duration-300"
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-slate-950 text-slate-200;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
}
@layer utilities {
.bg-grid {
background-image:
linear-gradient(rgba(99, 102, 241, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(99, 102, 241, 0.03) 1px, transparent 1px);
background-size: 64px 64px;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
@apply bg-slate-700 rounded-full;
}
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+30
View File
@@ -0,0 +1,30 @@
export interface ConvertResult {
data: Uint8Array
format: 'mp3' | 'flac'
title: string
artist: string
album: string
coverUrl: string
}
export type FileStatus = 'pending' | 'converting' | 'fetching_cover' | 'writing' | 'done' | 'error'
export interface FileItem {
name: string
status: FileStatus
error?: string
format?: string
}
declare global {
interface Window {
ncmdumpConvert?: (data: Uint8Array) => {
data?: Uint8Array; format?: string; error?: string
title?: string; artist?: string; album?: string; coverUrl?: string
}
ncmdumpEmbed?: (audio: Uint8Array, format: string, opts: {
title?: string; artist?: string; album?: string; coverData?: Uint8Array
}) => { data?: Uint8Array; error?: string }
ncmdumpOnReady?: () => void
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { ConvertResult } from './types'
let ready = false
let initPromise: Promise<void> | null = null
export async function initWasm(): Promise<void> {
if (ready) return
if (initPromise) return initPromise
initPromise = new Promise<void>((resolve) => {
window.ncmdumpOnReady = () => {
ready = true
resolve()
}
const go = new (window as any).Go()
WebAssembly.instantiateStreaming(fetch('/ncmdump.wasm'), go.importObject).then(
(result) => {
go.run(result.instance)
}
)
})
return initPromise
}
export async function convertNcm(data: ArrayBuffer): Promise<ConvertResult> {
if (!ready) throw new Error('WASM 未就绪')
const input = new Uint8Array(data)
const result = window.ncmdumpConvert!(input)
if (result.error) throw new Error(result.error)
return {
data: new Uint8Array(result.data!),
format: result.format as 'mp3' | 'flac',
title: result.title || '',
artist: result.artist || '',
album: result.album || '',
coverUrl: result.coverUrl || '',
}
}
export async function embedMetadata(
audio: Uint8Array,
format: string,
meta: { title: string; artist: string; album: string },
coverData?: Uint8Array
): Promise<Uint8Array> {
if (!ready) throw new Error('WASM 未就绪')
const opts: any = { title: meta.title, artist: meta.artist, album: meta.album }
if (coverData) {
opts.coverData = coverData
}
const result = window.ncmdumpEmbed!(audio, format, opts)
if (result.error) throw new Error(result.error)
return new Uint8Array(result.data!)
}
export function isWasmSupported(): boolean {
return typeof WebAssembly === 'object' && typeof (window as any).Go !== 'undefined'
}
+35
View File
@@ -0,0 +1,35 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
},
animation: {
'fade-in': 'fadeIn 0.3s ease-out',
'slide-up': 'slideUp 0.4s ease-out',
'pulse-ring': 'pulseRing 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0', transform: 'translateY(4px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
slideUp: {
'0%': { opacity: '0', transform: 'translateY(12px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
pulseRing: {
'0%, 100%': { boxShadow: '0 0 0 0 rgba(99, 102, 241, 0)' },
'50%': { boxShadow: '0 0 0 8px rgba(99, 102, 241, 0.15)' },
},
},
},
},
plugins: [],
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/wasm.ts","./src/components/FileList.tsx","./src/components/FileRow.tsx","./src/components/FolderPicker.tsx","./src/components/Header.tsx","./src/components/StatusBar.tsx"],"version":"5.9.3"}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
},
build: {
outDir: '../cmd/server/static/dist',
emptyOutDir: true,
},
})