Simplify custom world result editing controls

This commit is contained in:
2026-04-08 19:07:46 +08:00
parent bd9fdcbe31
commit a02f7b6414
125 changed files with 8804 additions and 1462 deletions

View File

@@ -10,8 +10,14 @@ import {
Upload,
Wrench,
} from 'lucide-react';
import { type ChangeEvent, useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
buildAnimationClipFromVideoSource,
GENERATED_FRAME_HEIGHT,
GENERATED_FRAME_WIDTH,
} from '../components/preset-editor/characterAssetStudioModel';
import { generateCharacterAnimationDraft } from '../components/preset-editor/characterAssetStudioPersistence';
import {
NumberField,
SelectField,
@@ -19,22 +25,24 @@ import {
TextField,
} from '../editor/shared/FormFields';
import { SectionCard } from '../editor/shared/SectionCard';
import { AnimationState } from '../types';
import {
buildDefaultFrameOrder,
buildMasterNegativePrompt,
buildMasterPrompt,
buildOrderedActiveFrameSources,
buildPlayableCharacterStyleReferenceBoard,
buildRepairNegativePrompt,
buildRepairPrompt,
buildSheetNegativePrompt,
buildSheetPrompt,
buildVideoActionPrompt,
composeSpriteSheetFromFrames,
DEFAULT_MASTER_NEGATIVE_PROMPT,
DEFAULT_REPAIR_NEGATIVE_PROMPT,
DEFAULT_SHEET_NEGATIVE_PROMPT,
extractSpriteFrame,
getActionTemplateById,
moveFrameOrderItem,
QWEN_SPRITE_ACTION_TEMPLATES,
type QwenSpriteActionTemplateId,
readFileAsDataUrl,
replaceSpriteFrame,
restoreAllFrames,
sliceSpriteSheetFrames,
@@ -53,6 +61,31 @@ import {
const MODEL_OPTIONS = [
{ label: 'Qwen-Image-2.0', value: 'qwen-image-2.0' },
];
const ACTION_GENERATION_MODE_OPTIONS = [
{ label: '方案一:直接生成精灵表', value: 'direct-sheet' },
{ label: '方案二:图生视频后抽帧', value: 'image-to-video' },
];
const FIXED_IMAGE_TO_VIDEO_MODEL = 'wan2.2-kf2v-flash';
const FIXED_IMAGE_TO_VIDEO_RESOLUTION = '480P';
const FIXED_IMAGE_TO_VIDEO_DURATION_SECONDS = 5;
function mapActionTemplateIdToAnimationState(
actionTemplateId: QwenSpriteActionTemplateId,
) {
switch (actionTemplateId) {
case 'run':
return AnimationState.RUN;
case 'attack_slash':
return AnimationState.ATTACK;
case 'hurt':
return AnimationState.HURT;
case 'die':
return AnimationState.DIE;
case 'idle':
default:
return AnimationState.IDLE;
}
}
function StatusNote({ title, message }: { title: string; message: string }) {
return (
@@ -116,21 +149,10 @@ export default function QwenSpriteSheetTool() {
const [characterBrief, setCharacterBrief] = useState(
'Q版大头身少女冒险者头部占比更大约 2 到 3 头身,金棕色头发,明亮表情,轻甲或冒险服,动作角色轮廓清楚。',
);
const [masterPromptText, setMasterPromptText] = useState(() =>
buildMasterPrompt(
'Q版大头身少女冒险者头部占比更大约 2 到 3 头身,金棕色头发,明亮表情,轻甲或冒险服,动作角色轮廓清楚。',
),
);
const [masterNegativePrompt, setMasterNegativePrompt] = useState(
DEFAULT_MASTER_NEGATIVE_PROMPT,
);
const [masterModel, setMasterModel] = useState('qwen-image-2.0');
const [masterSize, setMasterSize] = useState('1024*1024');
const [masterCandidateCount, setMasterCandidateCount] = useState(2);
const [masterSeed, setMasterSeed] = useState(1101);
const [masterPromptExtend, setMasterPromptExtend] = useState(true);
const [masterReferenceImages, setMasterReferenceImages] = useState<string[]>([]);
const [styleReferenceBoardSource, setStyleReferenceBoardSource] = useState('');
const masterCandidateCount = 2;
const masterSeed = 1101;
const masterPromptExtend = true;
const [masterDrafts, setMasterDrafts] = useState<QwenSpriteImageDraft[]>([]);
const [selectedMasterDraftId, setSelectedMasterDraftId] = useState('');
const [selectedMasterSource, setSelectedMasterSource] = useState('');
@@ -140,24 +162,22 @@ export default function QwenSpriteSheetTool() {
const [actionTemplateId, setActionTemplateId] =
useState<QwenSpriteActionTemplateId>('idle');
const [actionKey, setActionKey] = useState('idle');
const [sheetPromptText, setSheetPromptText] = useState('');
const [sheetNegativePrompt, setSheetNegativePrompt] = useState(
DEFAULT_SHEET_NEGATIVE_PROMPT,
const [actionGenerationMode, setActionGenerationMode] = useState<
'direct-sheet' | 'image-to-video'
>('image-to-video');
const [actionDetailText, setActionDetailText] = useState(
'动作清晰,幅度明确,适合后续抽帧成横版游戏精灵表。',
);
const [sheetModel, setSheetModel] = useState('qwen-image-2.0');
const [sheetSize, setSheetSize] = useState('1024*1024');
const [sheetCandidateCount, setSheetCandidateCount] = useState(2);
const [sheetSeed, setSheetSeed] = useState(2101);
const [sheetPromptExtend, setSheetPromptExtend] = useState(false);
const [sheetExtraDirection, setSheetExtraDirection] = useState(
'每格边界清晰,背景纯浅色,适合后续切帧。',
);
const [poseBoardSource, setPoseBoardSource] = useState('');
const [sheetDrafts, setSheetDrafts] = useState<QwenSpriteImageDraft[]>([]);
const [selectedSheetDraftId, setSelectedSheetDraftId] = useState('');
const [editedSheetSource, setEditedSheetSource] = useState('');
const [sheetStatus, setSheetStatus] = useState<string | null>(null);
const [isGeneratingSheet, setIsGeneratingSheet] = useState(false);
const [actionVideoPreviewPath, setActionVideoPreviewPath] = useState('');
const [useChromaKey, setUseChromaKey] = useState(true);
const [frameDataUrls, setFrameDataUrls] = useState<string[]>([]);
const [frameWidth, setFrameWidth] = useState(0);
@@ -172,10 +192,6 @@ export default function QwenSpriteSheetTool() {
const [repairIssueText, setRepairIssueText] = useState(
'修复手脚、武器和朝向,使这一帧与相邻帧连续。',
);
const [repairPromptText, setRepairPromptText] = useState('');
const [repairNegativePrompt, setRepairNegativePrompt] = useState(
DEFAULT_REPAIR_NEGATIVE_PROMPT,
);
const [repairModel, setRepairModel] = useState('qwen-image-2.0');
const [repairSeed, setRepairSeed] = useState(3101);
const [repairPromptExtend, setRepairPromptExtend] = useState(false);
@@ -198,6 +214,58 @@ export default function QwenSpriteSheetTool() {
() => getActionTemplateById(actionTemplateId),
[actionTemplateId],
);
const masterPromptText = useMemo(
() => buildMasterPrompt(characterBrief),
[characterBrief],
);
const masterNegativePrompt = useMemo(
() => buildMasterNegativePrompt(characterBrief),
[characterBrief],
);
const masterModel = 'qwen-image-2.0';
const masterSize = '1024*1024';
const sheetNegativePrompt = useMemo(
() => buildSheetNegativePrompt(characterBrief),
[characterBrief],
);
const repairNegativePrompt = useMemo(
() => buildRepairNegativePrompt(characterBrief),
[characterBrief],
);
const sheetModel = 'qwen-image-2.0';
const sheetSize = '1024*1024';
const resolvedActionAnimationState = useMemo(
() => mapActionTemplateIdToAnimationState(actionTemplateId),
[actionTemplateId],
);
const sheetPromptText = useMemo(
() =>
buildSheetPrompt({
characterBrief,
actionTemplate,
extraDirection: `动作细节描述:${actionDetailText.trim()}`,
}),
[actionDetailText, actionTemplate, characterBrief],
);
const repairPromptText = useMemo(
() =>
buildRepairPrompt({
issueText: repairIssueText,
useNeighborLabel:
repairNeighborDirection === 'previous' ? '上一帧' : '下一帧',
}),
[repairIssueText, repairNeighborDirection],
);
const videoActionPrompt = useMemo(
() =>
buildVideoActionPrompt({
actionTemplate,
actionDetailText,
characterBrief,
useChromaKey,
}),
[actionDetailText, actionTemplate, characterBrief, useChromaKey],
);
const previewFrames = useMemo(
() => buildOrderedActiveFrameSources(frameDataUrls, frameOrder, activeFrames),
[activeFrames, frameDataUrls, frameOrder],
@@ -208,6 +276,32 @@ export default function QwenSpriteSheetTool() {
setFps(actionTemplate.defaultFps);
}, [actionTemplate]);
useEffect(() => {
let isMounted = true;
void buildPlayableCharacterStyleReferenceBoard()
.then((nextBoard) => {
if (!isMounted) {
return;
}
setStyleReferenceBoardSource(nextBoard);
})
.catch((error) => {
if (!isMounted) {
return;
}
setMasterStatus(
error instanceof Error
? error.message
: '构建默认风格参考板失败。',
);
});
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
editorStateShapeRef.current = {
activeLength: activeFrames.length,
@@ -215,34 +309,6 @@ export default function QwenSpriteSheetTool() {
};
}, [activeFrames.length, frameOrder.length]);
useEffect(() => {
if (!sheetPromptText.trim()) {
setSheetPromptText(
buildSheetPrompt({
characterBrief,
actionTemplate,
extraDirection: sheetExtraDirection,
}),
);
}
}, [actionTemplate, characterBrief, sheetExtraDirection, sheetPromptText]);
useEffect(() => {
if (!repairPromptText.trim()) {
setRepairPromptText(
buildRepairPrompt({
issueText: repairIssueText,
useNeighborLabel:
repairNeighborDirection === 'previous' ? '上一帧' : '下一帧',
}),
);
}
}, [
repairIssueText,
repairNeighborDirection,
repairPromptText,
]);
useEffect(() => {
if (!editedSheetSource) {
setFrameDataUrls([]);
@@ -303,40 +369,6 @@ export default function QwenSpriteSheetTool() {
return () => window.clearInterval(intervalId);
}, [fps, isPreviewPlaying, previewFrames]);
const handleMasterReferenceUpload = async (
event: ChangeEvent<HTMLInputElement>,
) => {
const fileList = event.target.files;
if (!fileList || fileList.length === 0) {
return;
}
const uploaded = await Promise.all(
Array.from(fileList)
.slice(0, 3)
.map((file) => readFileAsDataUrl(file)),
);
setMasterReferenceImages(uploaded);
if (!selectedMasterSource && uploaded[0]) {
setSelectedMasterSource(uploaded[0]);
}
setMasterStatus(`已载入 ${uploaded.length} 张参考图。`);
event.target.value = '';
};
const handlePoseBoardUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const fileList = event.target.files;
if (!fileList || fileList.length === 0) {
return;
}
const firstFile = fileList[0];
if (!firstFile) {
return;
}
setPoseBoardSource(await readFileAsDataUrl(firstFile));
setSheetStatus('已载入动作参考板。');
event.target.value = '';
};
const handleGenerateMaster = async () => {
setIsGeneratingMaster(true);
setMasterStatus(null);
@@ -349,7 +381,9 @@ export default function QwenSpriteSheetTool() {
promptExtend: masterPromptExtend,
candidateCount: masterCandidateCount,
seed: masterSeed > 0 ? masterSeed : undefined,
referenceImages: masterReferenceImages,
referenceImages: styleReferenceBoardSource
? [styleReferenceBoardSource]
: [],
});
setMasterDrafts(result.drafts);
setSelectedMasterDraftId(result.drafts[0]?.id ?? '');
@@ -370,25 +404,95 @@ export default function QwenSpriteSheetTool() {
setIsGeneratingSheet(true);
setSheetStatus(null);
try {
const result = await generateQwenSpriteSheet({
promptText: sheetPromptText,
negativePrompt: sheetNegativePrompt,
model: sheetModel,
size: sheetSize,
promptExtend: sheetPromptExtend,
candidateCount: sheetCandidateCount,
seed: sheetSeed > 0 ? sheetSeed : undefined,
referenceImages: [
selectedMasterSource,
...(poseBoardSource ? [poseBoardSource] : []),
],
});
setSheetDrafts(result.drafts);
setSelectedSheetDraftId(result.drafts[0]?.id ?? '');
setEditedSheetSource(result.drafts[0]?.imageSrc ?? '');
setSheetStatus(`已生成 ${result.drafts.length} 张精灵表候选。`);
if (actionGenerationMode === 'image-to-video') {
const result = await generateCharacterAnimationDraft({
characterId: assetKey || 'qwen-sprite-tool',
strategy: 'image-to-video',
animation: actionKey,
promptText: videoActionPrompt,
visualSource: selectedMasterSource,
referenceImageDataUrls: [],
referenceVideoDataUrls: [],
frameCount: 16,
fps: actionTemplate.defaultFps,
durationSeconds: FIXED_IMAGE_TO_VIDEO_DURATION_SECONDS,
loop: actionTemplate.loop,
useChromaKey,
resolution: FIXED_IMAGE_TO_VIDEO_RESOLUTION,
imageSequenceModel: 'wan2.7-image-pro',
videoModel: FIXED_IMAGE_TO_VIDEO_MODEL,
referenceVideoModel: 'wan2.7-r2v',
motionTransferModel: 'wan2.2-animate-move',
});
if (result.strategy !== 'image-to-video') {
throw new Error('图生视频接口返回了非预期结果。');
}
const clip = await buildAnimationClipFromVideoSource(
result.previewVideoPath,
{
animation: resolvedActionAnimationState,
fps: actionTemplate.defaultFps,
loop: actionTemplate.loop,
frameCount: 16,
frameWidth: GENERATED_FRAME_WIDTH,
frameHeight: GENERATED_FRAME_HEIGHT,
applyChromaKey: useChromaKey,
},
);
const composedSheet = await composeSpriteSheetFromFrames(clip.frames, {
cols: 4,
rows: 4,
frameWidth: clip.frameWidth,
frameHeight: clip.frameHeight,
padToGrid: true,
});
const videoDraftId = `${assetKey || 'qwen-sprite'}-video-${Date.now()}`;
setActionVideoPreviewPath(result.previewVideoPath);
setSheetDrafts([
{
id: videoDraftId,
label: '图生视频抽帧结果',
imageSrc: composedSheet.dataUrl,
},
]);
setSelectedSheetDraftId(videoDraftId);
setEditedSheetSource(composedSheet.dataUrl);
setFps(actionTemplate.defaultFps);
setSheetStatus(
'图生视频已生成,并已自动抽帧为 16 帧精灵表草稿。',
);
} else {
const result = await generateQwenSpriteSheet({
promptText: sheetPromptText,
negativePrompt: sheetNegativePrompt,
model: sheetModel,
size: sheetSize,
promptExtend: sheetPromptExtend,
candidateCount: sheetCandidateCount,
seed: sheetSeed > 0 ? sheetSeed : undefined,
referenceImages: [selectedMasterSource],
});
setActionVideoPreviewPath('');
setSheetDrafts(result.drafts);
setSelectedSheetDraftId(result.drafts[0]?.id ?? '');
setEditedSheetSource(result.drafts[0]?.imageSrc ?? '');
setSheetStatus(`已生成 ${result.drafts.length} 张精灵表候选。`);
}
} catch (error) {
setSheetStatus(error instanceof Error ? error.message : '生成精灵表失败。');
const rawMessage =
error instanceof Error ? error.message : '生成精灵表失败。';
if (
actionGenerationMode === 'image-to-video' &&
/DataInspectionFailed/u.test(rawMessage)
) {
setSheetStatus(
'图生视频请求已到达模型侧,但触发了供应商内容审查。建议把动作细节描述改得更中性一些,减少暴力/未成年感/敏感词,再试一次;也可以临时切回“直接生成精灵表”方案。',
);
} else {
setSheetStatus(rawMessage);
}
} finally {
setIsGeneratingSheet(false);
}
@@ -488,6 +592,7 @@ export default function QwenSpriteSheetTool() {
sheetSource: composedSheet.dataUrl,
framesDataUrls: orderedActiveFrameSources,
metadata: {
generationMode: actionGenerationMode,
rows: composedSheet.rows,
cols: composedSheet.cols,
sourceFrameCount: frameDataUrls.length,
@@ -499,6 +604,8 @@ export default function QwenSpriteSheetTool() {
activeFrames,
frameOrder,
actionTemplateId,
actionDetailText,
previewVideoPath: actionVideoPreviewPath || undefined,
},
prompts: {
characterBrief,
@@ -506,6 +613,7 @@ export default function QwenSpriteSheetTool() {
masterNegativePrompt,
sheetPromptText,
sheetNegativePrompt,
videoActionPrompt,
repairPromptText,
repairNegativePrompt,
},
@@ -539,67 +647,39 @@ export default function QwenSpriteSheetTool() {
<SectionCard title="基础信息" description="保存时会落到 public/generated-qwen-sprites。">
<div className="space-y-4">
<TextField label="资产标识" value={assetKey} onChange={setAssetKey} />
<TextField label="动作标识" value={actionKey} onChange={setActionKey} />
<TextAreaField
label="角色描述"
value={characterBrief}
onChange={setCharacterBrief}
rows={4}
/>
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
{actionKey}
</div>
</div>
</SectionCard>
<SectionCard title="阶段 A主图" description="先产出一张适合做动作的标准角色图。">
<SectionCard title="阶段 A主图" description="这里只输入角色描述。工具会自动绑定项目内可扮演角色的像素风样式参考,并强制使用身体朝右的动作角色视角。">
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => setMasterPromptText(buildMasterPrompt(characterBrief))}
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-100 transition hover:bg-white/10"
>
<Sparkles className="h-4 w-4" />
<span></span>
</button>
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-100 transition hover:bg-white/10">
<Upload className="h-4 w-4" />
<span></span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
multiple
onChange={handleMasterReferenceUpload}
className="hidden"
/>
</label>
</div>
<TextAreaField label="主图提示词" value={masterPromptText} onChange={setMasterPromptText} rows={8} />
<TextAreaField
label="主图负向提示词"
value={masterNegativePrompt}
onChange={setMasterNegativePrompt}
rows={4}
label="角色描述与设定"
value={characterBrief}
onChange={setCharacterBrief}
rows={5}
placeholder="例如Q版大头身少女剑士头部占比更大明亮表情双刃或短剑轻装冒险服。"
/>
<SelectField label="模型" value={masterModel} onChange={setMasterModel} options={MODEL_OPTIONS} />
<SelectField
label="尺寸"
value={masterSize}
onChange={setMasterSize}
options={[
{ label: '1024 × 1024', value: '1024*1024' },
{ label: '1536 × 1536', value: '1536*1536' },
]}
/>
<NumberField label="候选数量" value={masterCandidateCount} onChange={setMasterCandidateCount} min={1} />
<NumberField label="种子" value={masterSeed} onChange={setMasterSeed} min={0} />
<label className="flex items-center gap-3 rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-200">
<input
type="checkbox"
checked={masterPromptExtend}
onChange={(event) => setMasterPromptExtend(event.target.checked)}
className="h-4 w-4 rounded border-white/20 bg-black/40"
/>
<span> prompt_extend</span>
</label>
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-relaxed text-zinc-400">
A Q版大头身
</div>
{styleReferenceBoardSource && (
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black/20 p-3">
<div className="mb-2 text-xs uppercase tracking-[0.2em] text-zinc-400">
</div>
<div className="flex h-[220px] items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-[linear-gradient(180deg,#171b23,#0d1117)] p-2">
<img
src={styleReferenceBoardSource}
alt="自动风格参考板"
className="h-full w-full object-contain pixelated"
style={{ imageRendering: 'pixelated' }}
/>
</div>
</div>
)}
<button
type="button"
onClick={() => void handleGenerateMaster()}
@@ -627,8 +707,18 @@ export default function QwenSpriteSheetTool() {
</div>
</SectionCard>
<SectionCard title="阶段 B精灵表" description="把动作翻成模板卡,再生成整张 4x4 / 16 帧。">
<SectionCard title="阶段 B动作生成" description="这里只选择动作类型并填写动作细节描述。工具会自动在底层拼接视频/精灵表提示词。">
<div className="space-y-4">
<SelectField
label="生成方案"
value={actionGenerationMode}
onChange={(value) =>
setActionGenerationMode(
value as 'direct-sheet' | 'image-to-video',
)
}
options={ACTION_GENERATION_MODE_OPTIONS}
/>
<SelectField
label="动作模板"
value={actionTemplateId}
@@ -641,63 +731,81 @@ export default function QwenSpriteSheetTool() {
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-relaxed text-zinc-400">
{actionTemplate.label} / {actionTemplate.defaultFps} FPS / {actionTemplate.loop ? '循环动作' : '一次性动作'}
</div>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() =>
setSheetPromptText(
buildSheetPrompt({
characterBrief,
actionTemplate,
extraDirection: sheetExtraDirection,
}),
)
}
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-100 transition hover:bg-white/10"
>
<Sparkles className="h-4 w-4" />
<span></span>
</button>
<label className="inline-flex cursor-pointer items-center gap-2 rounded-lg border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-100 transition hover:bg-white/10">
<Upload className="h-4 w-4" />
<span></span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handlePoseBoardUpload}
className="hidden"
/>
</label>
</div>
<TextAreaField label="精灵表提示词" value={sheetPromptText} onChange={setSheetPromptText} rows={8} />
<TextAreaField label="附加动作要求" value={sheetExtraDirection} onChange={setSheetExtraDirection} rows={3} />
<TextAreaField
label="精灵表负向提示词"
value={sheetNegativePrompt}
onChange={setSheetNegativePrompt}
label="动作细节描述"
value={actionDetailText}
onChange={setActionDetailText}
rows={4}
placeholder="例如:先轻微下压蓄力,再迅速前踏横斩,收招干净,动作利落。"
/>
<SelectField label="模型" value={sheetModel} onChange={setSheetModel} options={MODEL_OPTIONS} />
<SelectField
label="尺寸"
value={sheetSize}
onChange={setSheetSize}
options={[
{ label: '1024 × 1024', value: '1024*1024' },
{ label: '1536 × 1536', value: '1536*1536' },
]}
<TextAreaField
label={
actionGenerationMode === 'image-to-video'
? '底层视频提示词预览(自动生成)'
: '底层精灵表提示词预览(自动生成)'
}
value={
actionGenerationMode === 'image-to-video'
? videoActionPrompt
: sheetPromptText
}
onChange={() => {}}
rows={6}
disabled
/>
<NumberField label="候选数量" value={sheetCandidateCount} onChange={setSheetCandidateCount} min={1} />
<NumberField label="种子" value={sheetSeed} onChange={setSheetSeed} min={0} />
<label className="flex items-center gap-3 rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-200">
<input
type="checkbox"
checked={sheetPromptExtend}
onChange={(event) => setSheetPromptExtend(event.target.checked)}
className="h-4 w-4 rounded border-white/20 bg-black/40"
/>
<span> prompt_extend</span>
</label>
{actionGenerationMode === 'image-to-video' ? (
<>
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-relaxed text-zinc-400">
`wan2.2-kf2v-flash` `480P` 16
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
<div className="text-xs uppercase tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-1">{FIXED_IMAGE_TO_VIDEO_MODEL}</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
<div className="text-xs uppercase tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-1">{FIXED_IMAGE_TO_VIDEO_RESOLUTION}</div>
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
<div className="text-xs uppercase tracking-[0.18em] text-zinc-500">
</div>
<div className="mt-1">{FIXED_IMAGE_TO_VIDEO_DURATION_SECONDS} </div>
</div>
<label className="flex items-center gap-3 rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-200">
<input
type="checkbox"
checked={useChromaKey}
onChange={(event) => setUseChromaKey(event.target.checked)}
className="h-4 w-4 rounded border-white/20 bg-black/40"
/>
<span>绿便</span>
</label>
</>
) : (
<>
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-relaxed text-zinc-400">
</div>
<NumberField label="候选数量" value={sheetCandidateCount} onChange={setSheetCandidateCount} min={1} />
<NumberField label="种子" value={sheetSeed} onChange={setSheetSeed} min={0} />
<label className="flex items-center gap-3 rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-200">
<input
type="checkbox"
checked={sheetPromptExtend}
onChange={(event) => setSheetPromptExtend(event.target.checked)}
className="h-4 w-4 rounded border-white/20 bg-black/40"
/>
<span> prompt_extend</span>
</label>
</>
)}
<button
type="button"
onClick={() => void handleGenerateSheet()}
@@ -705,22 +813,14 @@ export default function QwenSpriteSheetTool() {
className="inline-flex w-full items-center justify-center gap-2 rounded-xl bg-emerald-500 px-4 py-3 text-sm font-medium text-black transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-60"
>
<Sparkles className="h-4 w-4" />
<span>{isGeneratingSheet ? '生成中...' : '生成精灵表候选'}</span>
<span>
{isGeneratingSheet
? '生成中...'
: actionGenerationMode === 'image-to-video'
? '生成动作视频并抽帧'
: '生成精灵表候选'}
</span>
</button>
{poseBoardSource && (
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black/20 p-3">
<div className="mb-2 text-xs uppercase tracking-[0.2em] text-zinc-400">
</div>
<div className="flex h-[180px] items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-[linear-gradient(180deg,#171b23,#0d1117)] p-2">
<img
src={poseBoardSource}
alt="动作参考板"
className="h-full w-full object-contain"
/>
</div>
</div>
)}
{sheetStatus && <StatusNote title="精灵表状态" message={sheetStatus} />}
</div>
</SectionCard>
@@ -753,9 +853,21 @@ export default function QwenSpriteSheetTool() {
</SectionCard>
)}
<SectionCard title="阶段 C编辑与修帧">
<SectionCard title="阶段 C视频抽帧结果 / 精灵表编辑">
<div className="grid gap-4 xl:grid-cols-[360px_1fr]">
<div className="space-y-4">
{actionVideoPreviewPath && (
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-3 text-sm font-medium text-white"></div>
<video
src={actionVideoPreviewPath}
controls
loop
muted
className="w-full rounded-xl border border-white/10 bg-black/30"
/>
</div>
)}
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-medium text-white"></div>
@@ -827,6 +939,7 @@ export default function QwenSpriteSheetTool() {
triggerJsonDownload(`${assetKey}-${actionKey}-metadata.json`, {
assetKey,
actionKey,
generationMode: actionGenerationMode,
rows: 4,
cols: 4,
sourceFrameCount: frameDataUrls.length,
@@ -838,6 +951,8 @@ export default function QwenSpriteSheetTool() {
activeFrames,
frameOrder,
actionTemplateId,
actionDetailText,
previewVideoPath: actionVideoPreviewPath || undefined,
})
}
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-100 transition hover:bg-white/10"
@@ -928,29 +1043,14 @@ export default function QwenSpriteSheetTool() {
<span></span>
</div>
<div className="space-y-4">
<button
type="button"
onClick={() =>
setRepairPromptText(
buildRepairPrompt({
issueText: repairIssueText,
useNeighborLabel:
repairNeighborDirection === 'previous' ? '上一帧' : '下一帧',
}),
)
}
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-100 transition hover:bg-white/10"
>
<Sparkles className="h-4 w-4" />
<span></span>
</button>
<TextAreaField label="问题描述" value={repairIssueText} onChange={setRepairIssueText} rows={3} />
<TextAreaField label="修帧提示词" value={repairPromptText} onChange={setRepairPromptText} rows={6} />
<TextAreaField label="修帧提示词(自动生成)" value={repairPromptText} onChange={() => {}} rows={6} disabled />
<TextAreaField
label="修帧负向提示词"
label="修帧负向提示词(固定)"
value={repairNegativePrompt}
onChange={setRepairNegativePrompt}
onChange={() => {}}
rows={4}
disabled
/>
<SelectField
label="连续性参考"

View File

@@ -1,12 +1,17 @@
import {
buildDefaultFrameOrder,
buildMasterNegativePrompt,
buildMasterPrompt,
buildOrderedActiveFrameIndices,
buildOrderedActiveFrameSources,
buildRepairNegativePrompt,
buildRepairPrompt,
buildSheetNegativePrompt,
buildSheetPrompt,
buildVideoActionPrompt,
getActionTemplateById,
moveFrameOrderItem,
PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES,
restoreAllFrames,
toggleActiveFrame,
} from './qwenSpriteSheetToolModel';
@@ -67,6 +72,27 @@ describe('qwenSpriteSheetToolModel', () => {
expect(prompt).toContain('2 到 3 头身');
});
it('strengthens non-human species traits for siren-like characters', () => {
const prompt = buildMasterPrompt('海妖,海洋歌者,蓝绿色鳞片,海妖耳鳍。');
const negativePrompt = buildMasterNegativePrompt(
'海妖,海洋歌者,蓝绿色鳞片,海妖耳鳍。',
);
expect(prompt).toContain('如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色');
expect(prompt).toContain('严格约束身体结构骨架');
expect(prompt).toContain('沿用参考图的人形动作角色身体结构');
expect(negativePrompt).toContain('不要机械地把主题词直接画成完整怪物本体');
});
it('teaches the model how to interpret jellyfish king concepts', () => {
const prompt = buildMasterPrompt('水母国王,半透明伞盖,荧光斑点,权杖。');
expect(prompt).toContain('示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色');
expect(prompt).toContain('水母主题的服装和配饰');
expect(prompt).toContain('水母权杖');
expect(prompt).toContain('而不是完整水母怪物本体');
});
it('builds a repair prompt that keeps chibi ratio', () => {
const prompt = buildRepairPrompt({
issueText: '修复头部和手部比例。',
@@ -76,4 +102,30 @@ describe('qwenSpriteSheetToolModel', () => {
expect(prompt).toContain('上一帧');
expect(prompt).toContain('大头身');
});
it('builds a video action prompt with pixel style constraints', () => {
const prompt = buildVideoActionPrompt({
actionTemplate: getActionTemplateById('run'),
actionDetailText: '跑步时上身前倾,手臂摆动明显。',
characterBrief: '海妖刺客,蓝绿色鳞片,鱼鳍耳。',
useChromaKey: true,
});
expect(prompt).toContain('动作视频');
expect(prompt).toContain('侧身朝右');
expect(prompt).toContain('像素风');
expect(prompt).toContain('绿幕');
expect(prompt).toContain('默认优先生成人形拟人化角色');
expect(prompt).toContain('Q版可爱的人形动作角色');
});
it('builds generic theme over-literalization negatives', () => {
expect(buildSheetNegativePrompt('海妖')).toContain('不要机械地把主题词直接画成完整怪物本体');
expect(buildRepairNegativePrompt('海妖')).toContain('不要机械地把主题词直接画成完整怪物本体');
});
it('contains built-in playable character style reference sources', () => {
expect(PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES.length).toBeGreaterThan(0);
expect(PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES.some((source) => source.includes('Girl Hero 1'))).toBe(true);
});
});

View File

@@ -27,6 +27,28 @@ export const DEFAULT_REPAIR_NEGATIVE_PROMPT =
const CHIBI_STYLE_TEXT =
'Q版大头身动作角色头部占比明显更大约 2 到 3 头身,类似横版像素 RPG 可扮演角色的比例,不要写实长身比例。';
const PIXEL_STYLE_TEXT =
'参考项目内可扮演角色的像素风动作角色画风,整体是像素游戏角色设计方向,身体始终朝右,适合横版动作 sprite 资产。';
const STYLE_REFERENCE_SCOPE_TEXT =
'参考图不仅用于约束像素画风、颜色组织、头身比例、右朝向和镜头语言,还要严格约束身体结构骨架。生成结果必须优先沿用参考图的人形动作角色身体结构,包括头、躯干、双臂、双腿、站姿重心和整体头身比;可以变化发型、服装、主题配饰和材质,但不要脱离参考图的人形身体结构。';
const CONCEPT_INTERPRETATION_TEXT =
'请先拆解设定中的“身份词、主题词、身体结构词”。如果文字设定没有明确要求非人身体结构,默认优先生成人形拟人化角色,让主题词主要体现在服装、头饰、冠冕、权杖、纹样、材质和发光装饰上。';
const HUMANLIKE_PRIORITY_TEXT =
'默认优先使用参考图对应的人类或类人动作角色骨架,保持清楚的头、躯干、手臂和双腿轮廓,这样更适合横版动作 sprite。只有当文字设定明确要求鱼尾、触手身体、伞盖头部、无双腿、漂浮体、凝胶体或其他非人结构时才改为对应非人身体。';
const JELLYFISH_THEME_EXAMPLE_TEXT =
'示例:“水母国王”默认应理解为严格沿用参考图人形身体结构的国王角色,再穿带有水母主题的服装和配饰,例如半透明蓝紫色披肩或裙摆、像水母伞盖的王冠、荧光斑点、海洋质感袖摆、水母权杖,而不是完整水母怪物本体。';
const CONCEPT_HIERARCHY_TEXT =
'视觉优先级应当是:身体结构词第一,身份词第二,主题词第三。没有明确身体结构词时,默认用人形拟人化表现,再把主题词转译成服装和装饰。';
const CHIBI_CHARACTER_TEXT =
'即使角色带有怪物或海洋主题,也优先做成 Q版可爱的人形动作角色方便读图和后续动画化。';
export const PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES = [
'/character/Sword Princess/Original/Hero/idle/Idle01.png',
'/character/Archer Hero/Original/Hero/idle/idle01.png',
'/character/Girl Hero 1/Original/Hero/Idle/Idle01.png',
'/character/Punch Hero 3/Original/Hero/Idle/Idle01.png',
'/character/Fighter 4/original/Hero/idle/idle01.png',
];
export const QWEN_SPRITE_ACTION_TEMPLATES: QwenSpriteActionTemplate[] = [
{
@@ -357,15 +379,55 @@ export async function composeSpriteSheetFromFrames(
};
}
export async function buildPlayableCharacterStyleReferenceBoard(
sources = PLAYABLE_CHARACTER_STYLE_REFERENCE_SOURCES,
) {
const images = await Promise.all(
sources.map((source) => loadImageFromSource(source)),
);
const cols = 3;
const rows = 2;
const cellSize = 320;
const padding = 24;
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
throw new Error('无法创建画布上下文');
}
canvas.width = cols * cellSize + padding * 2;
canvas.height = rows * cellSize + padding * 2;
context.fillStyle = '#f6f0dd';
context.fillRect(0, 0, canvas.width, canvas.height);
context.imageSmoothingEnabled = false;
images.forEach((image, index) => {
const colIndex = index % cols;
const rowIndex = Math.floor(index / cols);
drawContainedImage(context, image, {
x: padding + colIndex * cellSize,
y: padding + rowIndex * cellSize,
width: cellSize,
height: cellSize,
});
});
return canvas.toDataURL('image/png');
}
export function buildMasterPrompt(characterBrief: string) {
return [
'单人,全身,2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,站立待机姿态,脚底完整可见,武器完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
'画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色完整置于画面中央,不要裁切头顶和脚底,不要多角色,不要复杂环境,不要镜头透视,不要特写。',
`风格要求:${CHIBI_STYLE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,武器握持合理,便于后续连续动作生成。`,
'单人2D 横版游戏角色标准设定图,角色始终朝右侧,侧视角为主,主体完整可见,底部轮廓完整,身体比例稳定,轮廓清楚,适合后续制作 sprite sheet 动画。',
'画面要求1:1 正方形画布,纯色浅背景,画面中心构图,角色主体完整置于画面中央,不要裁切主体顶部和底部,不要多角色,不要复杂环境,不要镜头透视,不要特写。',
`风格要求:${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} 高可读性游戏角色设定图,偏像素动画前置设计稿,形体清晰,服装层次明确,道具/权杖/武器如有则存在关系合理,便于后续连续动作生成。`,
CONCEPT_INTERPRETATION_TEXT,
HUMANLIKE_PRIORITY_TEXT,
CONCEPT_HIERARCHY_TEXT,
JELLYFISH_THEME_EXAMPLE_TEXT,
characterBrief.trim(),
]
.filter(Boolean)
.join('\n\n');
.join('\n');
}
export function buildSheetPrompt(options: {
@@ -374,7 +436,11 @@ export function buildSheetPrompt(options: {
extraDirection: string;
}) {
return [
`使用图1作为唯一角色身份参考。生成一张 4x4 的 sprite sheet共 16 帧,展示同一个角色的连续动作。角色始终朝右,全身完整出现在每一个格子里,脚底始终可见,地面线高度基本一致,角色在每一格中的尺度基本一致,镜头固定不变,不要切换景别,不要切换视角,不要左右翻转。${CHIBI_STYLE_TEXT}`,
`使用图1作为风格参考。生成一张 4x4 的 sprite sheet共 16 帧,展示同一个角色的连续动作。角色始终朝右,主体完整出现在每一个格子里,底部轮廓稳定,角色在每一格中的尺度基本一致,镜头固定不变,不要切换景别,不要切换视角,不要左右翻转。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
CONCEPT_INTERPRETATION_TEXT,
HUMANLIKE_PRIORITY_TEXT,
CONCEPT_HIERARCHY_TEXT,
JELLYFISH_THEME_EXAMPLE_TEXT,
`动作名:${options.actionTemplate.label}`,
`是否循环:${options.actionTemplate.loop ? '是' : '否'}`,
`身体位移:${options.actionTemplate.bodyTravel}`,
@@ -394,13 +460,36 @@ export function buildRepairPrompt(options: {
useNeighborLabel: '上一帧' | '下一帧';
}) {
return [
`使用图1作为角色身份与服装武器的唯一标准参考图2的动作连续性修复图3这一个单帧。图2代表${options.useNeighborLabel}`,
`要求输出一张单独的动作帧图片,不要网格,不要背景细节。角色始终朝右,全身完整,脚底位置稳定保持与图2连续并且与图1是同一个角色。${CHIBI_STYLE_TEXT} 修复图3中的错误使这一帧适合插回原来的 sprite sheet 中。`,
`使用图1作为风格参考参考图2的动作连续性修复图3这一个单帧。图2代表${options.useNeighborLabel}`,
`要求输出一张单独的动作帧图片,不要网格,不要背景细节。角色始终朝右,主体完整,底部结构稳定保持与图2连续并且与图1是同一个角色。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT} ${CONCEPT_INTERPRETATION_TEXT} ${HUMANLIKE_PRIORITY_TEXT} ${CONCEPT_HIERARCHY_TEXT} ${JELLYFISH_THEME_EXAMPLE_TEXT} 修复图3中的错误使这一帧适合插回原来的 sprite sheet 中。`,
'保持不变:发型、服装结构、主配色、武器类型、朝向。',
`重点修复:${options.issueText.trim() || '修复手脚畸形、武器错误或朝向不一致问题。'}`,
].join('\n');
}
export function buildVideoActionPrompt(options: {
actionTemplate: QwenSpriteActionTemplate;
actionDetailText: string;
useChromaKey: boolean;
characterBrief: string;
}) {
return [
`单人全身角色动作视频,动作主题是 ${options.actionTemplate.label}`,
`角色固定为图1同一角色始终侧身朝右镜头稳定轮廓清晰。${CHIBI_STYLE_TEXT} ${CHIBI_CHARACTER_TEXT} ${PIXEL_STYLE_TEXT} ${STYLE_REFERENCE_SCOPE_TEXT}`,
CONCEPT_INTERPRETATION_TEXT,
HUMANLIKE_PRIORITY_TEXT,
CONCEPT_HIERARCHY_TEXT,
JELLYFISH_THEME_EXAMPLE_TEXT,
`动作结构:${options.actionTemplate.sequenceLines.join('')}。结尾要求:${options.actionTemplate.ending}`,
options.useChromaKey
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抽帧与抠像。'
: '背景简洁纯净,无复杂场景。',
`动作补充细节:${options.actionDetailText.trim() || '保持动作清晰、节奏明确、适合后续抽帧为 sprite sheet。'}`,
`角色设定:${options.characterBrief.trim()}`,
'目标是后续抽帧为横版动作游戏精灵表,因此不要镜头切换,不要景别变化,不要角色漂移。',
].join(' ');
}
export async function triggerDataUrlDownload(
filename: string,
dataUrl: string,
@@ -435,6 +524,18 @@ export function restoreAllFrames(frameCount: number) {
return buildDefaultFrameOrder(frameCount);
}
export function buildMasterNegativePrompt(_characterBrief: string) {
return `${DEFAULT_MASTER_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体`;
}
export function buildSheetNegativePrompt(_characterBrief: string) {
return `${DEFAULT_SHEET_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体`;
}
export function buildRepairNegativePrompt(_characterBrief: string) {
return `${DEFAULT_REPAIR_NEGATIVE_PROMPT},不要机械地把主题词直接画成完整怪物本体,除非文字设定明确要求非人身体`;
}
export function moveFrameOrderItem(
frameOrder: number[],
frameIndex: number,