Commit 3cd16618 authored by yaoke.yk's avatar yaoke.yk

三视图优化为一张

parent a6819dfe
...@@ -76,7 +76,13 @@ function getCharacterViewUrl(character: Partial<Character>, viewType: CharacterV ...@@ -76,7 +76,13 @@ function getCharacterViewUrl(character: Partial<Character>, viewType: CharacterV
return character.backImageUrl ?? null; return character.backImageUrl ?? null;
} }
function hasCompleteThreeViews(character: Partial<Character>) { /** AI 生成的角色三视图是单张拼图,存在 imageUrl 上 */
function getCharacterSheetUrl(character: Partial<Character>): string | null {
return character.imageUrl ?? null;
}
function hasCharacterImage(character: Partial<Character>) {
if (getCharacterSheetUrl(character)) return true;
return CHARACTER_VIEWS.every((view) => !!getCharacterViewUrl(character, view.type)); return CHARACTER_VIEWS.every((view) => !!getCharacterViewUrl(character, view.type));
} }
...@@ -120,7 +126,7 @@ export function CharacterGeneration() { ...@@ -120,7 +126,7 @@ export function CharacterGeneration() {
supporting: "配角", supporting: "配角",
}; };
const generatedCount = characters.filter((character) => hasCompleteThreeViews(character)).length; const generatedCount = characters.filter((character) => hasCharacterImage(character)).length;
const generatingCount = characters.filter((character) => character.status === "generating").length; const generatingCount = characters.filter((character) => character.status === "generating").length;
const statusBadge = (status: string) => { const statusBadge = (status: string) => {
...@@ -395,7 +401,7 @@ export function CharacterGeneration() { ...@@ -395,7 +401,7 @@ export function CharacterGeneration() {
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div> <div>
<span className="text-xs text-muted-foreground font-medium">角色三视图</span> <span className="text-xs text-muted-foreground font-medium">角色三视图</span>
<p className="text-[11px] text-muted-foreground mt-0.5">正面 + 侧面 + 背面,用于完整展示角色造型</p> <p className="text-[11px] text-muted-foreground mt-0.5">大头像 + 正/侧/背全身拼图,用于角色一致性参考</p>
</div> </div>
<button <button
onClick={() => handleGenerateImage(character)} onClick={() => handleGenerateImage(character)}
...@@ -403,43 +409,56 @@ export function CharacterGeneration() { ...@@ -403,43 +409,56 @@ export function CharacterGeneration() {
className="px-3 py-1.5 rounded-md border border-border bg-card text-foreground hover:bg-muted transition-colors text-xs flex items-center gap-1.5 disabled:opacity-50" className="px-3 py-1.5 rounded-md border border-border bg-card text-foreground hover:bg-muted transition-colors text-xs flex items-center gap-1.5 disabled:opacity-50"
> >
{isGenerating ? <Loader2 className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />} {isGenerating ? <Loader2 className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />}
{hasCompleteThreeViews(character) ? "重新生成三视图" : "AI生成三视图"} {hasCharacterImage(character) ? "重新生成三视图" : "AI生成三视图"}
</button> </button>
</div> </div>
<div className="grid grid-cols-3 gap-3"> {(() => {
{CHARACTER_VIEWS.map((view) => { const sheetUrl = getCharacterSheetUrl(character);
const imageUrl = getCharacterViewUrl(character, view.type); if (sheetUrl) {
return ( return (
<div key={view.type}> <div className="rounded-lg overflow-hidden bg-white border border-border">
<div className="text-[11px] text-muted-foreground mb-1 text-center">{view.label}</div> <img
<div className="aspect-[3/4] rounded-lg overflow-hidden bg-white border border-border"> src={sheetUrl}
{imageUrl ? ( alt={`${character.name}-角色三视图`}
<img src={imageUrl} alt={`${character.name}-${view.label}`} className="w-full h-full object-cover" /> className="w-full h-auto object-contain"
) : ( />
<div className="w-full h-full flex flex-col items-center justify-center gap-2"> </div>
{isGenerating ? ( );
<> }
<Loader2 className="w-6 h-6 text-primary animate-spin" /> return (
<span className="text-xs text-muted-foreground">生成中</span> <div className="grid grid-cols-3 gap-3">
</> {CHARACTER_VIEWS.map((view) => {
const imageUrl = getCharacterViewUrl(character, view.type);
return (
<div key={view.type}>
<div className="text-[11px] text-muted-foreground mb-1 text-center">{view.label}</div>
<div className="aspect-[3/4] rounded-lg overflow-hidden bg-white border border-border">
{imageUrl ? (
<img src={imageUrl} alt={`${character.name}-${view.label}`} className="w-full h-full object-cover" />
) : ( ) : (
<> <div className="w-full h-full flex flex-col items-center justify-center gap-2">
<ImageIcon className="w-6 h-6 text-muted-foreground" /> {isGenerating ? (
<span className="text-xs text-muted-foreground">待补充</span> <>
</> <Loader2 className="w-6 h-6 text-primary animate-spin" />
<span className="text-xs text-muted-foreground">生成中</span>
</>
) : (
<>
<ImageIcon className="w-6 h-6 text-muted-foreground" />
<span className="text-xs text-muted-foreground">待补充</span>
</>
)}
</div>
)} )}
</div> </div>
)} </div>
</div> );
</div> })}
); </div>
})} );
</div> })()}
{character.imagePrompt && (
<p className="mt-3 text-xs text-muted-foreground line-clamp-3">{character.imagePrompt}</p>
)}
</div> </div>
</div> </div>
); );
......
...@@ -10,6 +10,11 @@ public interface SeedreamService { ...@@ -10,6 +10,11 @@ public interface SeedreamService {
*/ */
String generateImage(String prompt); String generateImage(String prompt);
/**
* 文生图:自定义画布尺寸(如 "2048x1152" 用于 16:9 横版角色三视图)
*/
String generateImage(String prompt, String size);
/** 当前使用的 Seedream 模型 ID,用于计费明细 */ /** 当前使用的 Seedream 模型 ID,用于计费明细 */
String getModelId(); String getModelId();
......
...@@ -36,11 +36,16 @@ public class SeedreamServiceImpl implements SeedreamService { ...@@ -36,11 +36,16 @@ public class SeedreamServiceImpl implements SeedreamService {
@Override @Override
public String generateImage(String prompt) { public String generateImage(String prompt) {
return generateImage(prompt, "2048x2048");
}
@Override
public String generateImage(String prompt, String size) {
Map<String, Object> body = Map.of( Map<String, Object> body = Map.of(
"model", properties.getImageModel(), "model", properties.getImageModel(),
"prompt", prompt, "prompt", prompt,
"n", 1, "n", 1,
"size", "2048x2048", "size", size,
"response_format", "url" "response_format", "url"
); );
......
...@@ -42,9 +42,12 @@ public class VideoTaskController { ...@@ -42,9 +42,12 @@ public class VideoTaskController {
} }
List<String> characterKeys = req.getCharacterImageKeys(); List<String> characterKeys = req.getCharacterImageKeys();
if ((characterKeys == null || characterKeys.isEmpty()) boolean hasCharacter = (characterKeys != null && !characterKeys.isEmpty())
&& (req.getCharacterImageKey() == null || req.getCharacterImageKey().isBlank())) { || (req.getCharacterImageKey() != null && !req.getCharacterImageKey().isBlank());
throw new BizException(ErrorCode.INVALID_PARAM, "请至少选择一个出镜角色"); boolean hasScene = req.getSceneImageKey() != null && !req.getSceneImageKey().isBlank();
boolean hasProp = req.getPropImageKeys() != null && !req.getPropImageKeys().isEmpty();
if (!hasCharacter && !hasScene && !hasProp) {
throw new BizException(ErrorCode.INVALID_PARAM, "请至少选择一张参考图(角色/场景/道具任一)");
} }
Long tenantId = TenantContext.get(); Long tenantId = TenantContext.get();
......
...@@ -38,7 +38,6 @@ import java.util.ArrayList; ...@@ -38,7 +38,6 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CompletableFuture;
@Slf4j @Slf4j
@Service @Service
...@@ -215,7 +214,10 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -215,7 +214,10 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
Project project = loadProject(character.getProjectId(), tenantId); Project project = loadProject(character.getProjectId(), tenantId);
ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project); ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project);
if (character.getImagePrompt() == null || character.getImagePrompt().isBlank()) { if (character.getImagePrompt() == null || character.getImagePrompt().isBlank()) {
throw new BizException(ErrorCode.INVALID_PARAM, "角色缺少图片 Prompt"); character.setImagePrompt(buildCharacterImagePromptFallback(character));
}
if (character.getImagePrompt() == null || character.getImagePrompt().isBlank()) {
throw new BizException(ErrorCode.INVALID_PARAM, "角色缺少图片 Prompt,且无法从角色资料推导");
} }
billingService.checkBalance(BillingChargeRequest.builder() billingService.checkBalance(BillingChargeRequest.builder()
...@@ -227,8 +229,8 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -227,8 +229,8 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
.modelProvider(seedreamService.getProvider()) .modelProvider(seedreamService.getProvider())
.modelId(seedreamService.getModelId()) .modelId(seedreamService.getModelId())
.modelName(seedreamService.getModelName()) .modelName(seedreamService.getModelName())
.unitCount(3) .unitCount(1)
.meta(Map.of("views", List.of("front", "side", "back"))) .meta(Map.of("layout", "sheet"))
.refId(String.valueOf(characterId)) .refId(String.valueOf(characterId))
.build()); .build());
...@@ -236,18 +238,31 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -236,18 +238,31 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
characterMapper.updateById(character); characterMapper.updateById(character);
try { try {
CompletableFuture<CharacterViewAsset> frontFuture = String prompt = buildCharacterSheetPrompt(character.getImagePrompt(), visualStyle);
CompletableFuture.supplyAsync(() -> generateCharacterViewAsset(character, visualStyle, "front")); // 角色三视图采用 16:9 横版画布,承载“大头像 + 正/侧/背 全身”一排四联布局
CompletableFuture<CharacterViewAsset> sideFuture = // 注意:Seedream 要求像素数 ≥ 3,686,400,2560x1440 = 3,686,400 恰好满足且为标准 16:9
CompletableFuture.supplyAsync(() -> generateCharacterViewAsset(character, visualStyle, "side")); String generatedUrl = seedreamService.generateImage(prompt, "2560x1440");
CompletableFuture<CharacterViewAsset> backFuture = byte[] bytes = downloadBytes(generatedUrl);
CompletableFuture.supplyAsync(() -> generateCharacterViewAsset(character, visualStyle, "back")); String key = TosService.buildKey(
character.getTenantId(),
CompletableFuture.allOf(frontFuture, sideFuture, backFuture).join(); character.getProjectId(),
"characters/" + character.getId(),
"sheet.jpg"
);
tosService.upload(key, new ByteArrayInputStream(bytes), bytes.length, "image/jpeg");
applyCharacterView(character, frontFuture.join().viewType(), frontFuture.join().key()); String publicUrl = tosService.publicUrl(key);
applyCharacterView(character, sideFuture.join().viewType(), sideFuture.join().key()); // 拼图同时写入 imageUrl 与 frontImage* 字段:
applyCharacterView(character, backFuture.join().viewType(), backFuture.join().key()); // - imageUrl 是卡片主图
// - frontImage* 是 storyboard 引用兜底(StoryboardWorkspace.tsx 用到 frontImageUrl)
character.setImageUrl(publicUrl);
character.setImageTosKey(key);
character.setFrontImageUrl(publicUrl);
character.setFrontImageTosKey(key);
character.setSideImageUrl(null);
character.setSideImageTosKey(null);
character.setBackImageUrl(null);
character.setBackImageTosKey(null);
character.setStatus("ready"); character.setStatus("ready");
characterMapper.updateById(character); characterMapper.updateById(character);
...@@ -260,8 +275,8 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -260,8 +275,8 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
.modelProvider(seedreamService.getProvider()) .modelProvider(seedreamService.getProvider())
.modelId(seedreamService.getModelId()) .modelId(seedreamService.getModelId())
.modelName(seedreamService.getModelName()) .modelName(seedreamService.getModelName())
.unitCount(3) .unitCount(1)
.meta(Map.of("views", List.of("front", "side", "back"))) .meta(Map.of("layout", "sheet"))
.refId(String.valueOf(characterId)) .refId(String.valueOf(characterId))
.build()); .build());
return character; return character;
...@@ -351,6 +366,9 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -351,6 +366,9 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
@Override @Override
public Character saveCharacter(Character character) { public Character saveCharacter(Character character) {
if (character.getImagePrompt() == null || character.getImagePrompt().isBlank()) {
character.setImagePrompt(buildCharacterImagePromptFallback(character));
}
if (character.getId() == null) { if (character.getId() == null) {
characterMapper.insert(character); characterMapper.insert(character);
} else { } else {
...@@ -359,6 +377,36 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -359,6 +377,36 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
return character; return character;
} }
private String buildCharacterImagePromptFallback(Character character) {
if (character == null) {
return "";
}
List<String> parts = new ArrayList<>();
appendIfPresent(parts, character.getName());
if (character.getGender() != null) {
String gender = character.getGender().trim().toLowerCase(Locale.ROOT);
if ("male".equals(gender)) {
parts.add("male character");
} else if ("female".equals(gender)) {
parts.add("female character");
}
}
appendIfPresent(parts, character.getAge());
appendIfPresent(parts, character.getPersonality());
appendIfPresent(parts, character.getCostume());
appendIfPresent(parts, character.getVisualHint());
if (parts.isEmpty()) {
return "";
}
return String.join(", ", parts);
}
private void appendIfPresent(List<String> parts, String value) {
if (value != null && !value.isBlank()) {
parts.add(value.trim());
}
}
@Override @Override
public Scene saveScene(Scene scene) { public Scene saveScene(Scene scene) {
if (scene.getId() != null) { if (scene.getId() != null) {
...@@ -477,30 +525,6 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -477,30 +525,6 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
return character; return character;
} }
private CharacterViewAsset generateCharacterViewAsset(
Character character,
ProjectVisualStyle visualStyle,
String viewType
) {
try {
String prompt = buildCharacterViewPrompt(character.getImagePrompt(), visualStyle, viewType);
String generatedUrl = seedreamService.generateImage(prompt);
byte[] bytes = downloadBytes(generatedUrl);
String key = TosService.buildKey(
character.getTenantId(),
character.getProjectId(),
"characters/" + character.getId(),
viewType + ".jpg"
);
tosService.upload(key, new ByteArrayInputStream(bytes), bytes.length, "image/jpeg");
return new CharacterViewAsset(viewType, key);
} catch (BizException e) {
throw e;
} catch (Exception e) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "角色" + viewType + "视图生成失败: " + e.getMessage());
}
}
private void applyCharacterView(Character character, String viewType, String key) { private void applyCharacterView(Character character, String viewType, String key) {
String publicUrl = tosService.publicUrl(key); String publicUrl = tosService.publicUrl(key);
switch (normalizeCharacterViewType(viewType)) { switch (normalizeCharacterViewType(viewType)) {
...@@ -522,14 +546,7 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -522,14 +546,7 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
} }
} }
private String buildCharacterViewPrompt(String basePrompt, ProjectVisualStyle visualStyle, String viewType) { private String buildCharacterSheetPrompt(String basePrompt, ProjectVisualStyle visualStyle) {
String angleInstruction = switch (normalizeCharacterViewType(viewType)) {
case "front" -> "front view, full body, facing camera";
case "side" -> "left side profile view, full body";
case "back" -> "back view, full body, facing away";
default -> throw new BizException(ErrorCode.INVALID_PARAM, "不支持的角色视图: " + viewType);
};
List<String> promptParts = new ArrayList<>(); List<String> promptParts = new ArrayList<>();
String normalizedPrompt = basePrompt == null ? "" : basePrompt.trim(); String normalizedPrompt = basePrompt == null ? "" : basePrompt.trim();
if (!normalizedPrompt.isBlank()) { if (!normalizedPrompt.isBlank()) {
...@@ -537,15 +554,36 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -537,15 +554,36 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
} }
promptParts.add(visualStyle.renderGuidance()); promptParts.add(visualStyle.renderGuidance());
promptParts.addAll(buildStyleSpecificPromptParts(visualStyle)); promptParts.addAll(buildStyleSpecificPromptParts(visualStyle));
promptParts.add("neutral standing pose");
promptParts.add("consistent costume and hairstyle across all views"); // 单行四联布局:从左到右依次为大头像、正面全身、侧面全身、背面全身
promptParts.add("clean plain studio background"); promptParts.add("character design sheet, single composite image arranged as one horizontal row of four panels");
promptParts.add(angleInstruction); promptParts.add("landscape orientation canvas, aspect ratio 16:9");
promptParts.add("single character"); promptParts.add(
promptParts.add("no extra people"); "the canvas is divided into exactly four equal-width vertical panels of identical height and identical width, placed side by side in a single row, " +
promptParts.add("no collage"); "all four panels share the exact same dimensions and aspect ratio, " +
promptParts.add("no split panel"); "from left to right: " +
promptParts.add("no props blocking the body"); "panel 1: a bust portrait of the character (head and shoulders only) centered within the panel, looking straight at the camera; " +
"panel 2: full-body front view facing camera, complete from head to toe including shoes and feet; " +
"panel 3: full-body left side profile view (90 degrees rotation), complete from head to toe including shoes and feet; " +
"panel 4: full-body back view facing away from camera, complete from head to toe including shoes and feet"
);
promptParts.add(
"the three full-body views (panels 2, 3, 4) show the character standing naturally and relaxed, " +
"arms hanging straight down along the sides of the body, feet together flat on the ground with both shoes fully visible, neutral expression, " +
"no extra gestures or poses, no T-pose, no arms raised or outstretched, " +
"the entire body must fit inside each panel with comfortable headroom above the hair and clear margin below the feet, " +
"do not crop or cut off the feet, shoes, ankles or top of the head"
);
promptParts.add(
"background: pure solid white (#FFFFFF) studio backdrop across the entire canvas and across all four panels, " +
"flat seamless white, no gradient, no off-white tint, no cream, no beige, no gray, no colored cast, " +
"no environment, no scenery, no props, no floor pattern, no textures, no patterns, no shadows on the background, " +
"soft even studio lighting with no cast shadows behind the character"
);
promptParts.add("the four views render the exact same character: identical face, hairstyle, skin tone, body proportions, costume, accessories and footwear");
promptParts.add("balanced composition, each figure vertically centered within its panel, full-body figures share a consistent ground line and identical scale");
promptParts.add("no panel borders, no dividing lines, no text labels, no captions, no watermarks, no logos");
promptParts.add("single character only, no extra people, no duplicated faces beyond these four views, no props blocking the body");
promptParts.addAll(buildStyleNegativePromptParts(visualStyle)); promptParts.addAll(buildStyleNegativePromptParts(visualStyle));
return String.join(", ", promptParts); return String.join(", ", promptParts);
} }
...@@ -807,9 +845,6 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -807,9 +845,6 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
return text; return text;
} }
private record CharacterViewAsset(String viewType, String key) {
}
private record ProjectVisualStyle( private record ProjectVisualStyle(
String slug, String slug,
String label, String label,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment