Simplify custom world result editing controls
This commit is contained in:
@@ -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="连续性参考"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user