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

三视图优化为一张

parent a6819dfe
......@@ -76,7 +76,13 @@ function getCharacterViewUrl(character: Partial<Character>, viewType: CharacterV
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));
}
......@@ -120,7 +126,7 @@ export function CharacterGeneration() {
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 statusBadge = (status: string) => {
......@@ -395,7 +401,7 @@ export function CharacterGeneration() {
<div className="flex items-center justify-between mb-3">
<div>
<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>
<button
onClick={() => handleGenerateImage(character)}
......@@ -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"
>
{isGenerating ? <Loader2 className="w-3 h-3 animate-spin" /> : <RefreshCw className="w-3 h-3" />}
{hasCompleteThreeViews(character) ? "重新生成三视图" : "AI生成三视图"}
{hasCharacterImage(character) ? "重新生成三视图" : "AI生成三视图"}
</button>
</div>
<div className="grid grid-cols-3 gap-3">
{CHARACTER_VIEWS.map((view) => {
const imageUrl = getCharacterViewUrl(character, view.type);
{(() => {
const sheetUrl = getCharacterSheetUrl(character);
if (sheetUrl) {
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">
{isGenerating ? (
<>
<Loader2 className="w-6 h-6 text-primary animate-spin" />
<span className="text-xs text-muted-foreground">生成中</span>
</>
<div className="rounded-lg overflow-hidden bg-white border border-border">
<img
src={sheetUrl}
alt={`${character.name}-角色三视图`}
className="w-full h-auto object-contain"
/>
</div>
);
}
return (
<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" />
) : (
<>
<ImageIcon className="w-6 h-6 text-muted-foreground" />
<span className="text-xs text-muted-foreground">待补充</span>
</>
<div className="w-full h-full flex flex-col items-center justify-center gap-2">
{isGenerating ? (
<>
<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>
);
})()}
{character.imagePrompt && (
<p className="mt-3 text-xs text-muted-foreground line-clamp-3">{character.imagePrompt}</p>
)}
</div>
</div>
);
......
......@@ -10,6 +10,11 @@ public interface SeedreamService {
*/
String generateImage(String prompt);
/**
* 文生图:自定义画布尺寸(如 "2048x1152" 用于 16:9 横版角色三视图)
*/
String generateImage(String prompt, String size);
/** 当前使用的 Seedream 模型 ID,用于计费明细 */
String getModelId();
......
......@@ -36,11 +36,16 @@ public class SeedreamServiceImpl implements SeedreamService {
@Override
public String generateImage(String prompt) {
return generateImage(prompt, "2048x2048");
}
@Override
public String generateImage(String prompt, String size) {
Map<String, Object> body = Map.of(
"model", properties.getImageModel(),
"prompt", prompt,
"n", 1,
"size", "2048x2048",
"size", size,
"response_format", "url"
);
......
......@@ -42,9 +42,12 @@ public class VideoTaskController {
}
List<String> characterKeys = req.getCharacterImageKeys();
if ((characterKeys == null || characterKeys.isEmpty())
&& (req.getCharacterImageKey() == null || req.getCharacterImageKey().isBlank())) {
throw new BizException(ErrorCode.INVALID_PARAM, "请至少选择一个出镜角色");
boolean hasCharacter = (characterKeys != null && !characterKeys.isEmpty())
|| (req.getCharacterImageKey() != null && !req.getCharacterImageKey().isBlank());
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();
......
......@@ -38,7 +38,6 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
......@@ -215,7 +214,10 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
Project project = loadProject(character.getProjectId(), tenantId);
ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project);
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()
......@@ -227,8 +229,8 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
.modelProvider(seedreamService.getProvider())
.modelId(seedreamService.getModelId())
.modelName(seedreamService.getModelName())
.unitCount(3)
.meta(Map.of("views", List.of("front", "side", "back")))
.unitCount(1)
.meta(Map.of("layout", "sheet"))
.refId(String.valueOf(characterId))
.build());
......@@ -236,18 +238,31 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
characterMapper.updateById(character);
try {
CompletableFuture<CharacterViewAsset> frontFuture =
CompletableFuture.supplyAsync(() -> generateCharacterViewAsset(character, visualStyle, "front"));
CompletableFuture<CharacterViewAsset> sideFuture =
CompletableFuture.supplyAsync(() -> generateCharacterViewAsset(character, visualStyle, "side"));
CompletableFuture<CharacterViewAsset> backFuture =
CompletableFuture.supplyAsync(() -> generateCharacterViewAsset(character, visualStyle, "back"));
CompletableFuture.allOf(frontFuture, sideFuture, backFuture).join();
String prompt = buildCharacterSheetPrompt(character.getImagePrompt(), visualStyle);
// 角色三视图采用 16:9 横版画布,承载“大头像 + 正/侧/背 全身”一排四联布局
// 注意:Seedream 要求像素数 ≥ 3,686,400,2560x1440 = 3,686,400 恰好满足且为标准 16:9
String generatedUrl = seedreamService.generateImage(prompt, "2560x1440");
byte[] bytes = downloadBytes(generatedUrl);
String key = TosService.buildKey(
character.getTenantId(),
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());
applyCharacterView(character, sideFuture.join().viewType(), sideFuture.join().key());
applyCharacterView(character, backFuture.join().viewType(), backFuture.join().key());
String publicUrl = tosService.publicUrl(key);
// 拼图同时写入 imageUrl 与 frontImage* 字段:
// - 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");
characterMapper.updateById(character);
......@@ -260,8 +275,8 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
.modelProvider(seedreamService.getProvider())
.modelId(seedreamService.getModelId())
.modelName(seedreamService.getModelName())
.unitCount(3)
.meta(Map.of("views", List.of("front", "side", "back")))
.unitCount(1)
.meta(Map.of("layout", "sheet"))
.refId(String.valueOf(characterId))
.build());
return character;
......@@ -351,6 +366,9 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
@Override
public Character saveCharacter(Character character) {
if (character.getImagePrompt() == null || character.getImagePrompt().isBlank()) {
character.setImagePrompt(buildCharacterImagePromptFallback(character));
}
if (character.getId() == null) {
characterMapper.insert(character);
} else {
......@@ -359,6 +377,36 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
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
public Scene saveScene(Scene scene) {
if (scene.getId() != null) {
......@@ -477,30 +525,6 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
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) {
String publicUrl = tosService.publicUrl(key);
switch (normalizeCharacterViewType(viewType)) {
......@@ -522,14 +546,7 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
}
}
private String buildCharacterViewPrompt(String basePrompt, ProjectVisualStyle visualStyle, String viewType) {
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);
};
private String buildCharacterSheetPrompt(String basePrompt, ProjectVisualStyle visualStyle) {
List<String> promptParts = new ArrayList<>();
String normalizedPrompt = basePrompt == null ? "" : basePrompt.trim();
if (!normalizedPrompt.isBlank()) {
......@@ -537,15 +554,36 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
}
promptParts.add(visualStyle.renderGuidance());
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(angleInstruction);
promptParts.add("single character");
promptParts.add("no extra people");
promptParts.add("no collage");
promptParts.add("no split panel");
promptParts.add("no props blocking the body");
// 单行四联布局:从左到右依次为大头像、正面全身、侧面全身、背面全身
promptParts.add("character design sheet, single composite image arranged as one horizontal row of four panels");
promptParts.add("landscape orientation canvas, aspect ratio 16:9");
promptParts.add(
"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, " +
"all four panels share the exact same dimensions and aspect ratio, " +
"from left to right: " +
"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));
return String.join(", ", promptParts);
}
......@@ -807,9 +845,6 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
return text;
}
private record CharacterViewAsset(String viewType, String key) {
}
private record ProjectVisualStyle(
String slug,
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