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({ ...@@ -30,6 +30,11 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'), '@': 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. // File types to support raw imports. Never add .css, .tsx, or .ts files to this.
assetsInclude: ['**/*.svg', '**/*.csv'], assetsInclude: ['**/*.svg', '**/*.csv'],
......
version: "3.9"
services: services:
mysql: mysql:
image: mysql:8.0 image: mysql:8.0
......
...@@ -11,7 +11,6 @@ import org.springframework.web.bind.annotation.*; ...@@ -11,7 +11,6 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
@Tag(name = "角色管理") @Tag(name = "角色管理")
...@@ -22,7 +21,7 @@ public class CharacterController { ...@@ -22,7 +21,7 @@ public class CharacterController {
private final AssetGenPipelineService assetService; private final AssetGenPipelineService assetService;
@Operation(summary = "AI 提取角色列表(从大纲+分集)") @Operation(summary = "AI 提取角色列表")
@PostMapping("/extract") @PostMapping("/extract")
public ApiResponse<List<Character>> extract(@PathVariable Long projectId) { public ApiResponse<List<Character>> extract(@PathVariable Long projectId) {
return ApiResponse.success(assetService.extractCharacters(projectId, TenantContext.get())); return ApiResponse.success(assetService.extractCharacters(projectId, TenantContext.get()));
...@@ -34,7 +33,7 @@ public class CharacterController { ...@@ -34,7 +33,7 @@ public class CharacterController {
return ApiResponse.success(assetService.listCharacters(projectId, TenantContext.get())); return ApiResponse.success(assetService.listCharacters(projectId, TenantContext.get()));
} }
@Operation(summary = "新建/更新角色") @Operation(summary = "新建更新角色")
@PostMapping @PostMapping
public ApiResponse<Character> save(@PathVariable Long projectId, @RequestBody Character req) { public ApiResponse<Character> save(@PathVariable Long projectId, @RequestBody Character req) {
req.setProjectId(projectId); req.setProjectId(projectId);
...@@ -42,23 +41,29 @@ public class CharacterController { ...@@ -42,23 +41,29 @@ public class CharacterController {
return ApiResponse.success(assetService.saveCharacter(req)); return ApiResponse.success(assetService.saveCharacter(req));
} }
@Operation(summary = "为角色生成形象图(Seedream)") @Operation(summary = "AI 生成角色三视图")
@PostMapping("/{characterId}/generate-image") @PostMapping("/{characterId}/generate-image")
public ApiResponse<Character> generateImage(@PathVariable Long projectId, public ApiResponse<Character> generateImage(@PathVariable Long projectId,
@PathVariable Long characterId) { @PathVariable Long characterId) {
return ApiResponse.success(assetService.generateCharacterImage(characterId, TenantContext.get())); return ApiResponse.success(assetService.generateCharacterImage(characterId, TenantContext.get()));
} }
@Operation(summary = "上传角色图片(手动)") @Operation(summary = "上传角色视图图片")
@PostMapping(value = "/{characterId}/upload-image", consumes = "multipart/form-data") @PostMapping(value = "/{characterId}/upload-image", consumes = "multipart/form-data")
public ApiResponse<Character> uploadImage(@PathVariable Long projectId, public ApiResponse<Character> uploadImage(@PathVariable Long projectId,
@PathVariable Long characterId, @PathVariable Long characterId,
@RequestParam(defaultValue = "front") String viewType,
@RequestPart("file") MultipartFile file) throws IOException { @RequestPart("file") MultipartFile file) throws IOException {
String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "image.jpg"; String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "image.jpg";
String ext = originalName.contains(".") ? originalName.substring(originalName.lastIndexOf('.') + 1) : "jpg"; String ext = originalName.contains(".") ? originalName.substring(originalName.lastIndexOf('.') + 1) : "jpg";
return ApiResponse.success(assetService.uploadCharacterImage( return ApiResponse.success(assetService.uploadCharacterImage(
characterId, TenantContext.get(), characterId,
file.getInputStream(), file.getSize(), file.getContentType(), ext)); TenantContext.get(),
viewType,
file.getInputStream(),
file.getSize(),
file.getContentType(),
ext));
} }
@Operation(summary = "删除角色") @Operation(summary = "删除角色")
......
...@@ -13,4 +13,10 @@ public class ProjectCreateRequest { ...@@ -13,4 +13,10 @@ public class ProjectCreateRequest {
@Size(max = 2000, message = "描述最长 2000 字符") @Size(max = 2000, message = "描述最长 2000 字符")
private String description; private String description;
private String style;
private String aspectRatio;
private String resolution;
} }
...@@ -13,6 +13,8 @@ public class ProjectDTO { ...@@ -13,6 +13,8 @@ public class ProjectDTO {
private String description; private String description;
private String status; private String status;
private String style; private String style;
private String aspectRatio;
private String resolution;
private String coverUrl; private String coverUrl;
private long assetCount; private long assetCount;
private LocalDateTime createdAt; private LocalDateTime createdAt;
...@@ -25,6 +27,8 @@ public class ProjectDTO { ...@@ -25,6 +27,8 @@ public class ProjectDTO {
dto.setDescription(p.getDescription()); dto.setDescription(p.getDescription());
dto.setStatus(p.getStatus()); dto.setStatus(p.getStatus());
dto.setStyle(p.getStyle()); dto.setStyle(p.getStyle());
dto.setAspectRatio(p.getAspectRatio());
dto.setResolution(p.getResolution());
dto.setCoverUrl(coverUrl); dto.setCoverUrl(coverUrl);
dto.setAssetCount(assetCount); dto.setAssetCount(assetCount);
dto.setCreatedAt(p.getCreatedAt()); dto.setCreatedAt(p.getCreatedAt());
......
...@@ -13,4 +13,8 @@ public class ProjectUpdateRequest { ...@@ -13,4 +13,8 @@ public class ProjectUpdateRequest {
private String description; private String description;
private String style; private String style;
private String aspectRatio;
private String resolution;
} }
...@@ -40,6 +40,9 @@ public class ProjectServiceImpl implements ProjectService { ...@@ -40,6 +40,9 @@ public class ProjectServiceImpl implements ProjectService {
project.setName(req.getName()); project.setName(req.getName());
project.setDescription(req.getDescription()); project.setDescription(req.getDescription());
project.setStatus("draft"); project.setStatus("draft");
project.setStyle(req.getStyle());
project.setAspectRatio(req.getAspectRatio());
project.setResolution(req.getResolution());
projectMapper.insert(project); projectMapper.insert(project);
return toDTO(project); return toDTO(project);
...@@ -75,6 +78,8 @@ public class ProjectServiceImpl implements ProjectService { ...@@ -75,6 +78,8 @@ public class ProjectServiceImpl implements ProjectService {
if (req.getName() != null) project.setName(req.getName()); if (req.getName() != null) project.setName(req.getName());
if (req.getDescription() != null) project.setDescription(req.getDescription()); if (req.getDescription() != null) project.setDescription(req.getDescription());
if (req.getStyle() != null) project.setStyle(req.getStyle()); 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); projectMapper.updateById(project);
return toDTO(project); return toDTO(project);
......
...@@ -12,4 +12,5 @@ public class YaoAiApplication { ...@@ -12,4 +12,5 @@ public class YaoAiApplication {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(YaoAiApplication.class, 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 { ...@@ -28,13 +28,19 @@ public class Character {
private String costume; private String costume;
private String visualHint; private String visualHint;
/** 供 Seedream 文生图使用的英文 Prompt */ /** Base prompt for standardized character image generation. */
private String imagePrompt; private String imagePrompt;
/** Seedream 生成的临时 URL(仅展示) */ /** Keep the primary image for backward compatibility; this points to the front view. */
private String imageUrl; private String imageUrl;
/** 已存入 TOS 的 key */
private String imageTosKey; 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 */ /** draft / generating / ready / failed */
private String status; private String status;
......
...@@ -27,6 +27,10 @@ public class Project { ...@@ -27,6 +27,10 @@ public class Project {
private String style; private String style;
private String aspectRatio;
private String resolution;
private String coverKey; private String coverKey;
private LocalDateTime createdAt; private LocalDateTime createdAt;
......
...@@ -8,16 +8,13 @@ import java.util.List; ...@@ -8,16 +8,13 @@ import java.util.List;
public interface AssetGenPipelineService { public interface AssetGenPipelineService {
/** 根据大纲 + 分集内容,AI 提取角色列表并保存(不含生图) */
List<Character> extractCharacters(Long projectId, Long tenantId); List<Character> extractCharacters(Long projectId, Long tenantId);
/** 根据大纲 + 分集内容,AI 提取场景列表并保存(不含生图) */
List<Scene> extractScenes(Long projectId, Long tenantId); 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); Character generateCharacterImage(Long characterId, Long tenantId);
/** 为指定场景调用 Seedream 生图并存 TOS */
Scene generateSceneImage(Long sceneId, Long tenantId); Scene generateSceneImage(Long sceneId, Long tenantId);
List<Character> listCharacters(Long projectId, Long tenantId); List<Character> listCharacters(Long projectId, Long tenantId);
...@@ -32,9 +29,8 @@ public interface AssetGenPipelineService { ...@@ -32,9 +29,8 @@ public interface AssetGenPipelineService {
void deleteScene(Long id, Long tenantId); void deleteScene(Long id, Long tenantId);
/** 上传用户自定义角色图,存 TOS 并更新 imageUrl */ Character uploadCharacterImage(Long characterId, Long tenantId, String viewType,
Character uploadCharacterImage(Long characterId, Long tenantId, InputStream stream, long size, String contentType, String ext); InputStream stream, long size, String contentType, String ext);
/** 上传用户自定义场景图,存 TOS 并更新 imageUrl */
Scene uploadSceneImage(Long sceneId, Long tenantId, InputStream stream, long size, String contentType, String ext); Scene uploadSceneImage(Long sceneId, Long tenantId, InputStream stream, long size, String contentType, String ext);
} }
...@@ -11,13 +11,14 @@ import com.yaoai.common.exception.ErrorCode; ...@@ -11,13 +11,14 @@ import com.yaoai.common.exception.ErrorCode;
import com.yaoai.domain.entity.Character; import com.yaoai.domain.entity.Character;
import com.yaoai.domain.entity.Episode; import com.yaoai.domain.entity.Episode;
import com.yaoai.domain.entity.Outline; import com.yaoai.domain.entity.Outline;
import com.yaoai.domain.entity.Project;
import com.yaoai.domain.entity.Scene; import com.yaoai.domain.entity.Scene;
import com.yaoai.domain.mapper.CharacterMapper; import com.yaoai.domain.mapper.CharacterMapper;
import com.yaoai.domain.mapper.EpisodeMapper; import com.yaoai.domain.mapper.EpisodeMapper;
import com.yaoai.domain.mapper.OutlineMapper; import com.yaoai.domain.mapper.OutlineMapper;
import com.yaoai.domain.mapper.ProjectMapper;
import com.yaoai.domain.mapper.SceneMapper; import com.yaoai.domain.mapper.SceneMapper;
import com.yaoai.pipeline.service.AssetGenPipelineService; import com.yaoai.pipeline.service.AssetGenPipelineService;
import com.yaoai.pipeline.service.ImageGenPipelineService;
import com.yaoai.storage.service.TosService; import com.yaoai.storage.service.TosService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
...@@ -31,58 +32,63 @@ import java.net.http.HttpRequest; ...@@ -31,58 +32,63 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CompletableFuture;
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { 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 = """ private static final String CHARACTER_SYSTEM = """
你是影视剧角色设定专家。根据剧情大纲和分集内容,提取主要角色并生成结构化信息 你是影视短剧角色设定专家。请根据给定的大纲和分集信息,提取主要角色并输出 JSON 数组
请以 JSON 数组返回,格式 返回格式如下
[ [
{ {
"name": "角色姓名", "name": "角色姓名",
"role_type": "角色类型(女主/男主/反派/配角)", "role_type": "female_lead / male_lead / antagonist / supporting",
"gender": "male female", "gender": "male or female",
"age": "年龄描述(如:18/30多岁)", "age": "年龄描述",
"personality": "性格特点(50字以内)", "personality": "性格特征,30字以内",
"costume": "主要服装描述50字以内)", "costume": "主要服装描述30字以内",
"visual_hint": "外貌特征50字以内)", "visual_hint": "外貌特征30字以内",
"image_prompt": "英文文生图 Prompt40词以内,描述角色正面全身,适合 2D anime 或现实风格)" "image_prompt": "英文角色设定图 prompt,突出服装、发型、体型、气质,用于统一角色三视图生成"
} }
] ]
只返回主要角色(3~8个),只返回 JSON 数组 只返回 3 到 8 个主要角色,只返回 JSON
"""; """;
private static final String SCENE_SYSTEM = """ private static final String SCENE_SYSTEM = """
你是影视剧场景设计专家。根据剧情大纲和分集内容,提取主要场景并生成结构化信息 你是影视短剧场景设定专家。请根据给定的大纲和分集信息,提取主要场景并输出 JSON 数组
请以 JSON 数组返回,格式 返回格式如下
[ [
{ {
"name": "场景名称", "name": "场景名称",
"scene_type": "indoor outdoor", "scene_type": "indoor or outdoor",
"description": "场景描述50字以内)", "description": "场景描述30字以内",
"image_prompt": "英文文生图 Prompt40词以内,描述场景环境,电影级质感)" "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 @Override
public List<Character> extractCharacters(Long projectId, Long tenantId) { 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); log.info("Extracting characters: projectId={}", projectId);
String raw = llmService.chat(ChatRequest.builder() String raw = llmService.chat(ChatRequest.builder()
...@@ -92,23 +98,22 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -92,23 +98,22 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
try { try {
List<Map<String, Object>> items = objectMapper.readValue(extractJson(raw), new TypeReference<>() {}); List<Map<String, Object>> items = objectMapper.readValue(extractJson(raw), new TypeReference<>() {});
List<Character> result = new ArrayList<>(); List<Character> result = new ArrayList<>();
for (Map<String, Object> m : items) { for (Map<String, Object> item : items) {
Character c = new Character(); Character character = new Character();
c.setProjectId(projectId); character.setProjectId(projectId);
c.setTenantId(tenantId); character.setTenantId(tenantId);
c.setName((String) m.getOrDefault("name", "")); character.setName((String) item.getOrDefault("name", ""));
c.setRoleType((String) m.getOrDefault("role_type", "")); character.setRoleType((String) item.getOrDefault("role_type", ""));
c.setGender((String) m.getOrDefault("gender", "")); character.setGender((String) item.getOrDefault("gender", ""));
c.setAge((String) m.getOrDefault("age", "")); character.setAge((String) item.getOrDefault("age", ""));
c.setPersonality((String) m.getOrDefault("personality", "")); character.setPersonality((String) item.getOrDefault("personality", ""));
c.setCostume((String) m.getOrDefault("costume", "")); character.setCostume((String) item.getOrDefault("costume", ""));
c.setVisualHint((String) m.getOrDefault("visual_hint", "")); character.setVisualHint((String) item.getOrDefault("visual_hint", ""));
c.setImagePrompt((String) m.getOrDefault("image_prompt", "")); character.setImagePrompt((String) item.getOrDefault("image_prompt", ""));
c.setStatus("draft"); character.setStatus("draft");
characterMapper.insert(c); characterMapper.insert(character);
result.add(c); result.add(character);
} }
log.info("Characters extracted: count={}", result.size());
return result; return result;
} catch (Exception e) { } catch (Exception e) {
log.error("Character extraction failed", e); log.error("Character extraction failed", e);
...@@ -128,19 +133,18 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -128,19 +133,18 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
try { try {
List<Map<String, Object>> items = objectMapper.readValue(extractJson(raw), new TypeReference<>() {}); List<Map<String, Object>> items = objectMapper.readValue(extractJson(raw), new TypeReference<>() {});
List<Scene> result = new ArrayList<>(); List<Scene> result = new ArrayList<>();
for (Map<String, Object> m : items) { for (Map<String, Object> item : items) {
Scene s = new Scene(); Scene scene = new Scene();
s.setProjectId(projectId); scene.setProjectId(projectId);
s.setTenantId(tenantId); scene.setTenantId(tenantId);
s.setName((String) m.getOrDefault("name", "")); scene.setName((String) item.getOrDefault("name", ""));
s.setSceneType((String) m.getOrDefault("scene_type", "indoor")); scene.setSceneType((String) item.getOrDefault("scene_type", "indoor"));
s.setDescription((String) m.getOrDefault("description", "")); scene.setDescription((String) item.getOrDefault("description", ""));
s.setImagePrompt((String) m.getOrDefault("image_prompt", "")); scene.setImagePrompt((String) item.getOrDefault("image_prompt", ""));
s.setStatus("draft"); scene.setStatus("draft");
sceneMapper.insert(s); sceneMapper.insert(scene);
result.add(s); result.add(scene);
} }
log.info("Scenes extracted: count={}", result.size());
return result; return result;
} catch (Exception e) { } catch (Exception e) {
log.error("Scene extraction failed", e); log.error("Scene extraction failed", e);
...@@ -150,72 +154,75 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -150,72 +154,75 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
@Override @Override
public Character generateCharacterImage(Long characterId, Long tenantId) { public Character generateCharacterImage(Long characterId, Long tenantId) {
Character c = characterMapper.selectById(characterId); Character character = requireCharacter(characterId, tenantId);
if (c == null || !tenantId.equals(c.getTenantId())) { Project project = loadProject(character.getProjectId(), tenantId);
throw new BizException(ErrorCode.NOT_FOUND, "角色不存在"); ProjectVisualStyle visualStyle = resolveProjectVisualStyle(project);
} if (character.getImagePrompt() == null || character.getImagePrompt().isBlank()) {
if (c.getImagePrompt() == null || c.getImagePrompt().isBlank()) {
throw new BizException(ErrorCode.INVALID_PARAM, "角色缺少图片 Prompt"); throw new BizException(ErrorCode.INVALID_PARAM, "角色缺少图片 Prompt");
} }
c.setStatus("generating"); character.setStatus("generating");
characterMapper.updateById(c); characterMapper.updateById(character);
try { try {
String imageUrl = seedreamService.generateImage(c.getImagePrompt()); CompletableFuture<CharacterViewAsset> frontFuture =
byte[] bytes = downloadBytes(imageUrl); CompletableFuture.supplyAsync(() -> generateCharacterViewAsset(character, visualStyle, "front"));
String key = TosService.buildKey(c.getTenantId(), c.getProjectId(), "characters", c.getName() + ".jpg"); CompletableFuture<CharacterViewAsset> sideFuture =
tosService.upload(key, new ByteArrayInputStream(bytes), bytes.length, "image/jpeg"); CompletableFuture.supplyAsync(() -> generateCharacterViewAsset(character, visualStyle, "side"));
CompletableFuture<CharacterViewAsset> backFuture =
CompletableFuture.supplyAsync(() -> generateCharacterViewAsset(character, visualStyle, "back"));
c.setImageUrl(tosService.publicUrl(key)); CompletableFuture.allOf(frontFuture, sideFuture, backFuture).join();
c.setImageTosKey(key);
c.setStatus("ready"); applyCharacterView(character, frontFuture.join().viewType(), frontFuture.join().key());
characterMapper.updateById(c); applyCharacterView(character, sideFuture.join().viewType(), sideFuture.join().key());
log.info("Character image generated: id={}, key={}", characterId, key); applyCharacterView(character, backFuture.join().viewType(), backFuture.join().key());
return c;
character.setStatus("ready");
characterMapper.updateById(character);
return character;
} catch (BizException e) { } catch (BizException e) {
c.setStatus("failed"); character.setStatus("failed");
characterMapper.updateById(c); characterMapper.updateById(character);
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
c.setStatus("failed"); character.setStatus("failed");
characterMapper.updateById(c); characterMapper.updateById(character);
throw new BizException(ErrorCode.INTERNAL_ERROR, "角色图片生成失败: " + e.getMessage()); throw new BizException(ErrorCode.INTERNAL_ERROR, "角色三视图生成失败: " + e.getMessage());
} }
} }
@Override @Override
public Scene generateSceneImage(Long sceneId, Long tenantId) { public Scene generateSceneImage(Long sceneId, Long tenantId) {
Scene s = sceneMapper.selectById(sceneId); Scene scene = sceneMapper.selectById(sceneId);
if (s == null || !tenantId.equals(s.getTenantId())) { if (scene == null || !tenantId.equals(scene.getTenantId())) {
throw new BizException(ErrorCode.NOT_FOUND, "场景不存在"); 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"); throw new BizException(ErrorCode.INVALID_PARAM, "场景缺少图片 Prompt");
} }
s.setStatus("generating"); scene.setStatus("generating");
sceneMapper.updateById(s); sceneMapper.updateById(scene);
try { try {
String imageUrl = seedreamService.generateImage(s.getImagePrompt()); String imageUrl = seedreamService.generateImage(scene.getImagePrompt());
byte[] bytes = downloadBytes(imageUrl); 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"); tosService.upload(key, new ByteArrayInputStream(bytes), bytes.length, "image/jpeg");
s.setImageUrl(tosService.publicUrl(key)); scene.setImageUrl(tosService.publicUrl(key));
s.setImageTosKey(key); scene.setImageTosKey(key);
s.setStatus("ready"); scene.setStatus("ready");
sceneMapper.updateById(s); sceneMapper.updateById(scene);
log.info("Scene image generated: id={}, key={}", sceneId, key); return scene;
return s;
} catch (BizException e) { } catch (BizException e) {
s.setStatus("failed"); scene.setStatus("failed");
sceneMapper.updateById(s); sceneMapper.updateById(scene);
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
s.setStatus("failed"); scene.setStatus("failed");
sceneMapper.updateById(s); sceneMapper.updateById(scene);
throw new BizException(ErrorCode.INTERNAL_ERROR, "场景图片生成失败: " + e.getMessage()); throw new BizException(ErrorCode.INTERNAL_ERROR, "场景图片生成失败: " + e.getMessage());
} }
} }
...@@ -252,80 +259,393 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -252,80 +259,393 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
@Override @Override
public void deleteCharacter(Long id, Long tenantId) { public void deleteCharacter(Long id, Long tenantId) {
Character c = characterMapper.selectById(id); Character character = characterMapper.selectById(id);
if (c != null && tenantId.equals(c.getTenantId())) characterMapper.deleteById(id); if (character != null && tenantId.equals(character.getTenantId())) {
characterMapper.deleteById(id);
}
} }
@Override @Override
public void deleteScene(Long id, Long tenantId) { public void deleteScene(Long id, Long tenantId) {
Scene s = sceneMapper.selectById(id); Scene scene = sceneMapper.selectById(id);
if (s != null && tenantId.equals(s.getTenantId())) sceneMapper.deleteById(id); if (scene != null && tenantId.equals(scene.getTenantId())) {
sceneMapper.deleteById(scene);
}
} }
@Override @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) { InputStream stream, long size, String contentType, String ext) {
Character c = characterMapper.selectById(characterId); Character character = requireCharacter(characterId, tenantId);
if (c == null || !tenantId.equals(c.getTenantId())) String normalizedViewType = normalizeCharacterViewType(viewType);
throw new RuntimeException("角色不存在或无权限"); String safeExt = (ext == null || ext.isBlank()) ? "jpg" : ext;
String key = "projects/" + c.getProjectId() + "/characters/" + characterId + "/image." + ext; String key = "projects/" + character.getProjectId() + "/characters/" + characterId + "/" + normalizedViewType + "." + safeExt;
tosService.upload(key, stream, size, contentType); tosService.upload(key, stream, size, contentType);
c.setImageTosKey(key); applyCharacterView(character, normalizedViewType, key);
c.setImageUrl(tosService.publicUrl(key)); character.setStatus("ready");
c.setStatus("ready"); characterMapper.updateById(character);
characterMapper.updateById(c); return character;
return c;
} }
@Override @Override
public Scene uploadSceneImage(Long sceneId, Long tenantId, public Scene uploadSceneImage(Long sceneId, Long tenantId,
InputStream stream, long size, String contentType, String ext) { InputStream stream, long size, String contentType, String ext) {
Scene s = sceneMapper.selectById(sceneId); Scene scene = sceneMapper.selectById(sceneId);
if (s == null || !tenantId.equals(s.getTenantId())) if (scene == null || !tenantId.equals(scene.getTenantId())) {
throw new RuntimeException("场景不存在或无权限"); 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); tosService.upload(key, stream, size, contentType);
s.setImageTosKey(key); scene.setImageTosKey(key);
s.setImageUrl(tosService.publicUrl(key)); scene.setImageUrl(tosService.publicUrl(key));
s.setStatus("ready"); scene.setStatus("ready");
sceneMapper.updateById(s); sceneMapper.updateById(scene);
return s; 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) { private String buildProjectContext(Long projectId, Long tenantId) {
Outline outline = outlineMapper.findLatestByProject(projectId, tenantId); Outline outline = outlineMapper.findLatestByProject(projectId, tenantId);
List<Episode> episodes = episodeMapper.findByProject(projectId, tenantId); List<Episode> episodes = episodeMapper.findByProject(projectId, tenantId);
StringBuilder sb = new StringBuilder(); StringBuilder builder = new StringBuilder();
if (outline != null) { if (outline != null) {
sb.append("剧名:").append(outline.getTitle()).append("\n"); builder.append("剧名:").append(outline.getTitle()).append('\n');
sb.append("类型:").append(outline.getGenre()).append("\n"); builder.append("类型:").append(outline.getGenre()).append('\n');
sb.append("故事梗概:").append(outline.getSynopsis()).append("\n\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"); builder.append("Character visual style requirements:\n");
for (Episode ep : episodes) { builder.append("- Selected project style: ").append(visualStyle.label()).append('\n');
sb.append("第").append(ep.getEpisodeNumber()).append("集 《").append(ep.getTitle()).append("》:"); builder.append("- Style direction: ").append(visualStyle.extractionGuidance()).append('\n');
sb.append(ep.getSummary()).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 { private byte[] downloadBytes(String url) throws Exception {
HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).GET().build(); HttpRequest request = HttpRequest.newBuilder().uri(URI.create(url)).GET().build();
HttpResponse<byte[]> resp = httpClient.send(req, HttpResponse.BodyHandlers.ofByteArray()); HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (resp.statusCode() != 200) throw new BizException(ErrorCode.INTERNAL_ERROR, "下载图片失败 HTTP " + resp.statusCode()); if (response.statusCode() != 200) {
return resp.body(); throw new BizException(ErrorCode.INTERNAL_ERROR, "下载图片失败 HTTP " + response.statusCode());
}
return response.body();
} }
private String extractJson(String raw) { private String extractJson(String raw) {
String s = raw.strip(); String text = raw == null ? "" : raw.strip();
if (s.startsWith("```")) { if (text.startsWith("```")) {
int start = s.indexOf('\n') + 1; int start = text.indexOf('\n') + 1;
int end = s.lastIndexOf("```"); int end = text.lastIndexOf("```");
if (end > start) s = s.substring(start, end).strip(); 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