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

Update comic studio project and asset generation flow

parent 16da3a9f
......@@ -30,6 +30,11 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
server: {
host: true,
port: 5173,
allowedHosts: true
},
// File types to support raw imports. Never add .css, .tsx, or .ts files to this.
assetsInclude: ['**/*.svg', '**/*.csv'],
......
version: "3.9"
services:
mysql:
image: mysql:8.0
......
......@@ -11,7 +11,6 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
@Tag(name = "角色管理")
......@@ -22,7 +21,7 @@ public class CharacterController {
private final AssetGenPipelineService assetService;
@Operation(summary = "AI 提取角色列表(从大纲+分集)")
@Operation(summary = "AI 提取角色列表")
@PostMapping("/extract")
public ApiResponse<List<Character>> extract(@PathVariable Long projectId) {
return ApiResponse.success(assetService.extractCharacters(projectId, TenantContext.get()));
......@@ -34,7 +33,7 @@ public class CharacterController {
return ApiResponse.success(assetService.listCharacters(projectId, TenantContext.get()));
}
@Operation(summary = "新建/更新角色")
@Operation(summary = "新建更新角色")
@PostMapping
public ApiResponse<Character> save(@PathVariable Long projectId, @RequestBody Character req) {
req.setProjectId(projectId);
......@@ -42,23 +41,29 @@ public class CharacterController {
return ApiResponse.success(assetService.saveCharacter(req));
}
@Operation(summary = "为角色生成形象图(Seedream)")
@Operation(summary = "AI 生成角色三视图")
@PostMapping("/{characterId}/generate-image")
public ApiResponse<Character> generateImage(@PathVariable Long projectId,
@PathVariable Long characterId) {
return ApiResponse.success(assetService.generateCharacterImage(characterId, TenantContext.get()));
}
@Operation(summary = "上传角色图片(手动)")
@Operation(summary = "上传角色视图图片")
@PostMapping(value = "/{characterId}/upload-image", consumes = "multipart/form-data")
public ApiResponse<Character> uploadImage(@PathVariable Long projectId,
@PathVariable Long characterId,
@RequestParam(defaultValue = "front") String viewType,
@RequestPart("file") MultipartFile file) throws IOException {
String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "image.jpg";
String ext = originalName.contains(".") ? originalName.substring(originalName.lastIndexOf('.') + 1) : "jpg";
return ApiResponse.success(assetService.uploadCharacterImage(
characterId, TenantContext.get(),
file.getInputStream(), file.getSize(), file.getContentType(), ext));
characterId,
TenantContext.get(),
viewType,
file.getInputStream(),
file.getSize(),
file.getContentType(),
ext));
}
@Operation(summary = "删除角色")
......
......@@ -13,4 +13,10 @@ public class ProjectCreateRequest {
@Size(max = 2000, message = "描述最长 2000 字符")
private String description;
private String style;
private String aspectRatio;
private String resolution;
}
......@@ -13,6 +13,8 @@ public class ProjectDTO {
private String description;
private String status;
private String style;
private String aspectRatio;
private String resolution;
private String coverUrl;
private long assetCount;
private LocalDateTime createdAt;
......@@ -25,6 +27,8 @@ public class ProjectDTO {
dto.setDescription(p.getDescription());
dto.setStatus(p.getStatus());
dto.setStyle(p.getStyle());
dto.setAspectRatio(p.getAspectRatio());
dto.setResolution(p.getResolution());
dto.setCoverUrl(coverUrl);
dto.setAssetCount(assetCount);
dto.setCreatedAt(p.getCreatedAt());
......
......@@ -13,4 +13,8 @@ public class ProjectUpdateRequest {
private String description;
private String style;
private String aspectRatio;
private String resolution;
}
......@@ -40,6 +40,9 @@ public class ProjectServiceImpl implements ProjectService {
project.setName(req.getName());
project.setDescription(req.getDescription());
project.setStatus("draft");
project.setStyle(req.getStyle());
project.setAspectRatio(req.getAspectRatio());
project.setResolution(req.getResolution());
projectMapper.insert(project);
return toDTO(project);
......@@ -75,6 +78,8 @@ public class ProjectServiceImpl implements ProjectService {
if (req.getName() != null) project.setName(req.getName());
if (req.getDescription() != null) project.setDescription(req.getDescription());
if (req.getStyle() != null) project.setStyle(req.getStyle());
if (req.getAspectRatio() != null) project.setAspectRatio(req.getAspectRatio());
if (req.getResolution() != null) project.setResolution(req.getResolution());
projectMapper.updateById(project);
return toDTO(project);
......
......@@ -12,4 +12,5 @@ public class YaoAiApplication {
public static void main(String[] args) {
SpringApplication.run(YaoAiApplication.class, args);
}
}
ALTER TABLE characters
ADD COLUMN front_image_url VARCHAR(1024) NULL AFTER image_url,
ADD COLUMN front_image_tos_key VARCHAR(512) NULL AFTER image_tos_key,
ADD COLUMN side_image_url VARCHAR(1024) NULL AFTER front_image_url,
ADD COLUMN side_image_tos_key VARCHAR(512) NULL AFTER front_image_tos_key,
ADD COLUMN back_image_url VARCHAR(1024) NULL AFTER side_image_url,
ADD COLUMN back_image_tos_key VARCHAR(512) NULL AFTER side_image_tos_key;
UPDATE characters
SET front_image_url = image_url,
front_image_tos_key = image_tos_key
WHERE image_url IS NOT NULL
AND (front_image_url IS NULL OR front_image_url = '');
ALTER TABLE projects
ADD COLUMN aspect_ratio VARCHAR(20) DEFAULT NULL COMMENT '画幅比例' AFTER style,
ADD COLUMN resolution VARCHAR(20) DEFAULT NULL COMMENT '清晰度' AFTER aspect_ratio;
......@@ -28,13 +28,19 @@ public class Character {
private String costume;
private String visualHint;
/** 供 Seedream 文生图使用的英文 Prompt */
/** Base prompt for standardized character image generation. */
private String imagePrompt;
/** Seedream 生成的临时 URL(仅展示) */
/** Keep the primary image for backward compatibility; this points to the front view. */
private String imageUrl;
/** 已存入 TOS 的 key */
private String imageTosKey;
private String frontImageUrl;
private String frontImageTosKey;
private String sideImageUrl;
private String sideImageTosKey;
private String backImageUrl;
private String backImageTosKey;
/** draft / generating / ready / failed */
private String status;
......
......@@ -27,6 +27,10 @@ public class Project {
private String style;
private String aspectRatio;
private String resolution;
private String coverKey;
private LocalDateTime createdAt;
......
......@@ -8,16 +8,13 @@ import java.util.List;
public interface AssetGenPipelineService {
/** 根据大纲 + 分集内容,AI 提取角色列表并保存(不含生图) */
List<Character> extractCharacters(Long projectId, Long tenantId);
/** 根据大纲 + 分集内容,AI 提取场景列表并保存(不含生图) */
List<Scene> extractScenes(Long projectId, Long tenantId);
/** 为指定角色调用 Seedream 生图并存 TOS */
/** Generate three standardized character turnaround views: front, side, and back. */
Character generateCharacterImage(Long characterId, Long tenantId);
/** 为指定场景调用 Seedream 生图并存 TOS */
Scene generateSceneImage(Long sceneId, Long tenantId);
List<Character> listCharacters(Long projectId, Long tenantId);
......@@ -32,9 +29,8 @@ public interface AssetGenPipelineService {
void deleteScene(Long id, Long tenantId);
/** 上传用户自定义角色图,存 TOS 并更新 imageUrl */
Character uploadCharacterImage(Long characterId, Long tenantId, InputStream stream, long size, String contentType, String ext);
Character uploadCharacterImage(Long characterId, Long tenantId, String viewType,
InputStream stream, long size, String contentType, String ext);
/** 上传用户自定义场景图,存 TOS 并更新 imageUrl */
Scene uploadSceneImage(Long sceneId, Long tenantId, InputStream stream, long size, String contentType, String ext);
}
......@@ -11,13 +11,14 @@ import com.yaoai.common.exception.ErrorCode;
import com.yaoai.domain.entity.Character;
import com.yaoai.domain.entity.Episode;
import com.yaoai.domain.entity.Outline;
import com.yaoai.domain.entity.Project;
import com.yaoai.domain.entity.Scene;
import com.yaoai.domain.mapper.CharacterMapper;
import com.yaoai.domain.mapper.EpisodeMapper;
import com.yaoai.domain.mapper.OutlineMapper;
import com.yaoai.domain.mapper.ProjectMapper;
import com.yaoai.domain.mapper.SceneMapper;
import com.yaoai.pipeline.service.AssetGenPipelineService;
import com.yaoai.pipeline.service.ImageGenPipelineService;
import com.yaoai.storage.service.TosService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
......@@ -31,58 +32,63 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@Slf4j
@Service
@RequiredArgsConstructor
public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
private final LlmService llmService;
private final SeedreamService seedreamService;
private final TosService tosService;
private final CharacterMapper characterMapper;
private final SceneMapper sceneMapper;
private final OutlineMapper outlineMapper;
private final EpisodeMapper episodeMapper;
private final ObjectMapper objectMapper;
private final HttpClient httpClient = HttpClient.newHttpClient();
private static final String CHARACTER_SYSTEM = """
你是影视剧角色设定专家。根据剧情大纲和分集内容,提取主要角色并生成结构化信息
请以 JSON 数组返回,格式
你是影视短剧角色设定专家。请根据给定的大纲和分集信息,提取主要角色并输出 JSON 数组
返回格式如下
[
{
"name": "角色姓名",
"role_type": "角色类型(女主/男主/反派/配角)",
"gender": "male female",
"age": "年龄描述(如:18/30多岁)",
"personality": "性格特点(50字以内)",
"costume": "主要服装描述50字以内)",
"visual_hint": "外貌特征50字以内)",
"image_prompt": "英文文生图 Prompt40词以内,描述角色正面全身,适合 2D anime 或现实风格)"
"role_type": "female_lead / male_lead / antagonist / supporting",
"gender": "male or female",
"age": "年龄描述",
"personality": "性格特征,30字以内",
"costume": "主要服装描述30字以内",
"visual_hint": "外貌特征30字以内",
"image_prompt": "英文角色设定图 prompt,突出服装、发型、体型、气质,用于统一角色三视图生成"
}
]
只返回主要角色(3~8个),只返回 JSON 数组
只返回 3 到 8 个主要角色,只返回 JSON
""";
private static final String SCENE_SYSTEM = """
你是影视剧场景设计专家。根据剧情大纲和分集内容,提取主要场景并生成结构化信息
请以 JSON 数组返回,格式
你是影视短剧场景设定专家。请根据给定的大纲和分集信息,提取主要场景并输出 JSON 数组
返回格式如下
[
{
"name": "场景名称",
"scene_type": "indoor outdoor",
"description": "场景描述50字以内)",
"image_prompt": "英文文生图 Prompt40词以内,描述场景环境,电影级质感)"
"scene_type": "indoor or outdoor",
"description": "场景描述30字以内",
"image_prompt": "英文场景图 prompt,电影感环境描述"
}
]
只返回主要场景(3~6个),只返回 JSON 数组
只返回 3 到 6 个主要场景,只返回 JSON
""";
private final LlmService llmService;
private final SeedreamService seedreamService;
private final TosService tosService;
private final CharacterMapper characterMapper;
private final SceneMapper sceneMapper;
private final OutlineMapper outlineMapper;
private final EpisodeMapper episodeMapper;
private final ProjectMapper projectMapper;
private final ObjectMapper objectMapper;
private final HttpClient httpClient = HttpClient.newHttpClient();
@Override
public List<Character> extractCharacters(Long projectId, Long tenantId) {
String context = buildProjectContext(projectId, tenantId);
Project project = loadProject(projectId, tenantId);
ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project);
String context = buildCharacterExtractionContext(projectId, tenantId, visualStyle);
log.info("Extracting characters: projectId={}", projectId);
String raw = llmService.chat(ChatRequest.builder()
......@@ -92,23 +98,22 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
try {
List<Map<String, Object>> items = objectMapper.readValue(extractJson(raw), new TypeReference<>() {});
List<Character> result = new ArrayList<>();
for (Map<String, Object> m : items) {
Character c = new Character();
c.setProjectId(projectId);
c.setTenantId(tenantId);
c.setName((String) m.getOrDefault("name", ""));
c.setRoleType((String) m.getOrDefault("role_type", ""));
c.setGender((String) m.getOrDefault("gender", ""));
c.setAge((String) m.getOrDefault("age", ""));
c.setPersonality((String) m.getOrDefault("personality", ""));
c.setCostume((String) m.getOrDefault("costume", ""));
c.setVisualHint((String) m.getOrDefault("visual_hint", ""));
c.setImagePrompt((String) m.getOrDefault("image_prompt", ""));
c.setStatus("draft");
characterMapper.insert(c);
result.add(c);
for (Map<String, Object> item : items) {
Character character = new Character();
character.setProjectId(projectId);
character.setTenantId(tenantId);
character.setName((String) item.getOrDefault("name", ""));
character.setRoleType((String) item.getOrDefault("role_type", ""));
character.setGender((String) item.getOrDefault("gender", ""));
character.setAge((String) item.getOrDefault("age", ""));
character.setPersonality((String) item.getOrDefault("personality", ""));
character.setCostume((String) item.getOrDefault("costume", ""));
character.setVisualHint((String) item.getOrDefault("visual_hint", ""));
character.setImagePrompt((String) item.getOrDefault("image_prompt", ""));
character.setStatus("draft");
characterMapper.insert(character);
result.add(character);
}
log.info("Characters extracted: count={}", result.size());
return result;
} catch (Exception e) {
log.error("Character extraction failed", e);
......@@ -128,19 +133,18 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
try {
List<Map<String, Object>> items = objectMapper.readValue(extractJson(raw), new TypeReference<>() {});
List<Scene> result = new ArrayList<>();
for (Map<String, Object> m : items) {
Scene s = new Scene();
s.setProjectId(projectId);
s.setTenantId(tenantId);
s.setName((String) m.getOrDefault("name", ""));
s.setSceneType((String) m.getOrDefault("scene_type", "indoor"));
s.setDescription((String) m.getOrDefault("description", ""));
s.setImagePrompt((String) m.getOrDefault("image_prompt", ""));
s.setStatus("draft");
sceneMapper.insert(s);
result.add(s);
for (Map<String, Object> item : items) {
Scene scene = new Scene();
scene.setProjectId(projectId);
scene.setTenantId(tenantId);
scene.setName((String) item.getOrDefault("name", ""));
scene.setSceneType((String) item.getOrDefault("scene_type", "indoor"));
scene.setDescription((String) item.getOrDefault("description", ""));
scene.setImagePrompt((String) item.getOrDefault("image_prompt", ""));
scene.setStatus("draft");
sceneMapper.insert(scene);
result.add(scene);
}
log.info("Scenes extracted: count={}", result.size());
return result;
} catch (Exception e) {
log.error("Scene extraction failed", e);
......@@ -150,72 +154,75 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
@Override
public Character generateCharacterImage(Long characterId, Long tenantId) {
Character c = characterMapper.selectById(characterId);
if (c == null || !tenantId.equals(c.getTenantId())) {
throw new BizException(ErrorCode.NOT_FOUND, "角色不存在");
}
if (c.getImagePrompt() == null || c.getImagePrompt().isBlank()) {
Character character = requireCharacter(characterId, tenantId);
Project project = loadProject(character.getProjectId(), tenantId);
ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project);
if (character.getImagePrompt() == null || character.getImagePrompt().isBlank()) {
throw new BizException(ErrorCode.INVALID_PARAM, "角色缺少图片 Prompt");
}
c.setStatus("generating");
characterMapper.updateById(c);
character.setStatus("generating");
characterMapper.updateById(character);
try {
String imageUrl = seedreamService.generateImage(c.getImagePrompt());
byte[] bytes = downloadBytes(imageUrl);
String key = TosService.buildKey(c.getTenantId(), c.getProjectId(), "characters", c.getName() + ".jpg");
tosService.upload(key, new ByteArrayInputStream(bytes), bytes.length, "image/jpeg");
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"));
c.setImageUrl(tosService.publicUrl(key));
c.setImageTosKey(key);
c.setStatus("ready");
characterMapper.updateById(c);
log.info("Character image generated: id={}, key={}", characterId, key);
return c;
CompletableFuture.allOf(frontFuture, sideFuture, backFuture).join();
applyCharacterView(character, frontFuture.join().viewType(), frontFuture.join().key());
applyCharacterView(character, sideFuture.join().viewType(), sideFuture.join().key());
applyCharacterView(character, backFuture.join().viewType(), backFuture.join().key());
character.setStatus("ready");
characterMapper.updateById(character);
return character;
} catch (BizException e) {
c.setStatus("failed");
characterMapper.updateById(c);
character.setStatus("failed");
characterMapper.updateById(character);
throw e;
} catch (Exception e) {
c.setStatus("failed");
characterMapper.updateById(c);
throw new BizException(ErrorCode.INTERNAL_ERROR, "角色图片生成失败: " + e.getMessage());
character.setStatus("failed");
characterMapper.updateById(character);
throw new BizException(ErrorCode.INTERNAL_ERROR, "角色三视图生成失败: " + e.getMessage());
}
}
@Override
public Scene generateSceneImage(Long sceneId, Long tenantId) {
Scene s = sceneMapper.selectById(sceneId);
if (s == null || !tenantId.equals(s.getTenantId())) {
Scene scene = sceneMapper.selectById(sceneId);
if (scene == null || !tenantId.equals(scene.getTenantId())) {
throw new BizException(ErrorCode.NOT_FOUND, "场景不存在");
}
if (s.getImagePrompt() == null || s.getImagePrompt().isBlank()) {
if (scene.getImagePrompt() == null || scene.getImagePrompt().isBlank()) {
throw new BizException(ErrorCode.INVALID_PARAM, "场景缺少图片 Prompt");
}
s.setStatus("generating");
sceneMapper.updateById(s);
scene.setStatus("generating");
sceneMapper.updateById(scene);
try {
String imageUrl = seedreamService.generateImage(s.getImagePrompt());
String imageUrl = seedreamService.generateImage(scene.getImagePrompt());
byte[] bytes = downloadBytes(imageUrl);
String key = TosService.buildKey(s.getTenantId(), s.getProjectId(), "scenes", s.getName() + ".jpg");
String key = TosService.buildKey(scene.getTenantId(), scene.getProjectId(), "scenes", scene.getName() + ".jpg");
tosService.upload(key, new ByteArrayInputStream(bytes), bytes.length, "image/jpeg");
s.setImageUrl(tosService.publicUrl(key));
s.setImageTosKey(key);
s.setStatus("ready");
sceneMapper.updateById(s);
log.info("Scene image generated: id={}, key={}", sceneId, key);
return s;
scene.setImageUrl(tosService.publicUrl(key));
scene.setImageTosKey(key);
scene.setStatus("ready");
sceneMapper.updateById(scene);
return scene;
} catch (BizException e) {
s.setStatus("failed");
sceneMapper.updateById(s);
scene.setStatus("failed");
sceneMapper.updateById(scene);
throw e;
} catch (Exception e) {
s.setStatus("failed");
sceneMapper.updateById(s);
scene.setStatus("failed");
sceneMapper.updateById(scene);
throw new BizException(ErrorCode.INTERNAL_ERROR, "场景图片生成失败: " + e.getMessage());
}
}
......@@ -252,80 +259,393 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
@Override
public void deleteCharacter(Long id, Long tenantId) {
Character c = characterMapper.selectById(id);
if (c != null && tenantId.equals(c.getTenantId())) characterMapper.deleteById(id);
Character character = characterMapper.selectById(id);
if (character != null && tenantId.equals(character.getTenantId())) {
characterMapper.deleteById(id);
}
}
@Override
public void deleteScene(Long id, Long tenantId) {
Scene s = sceneMapper.selectById(id);
if (s != null && tenantId.equals(s.getTenantId())) sceneMapper.deleteById(id);
Scene scene = sceneMapper.selectById(id);
if (scene != null && tenantId.equals(scene.getTenantId())) {
sceneMapper.deleteById(scene);
}
}
@Override
public Character uploadCharacterImage(Long characterId, Long tenantId,
public Character uploadCharacterImage(Long characterId, Long tenantId, String viewType,
InputStream stream, long size, String contentType, String ext) {
Character c = characterMapper.selectById(characterId);
if (c == null || !tenantId.equals(c.getTenantId()))
throw new RuntimeException("角色不存在或无权限");
String key = "projects/" + c.getProjectId() + "/characters/" + characterId + "/image." + ext;
Character character = requireCharacter(characterId, tenantId);
String normalizedViewType = normalizeCharacterViewType(viewType);
String safeExt = (ext == null || ext.isBlank()) ? "jpg" : ext;
String key = "projects/" + character.getProjectId() + "/characters/" + characterId + "/" + normalizedViewType + "." + safeExt;
tosService.upload(key, stream, size, contentType);
c.setImageTosKey(key);
c.setImageUrl(tosService.publicUrl(key));
c.setStatus("ready");
characterMapper.updateById(c);
return c;
applyCharacterView(character, normalizedViewType, key);
character.setStatus("ready");
characterMapper.updateById(character);
return character;
}
@Override
public Scene uploadSceneImage(Long sceneId, Long tenantId,
InputStream stream, long size, String contentType, String ext) {
Scene s = sceneMapper.selectById(sceneId);
if (s == null || !tenantId.equals(s.getTenantId()))
Scene scene = sceneMapper.selectById(sceneId);
if (scene == null || !tenantId.equals(scene.getTenantId())) {
throw new RuntimeException("场景不存在或无权限");
String key = "projects/" + s.getProjectId() + "/scenes/" + sceneId + "/image." + ext;
}
String safeExt = (ext == null || ext.isBlank()) ? "jpg" : ext;
String key = "projects/" + scene.getProjectId() + "/scenes/" + sceneId + "/image." + safeExt;
tosService.upload(key, stream, size, contentType);
s.setImageTosKey(key);
s.setImageUrl(tosService.publicUrl(key));
s.setStatus("ready");
sceneMapper.updateById(s);
return s;
scene.setImageTosKey(key);
scene.setImageUrl(tosService.publicUrl(key));
scene.setStatus("ready");
sceneMapper.updateById(scene);
return scene;
}
// ---- helpers ----
private Character requireCharacter(Long characterId, Long tenantId) {
Character character = characterMapper.selectById(characterId);
if (character == null || !tenantId.equals(character.getTenantId())) {
throw new BizException(ErrorCode.NOT_FOUND, "角色不存在");
}
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)) {
case "front" -> {
character.setImageUrl(publicUrl);
character.setImageTosKey(key);
character.setFrontImageUrl(publicUrl);
character.setFrontImageTosKey(key);
}
case "side" -> {
character.setSideImageUrl(publicUrl);
character.setSideImageTosKey(key);
}
case "back" -> {
character.setBackImageUrl(publicUrl);
character.setBackImageTosKey(key);
}
default -> throw new BizException(ErrorCode.INVALID_PARAM, "不支持的角色视图: " + viewType);
}
}
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);
};
List<String> promptParts = new ArrayList<>();
String normalizedPrompt = basePrompt == null ? "" : basePrompt.trim();
if (!normalizedPrompt.isBlank()) {
promptParts.add(normalizedPrompt);
}
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.addAll(buildStyleNegativePromptParts(visualStyle));
return String.join(", ", promptParts);
}
private String normalizeCharacterViewType(String viewType) {
if (viewType == null || viewType.isBlank()) {
return "front";
}
String normalized = viewType.toLowerCase(Locale.ROOT);
return switch (normalized) {
case "front", "side", "back" -> normalized;
default -> throw new BizException(ErrorCode.INVALID_PARAM, "不支持的角色视图: " + viewType);
};
}
private String buildProjectContext(Long projectId, Long tenantId) {
Outline outline = outlineMapper.findLatestByProject(projectId, tenantId);
List<Episode> episodes = episodeMapper.findByProject(projectId, tenantId);
StringBuilder sb = new StringBuilder();
StringBuilder builder = new StringBuilder();
if (outline != null) {
sb.append("剧名:").append(outline.getTitle()).append("\n");
sb.append("类型:").append(outline.getGenre()).append("\n");
sb.append("故事梗概:").append(outline.getSynopsis()).append("\n\n");
builder.append("剧名:").append(outline.getTitle()).append('\n');
builder.append("类型:").append(outline.getGenre()).append('\n');
builder.append("故事梗概:").append(outline.getSynopsis()).append("\n\n");
}
builder.append("分集摘要:\n");
for (Episode episode : episodes) {
builder.append("第")
.append(episode.getEpisodeNumber())
.append("集《")
.append(episode.getTitle())
.append("》:")
.append(episode.getSummary())
.append('\n');
}
return builder.toString();
}
private String buildCharacterExtractionContext(Long projectId, Long tenantId, ProjectVisualStyle visualStyle) {
StringBuilder builder = new StringBuilder(buildProjectContext(projectId, tenantId));
if (builder.length() > 0) {
builder.append("\n\n");
}
sb.append("分集摘要:\n");
for (Episode ep : episodes) {
sb.append("第").append(ep.getEpisodeNumber()).append("集 《").append(ep.getTitle()).append("》:");
sb.append(ep.getSummary()).append("\n");
builder.append("Character visual style requirements:\n");
builder.append("- Selected project style: ").append(visualStyle.label()).append('\n');
builder.append("- Style direction: ").append(visualStyle.extractionGuidance()).append('\n');
builder.append("- The image_prompt must be suitable for generating a consistent front/side/back turnaround sheet for the same person in the selected visual style.\n");
builder.append("- Output target: ").append(buildExtractionTargetRule(visualStyle)).append('\n');
builder.append("- Avoid: ").append(buildExtractionAvoidRule(visualStyle)).append('\n');
builder.append("- Additional style notes: ").append(visualStyle.extractionGuidance()).append('\n');
return builder.toString();
}
private Project loadProject(Long projectId, Long tenantId) {
return projectMapper.findActiveById(projectId, tenantId).orElse(null);
}
private ProjectVisualStyle resolveProjectVisualStyle(Project project) {
if (project == null || project.getStyle() == null || project.getStyle().isBlank()) {
return new ProjectVisualStyle(
"default",
"真人短剧视觉",
"grounded live-action short drama characters with realistic styling",
"grounded live-action short drama aesthetic, cinematic realism, realistic wardrobe"
);
}
return sb.toString();
String slug = project.getStyle().trim().toLowerCase(Locale.ROOT);
return switch (slug) {
case "live_action_drama" -> new ProjectVisualStyle(
slug,
"真人短剧视觉",
"live-action short drama styling with realistic actors, believable wardrobe, and production-ready character design",
"live-action short drama aesthetic, realistic actors, cinematic realism, believable wardrobe details"
);
case "japanese_anime" -> new ProjectVisualStyle(
slug,
"2D日漫",
"2D Japanese anime styling with clean line art, cel shading, expressive silhouettes, and strong character readability",
"2D Japanese anime aesthetic, clean line art, cel shading, expressive character design, polished turnaround sheet"
);
case "korean_webtoon" -> new ProjectVisualStyle(
slug,
"2D韩漫都市",
"Korean webtoon urban styling with fashionable silhouettes, soft shading, and modern romance or city-drama appeal",
"2D Korean webtoon aesthetic, modern urban fashion, clean line art, soft shading, stylish character sheet"
);
case "chinese_anime" -> new ProjectVisualStyle(
slug,
"2D国漫",
"2D Chinese animation styling with strong silhouette design, refined costume details, and contemporary guoman appeal",
"2D Chinese animation aesthetic, clean line art, refined costume design, guoman character sheet"
);
case "chinese_fantasy_3d" -> new ProjectVisualStyle(
slug,
"3D国风仙侠",
"stylized 3D Chinese fantasy styling with layered costume details, elegant accessories, and xianxia or guofeng presentation",
"3D Chinese fantasy aesthetic, stylized character render, ornate costume layers, xianxia guofeng hero design"
);
case "cg_cinematic" -> new ProjectVisualStyle(
slug,
"CG电影感",
"high-end CGI cinematic styling with hero-asset presentation, premium materials, and polished production design",
"cinematic CGI character design, premium materials, high-end render quality, hero asset presentation"
);
case "urban" -> new ProjectVisualStyle(
slug,
"Urban emotional drama",
"modern city setting, fashionable contemporary wardrobe, restrained makeup, grounded live-action drama references",
"modern urban live-action drama aesthetic, contemporary Chinese wardrobe, cinematic realism, grounded emotional tone"
);
case "fantasy" -> new ProjectVisualStyle(
slug,
"Fantasy xianxia drama",
"fantasy or xianxia worldbuilding, layered costume details, elegant accessories, but still designed as live-action costume drama characters",
"Chinese fantasy xianxia live-action costume drama aesthetic, refined accessories, layered fabrics, cinematic realism"
);
case "romance" -> new ProjectVisualStyle(
slug,
"Romance drama",
"attractive but believable real-world styling, polished wardrobe, intimate emotional tone, live-action drama reference",
"contemporary live-action romance drama aesthetic, polished styling, soft cinematic lighting, realistic people"
);
case "historical" -> new ProjectVisualStyle(
slug,
"Historical costume drama",
"historical Chinese costume drama styling, era-appropriate silhouette and accessories, realistic live-action wardrobe construction",
"Chinese historical live-action costume drama aesthetic, period wardrobe, elegant materials, cinematic realism"
);
case "suspense" -> new ProjectVisualStyle(
slug,
"Suspense thriller",
"grounded thriller styling, practical wardrobe, realistic tension, understated colors, live-action reference",
"grounded suspense thriller live-action aesthetic, restrained palette, realistic wardrobe, cinematic realism"
);
case "comedy" -> new ProjectVisualStyle(
slug,
"Comedy drama",
"bright and expressive but still realistic live-action styling, contemporary wardrobe, approachable character design",
"bright live-action comedy-drama aesthetic, expressive styling, realistic people, clean contemporary wardrobe"
);
default -> new ProjectVisualStyle(
slug,
"Custom style: " + slug,
"match the selected project style while keeping the result visually coherent and suitable for character turnaround generation",
"match the selected project style `" + slug + "` with a clear, consistent character-design presentation"
);
};
}
private List<String> buildStyleSpecificPromptParts(ProjectVisualStyle visualStyle) {
return switch (visualStyle.slug()) {
case "japanese_anime" -> List.of(
"2D Japanese anime character turnaround sheet",
"clean line art",
"cel shading",
"anime-style color blocking"
);
case "korean_webtoon" -> List.of(
"2D Korean webtoon character turnaround sheet",
"clean line art",
"soft webtoon shading",
"modern urban fashion styling"
);
case "chinese_anime" -> List.of(
"2D Chinese animation character turnaround sheet",
"clean line art",
"stylized guoman character design",
"refined costume detailing"
);
case "chinese_fantasy_3d" -> List.of(
"3D Chinese fantasy character turnaround sheet",
"stylized 3D render",
"ornate costume layers",
"xianxia-inspired accessories"
);
case "cg_cinematic" -> List.of(
"cinematic CGI character turnaround sheet",
"high-end CGI rendering",
"hero asset presentation",
"premium material definition"
);
default -> List.of(
"live-action TV drama character turnaround sheet",
"production-ready costume design reference",
"realistic human anatomy and natural skin texture"
);
};
}
private List<String> buildStyleNegativePromptParts(ProjectVisualStyle visualStyle) {
return switch (visualStyle.slug()) {
case "japanese_anime", "korean_webtoon", "chinese_anime" -> List.of(
"not live action",
"not photorealistic skin texture",
"not 3D render",
"not photo"
);
case "chinese_fantasy_3d", "cg_cinematic" -> List.of(
"not live action photo",
"not flat 2D illustration",
"not manga panel",
"not collage"
);
default -> List.of(
"not anime",
"not manga",
"not cartoon",
"not cel shaded",
"not illustration"
);
};
}
private String buildExtractionTargetRule(ProjectVisualStyle visualStyle) {
return switch (visualStyle.slug()) {
case "japanese_anime" -> "a 2D Japanese anime turnaround sheet with front, side, and back consistency";
case "korean_webtoon" -> "a 2D Korean webtoon turnaround sheet with modern urban styling and clear silhouette consistency";
case "chinese_anime" -> "a 2D guoman turnaround sheet with refined costume details and consistent proportions";
case "chinese_fantasy_3d" -> "a 3D Chinese fantasy turnaround sheet with layered costume construction and clean silhouette readability";
case "cg_cinematic" -> "a cinematic CGI turnaround sheet with premium material definition and hero-asset clarity";
default -> "a live-action short drama turnaround sheet with realistic wardrobe, hair, and body proportions";
};
}
private String buildExtractionAvoidRule(ProjectVisualStyle visualStyle) {
return switch (visualStyle.slug()) {
case "japanese_anime", "korean_webtoon", "chinese_anime" ->
"live-action photography, photorealistic actor language, or realistic skin-texture wording";
case "chinese_fantasy_3d", "cg_cinematic" ->
"flat 2D illustration wording, manga panel composition, or live-action actor-photo language";
default ->
"anime, manga, cartoon, cel-shaded, or illustrated wording unless the selected style explicitly requires it";
};
}
private byte[] downloadBytes(String url) throws Exception {
HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
HttpResponse<byte[]> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofByteArray());
if (resp.statusCode() != 200) throw new BizException(ErrorCode.INTERNAL_ERROR, "下载图片失败 HTTP " + resp.statusCode());
return resp.body();
HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "下载图片失败 HTTP " + response.statusCode());
}
return response.body();
}
private String extractJson(String raw) {
String s = raw.strip();
if (s.startsWith("```")) {
int start = s.indexOf('\n') + 1;
int end = s.lastIndexOf("```");
if (end > start) s = s.substring(start, end).strip();
String text = raw == null ? "" : raw.strip();
if (text.startsWith("```")) {
int start = text.indexOf('\n') + 1;
int end = text.lastIndexOf("```");
if (end > start) {
text = text.substring(start, end).strip();
}
}
return s;
return text;
}
private record CharacterViewAsset(String viewType, String key) {
}
private record ProjectVisualStyle(
String slug,
String label,
String extractionGuidance,
String renderGuidance
) {
}
}
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