mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-11-15 07:20:40 +00:00
feat: 完善短剧创作、技能目录与媒体任务恢复
保存原始创作输入,整合剧本打磨、分场规划与流式进度,接入审美及导演技能、角色声音和音乐;保持已批准媒体并完善请求去重与恢复。 统一增量 SQL 目录,补齐新安装字段并修正示例插入列,更新迁移说明;修复测试夹具与当前审阅、重启恢复行为的断言。验证:相关模块编译通过,短剧及 Atlas 回归 337 项、SSE 回归 3 项均通过。
This commit is contained in:
@@ -1,51 +0,0 @@
|
||||
package org.ruoyi.agent;
|
||||
|
||||
import dev.langchain4j.service.SystemMessage;
|
||||
import dev.langchain4j.service.UserMessage;
|
||||
import dev.langchain4j.service.V;
|
||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptResult;
|
||||
|
||||
/**
|
||||
* 短剧剧本打磨 Agent —— 使用 langchain4j AiServices 结构化输出
|
||||
* <p>
|
||||
* 框架自动生成 JSON Schema 并强制 LLM 返回符合结构的数据,无需手工 parse。
|
||||
*
|
||||
* @author ageerle
|
||||
*/
|
||||
public interface ShortDramaScriptAgent {
|
||||
|
||||
@SystemMessage("""
|
||||
你是顶级短剧编剧和创意总监。请根据用户的一个创意想法,创作完整的短剧剧本。
|
||||
|
||||
【核心原则 - 最高优先级】
|
||||
1. 剧本必须完整、有张力、有画面感
|
||||
2. 角色要有鲜明性格,不是工具人
|
||||
3. 情节紧凑,每句台词都推动剧情
|
||||
4. 场景描写具体,让分镜师能直接画出画面
|
||||
5. 对话自然有力,避免废话
|
||||
|
||||
【剧本格式】
|
||||
使用标准剧本格式,包含以下元素:
|
||||
1. 场景头(Scene Heading):内景/外景+地点+时间,如"内景 客厅 清晨"
|
||||
2. 场景描述(Scene Description):简洁描述场景环境、布局、关键道具
|
||||
3. 动作描述(Action):描述角色的动作、表情、行为,连续段落形式
|
||||
4. 对话(Dialogue):角色名: 台词内容
|
||||
5. 画外音(Voiceover):旁白、独白、回忆中的声音
|
||||
|
||||
【剧本长度要求】
|
||||
- scriptText:1000-3000字,含完整的开场、冲突发展、高潮、结尾
|
||||
- outlineText:400-800字,概述完整故事线
|
||||
|
||||
【角色塑造要求】
|
||||
- 每个角色要有明确的性格标签(如:霸道总裁、温柔女医、腹黑谋士)
|
||||
- 角色之间要有清晰的关系和冲突
|
||||
- 对话要符合角色性格
|
||||
|
||||
【情节要求】
|
||||
- 必须有清晰的冲突和反转
|
||||
- 情绪节奏要有起伏(紧张→舒缓→爆发)
|
||||
- 结尾要有记忆点(反转/留白/情感升华)
|
||||
""")
|
||||
@UserMessage("用户期望项目名:{{projectName}}\n用户创意:{{idea}}")
|
||||
ShortDramaScriptResult polish(@V("projectName") String projectName, @V("idea") String idea);
|
||||
}
|
||||
@@ -44,9 +44,10 @@ public final class ShortDramaImageConstants {
|
||||
/** 项目视觉风格 → 生图 prompt 后缀映射,确保同项目所有图片风格一致 */
|
||||
public static final java.util.Map<String, String> ART_STYLE_PROMPTS = java.util.Map.of(
|
||||
"american-comic", "美式漫画风格,粗线条,高饱和度色彩,强烈光影对比",
|
||||
"chinese-3d", "国风电影级三维动画,Chinese donghua cinematic 3D animation,统一的角色三维建模和辨识度,东方人物面部结构与自然身体比例,PBR衣料、木石金属材质和细腻服饰纹理,立体体积光与可解释的环境灯光,克制的次表面散射,自然表情和电影镜头景深,国风建筑与服饰遵循当前故事年代,色彩清透且光色统一;不使用真人摄影实拍、2D赛璐璐平涂、黑色漫画线稿、塑料玩偶或油亮蜡像肤质",
|
||||
"chinese-comic", "现代国漫动画风格,Chinese donghua 2D comic style,赛璐璐平涂上色,干净锐利的黑色线稿,平面化光影无真实景深,动漫人物比例(略放大双眼、修长身形),皮肤平滑无毛孔无写实肤质,国风服饰剪裁与材质细节清晰,色彩饱满通透,画面精致干净;禁止真人写实、摄影实拍、3D渲染、CGI、厚涂油画、写实皮肤纹理、景深虚化",
|
||||
"japanese-anime", "现代日系动漫风格,赛璐璐上色,清晰干净的线条,视觉小说CG感,高质量2D风格",
|
||||
"realistic", "真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感"
|
||||
"realistic", "真人影视写实,自然人物比例与未磨皮肤质,真实衣料与环境材质,可解释的光源和丰富中间调,色彩与维护程度服从故事,不使用美颜滤镜、塑料CG或电商棚拍质感"
|
||||
);
|
||||
|
||||
/** 默认视觉风格 */
|
||||
|
||||
@@ -67,6 +67,7 @@ public class ChatModelController extends BaseController {
|
||||
} else {
|
||||
internalQuery.setCategory(query.getCategory());
|
||||
}
|
||||
if (query != null) internalQuery.setProviderCode(query.getProviderCode());
|
||||
return R.ok(chatModelService.queryAvailableList(internalQuery).stream()
|
||||
.map(ChatModelSelectVo::from)
|
||||
.toList());
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.ruoyi.controller.shortdrama;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.common.satoken.utils.LoginHelper;
|
||||
import org.ruoyi.service.shortdrama.impl.ShortDramaCharacterVoiceService;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}")
|
||||
public class ShortDramaCharacterVoiceController {
|
||||
private final ShortDramaCharacterVoiceService service;
|
||||
@GetMapping("/characters/{characterId}/voice")
|
||||
public R<?> get(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId) throws Exception {return R.ok(service.get(projectId,characterId,LoginHelper.getUserId()));}
|
||||
@PutMapping("/characters/{characterId}/voice")
|
||||
public R<?> settings(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@RequestBody ShortDramaCharacterVoiceService.Settings request) throws Exception {return R.ok(service.settings(projectId,characterId,LoginHelper.getUserId(),request));}
|
||||
@PostMapping("/characters/{characterId}/voice/generate")
|
||||
public R<?> generate(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@RequestBody ShortDramaCharacterVoiceService.Generate request) throws Exception {return R.ok(service.generate(projectId,characterId,LoginHelper.getUserId(),request));}
|
||||
@GetMapping("/characters/{characterId}/voice/jobs/{id}")
|
||||
public R<?> poll(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@PathVariable("id") String id) throws Exception {return R.ok(service.poll(projectId,characterId,LoginHelper.getUserId(),id));}
|
||||
@PostMapping("/characters/{characterId}/voice/upload")
|
||||
public R<?> upload(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@RequestPart("file") MultipartFile file) throws Exception {return R.ok(service.upload(projectId,characterId,LoginHelper.getUserId(),file));}
|
||||
@PostMapping("/characters/{characterId}/voice/select")
|
||||
public R<?> select(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@RequestBody ShortDramaCharacterVoiceService.Selection request) throws Exception {return R.ok(service.select(projectId,characterId,LoginHelper.getUserId(),request));}
|
||||
@GetMapping("/characters/{characterId}/voice/samples/{id}")
|
||||
public ResponseEntity<byte[]> audio(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@PathVariable("id") String id) throws Exception {
|
||||
return ResponseEntity.ok().contentType(MediaType.valueOf("audio/mpeg")).cacheControl(CacheControl.noStore()).body(Files.readAllBytes(service.audio(projectId,characterId,LoginHelper.getUserId(),id)));
|
||||
}
|
||||
@GetMapping("/voices/storyboards/{storyboardId}")
|
||||
public R<?> preview(@PathVariable("projectId") Long projectId,@PathVariable("storyboardId") Long storyboardId,@RequestParam(value="model",required=false) String model) throws Exception {
|
||||
var plan=service.preview(projectId,storyboardId,LoginHelper.getUserId(),model);
|
||||
return R.ok(Map.of("bindings",plan.bindings(),"direction",plan.direction(),"issues",plan.issues(),"explicit",plan.explicit(),"references",plan.fingerprints(),"speakerIds",plan.speakerIds()));
|
||||
}
|
||||
@PutMapping("/voices/storyboards/{storyboardId}")
|
||||
public R<?> speakers(@PathVariable("projectId") Long projectId,@PathVariable("storyboardId") Long storyboardId,@RequestBody ShortDramaCharacterVoiceService.SpeakersRequest request) throws Exception {
|
||||
return R.ok(Map.of("continuityJson",service.speakers(projectId,storyboardId,LoginHelper.getUserId(),request).getContinuityJson()));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.ruoyi.common.chat.entity.media.MediaGenerationResponse;
|
||||
@@ -18,8 +20,10 @@ import org.ruoyi.domain.bo.shortdrama.ShortDramaAudioBo;
|
||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaLocationBo;
|
||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaProjectBo;
|
||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptBo;
|
||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptRevisionBo;
|
||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaStoryboardBo;
|
||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaIdeaBo;
|
||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaImageRevisionBo;
|
||||
import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterVo;
|
||||
import org.ruoyi.domain.vo.shortdrama.ShortDramaCharacterAppearanceVo;
|
||||
import org.ruoyi.domain.vo.shortdrama.ShortDramaComposeVideoVo;
|
||||
@@ -31,8 +35,10 @@ import org.ruoyi.domain.vo.shortdrama.ShortDramaScriptVo;
|
||||
import org.ruoyi.domain.vo.shortdrama.ShortDramaStoryboardVo;
|
||||
import org.ruoyi.service.shortdrama.IShortDramaService;
|
||||
import org.ruoyi.service.shortdrama.IShortDramaVideoComposeService;
|
||||
import org.ruoyi.service.shortdrama.impl.ShortDramaTiming;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
@@ -57,6 +63,11 @@ import java.util.List;
|
||||
@RequestMapping("/short-drama")
|
||||
public class ShortDramaController {
|
||||
|
||||
@ExceptionHandler(ShortDramaTiming.ReviewException.class)
|
||||
public R<Void> handleTimingReview(ShortDramaTiming.ReviewException error) {
|
||||
return R.fail(error.getMessage());
|
||||
}
|
||||
|
||||
private final IShortDramaService shortDramaService;
|
||||
|
||||
private final IShortDramaVideoComposeService videoComposeService;
|
||||
@@ -131,10 +142,30 @@ public class ShortDramaController {
|
||||
return R.ok(shortDramaService.saveStoryboard(bo, LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
@PostMapping("/storyboard/add")
|
||||
public R<ShortDramaStoryboardVo> addStoryboard(@RequestParam Long projectId, @RequestParam Long scriptId,
|
||||
@RequestParam(required = false) Long afterId) {
|
||||
return R.ok(shortDramaService.addStoryboard(projectId, scriptId, afterId, LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/storyboard/{storyboardId}")
|
||||
public R<Void> deleteStoryboard(@PathVariable Long storyboardId) {
|
||||
shortDramaService.deleteStoryboard(storyboardId, LoginHelper.getUserId());
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/storyboard/{storyboardId}/generate-video")
|
||||
public R<ShortDramaStoryboardVo> generateVideo(@NotNull @PathVariable Long storyboardId,
|
||||
@NotBlank @RequestParam String model) {
|
||||
return R.ok(shortDramaService.generateVideo(storyboardId, model, LoginHelper.getUserId()));
|
||||
@NotBlank @RequestParam String model,
|
||||
@RequestParam(required = false) String requestId,
|
||||
@RequestParam(defaultValue = "false") boolean regenerate) {
|
||||
return R.ok(shortDramaService.generateVideo(storyboardId, model, LoginHelper.getUserId(), requestId, regenerate));
|
||||
}
|
||||
|
||||
@GetMapping("/storyboard/{storyboardId}/video-submissions/{requestId}")
|
||||
public R<org.ruoyi.service.shortdrama.impl.ShortDramaVideoSubmissionStore.Submission> videoSubmission(
|
||||
@NotNull @PathVariable Long storyboardId, @PathVariable String requestId) {
|
||||
return R.ok(shortDramaService.videoSubmission(storyboardId, requestId, LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
@GetMapping("/storyboard/{storyboardId}/video-result")
|
||||
@@ -145,8 +176,10 @@ public class ShortDramaController {
|
||||
|
||||
@PostMapping("/{projectId}/generate-all-videos")
|
||||
public R<List<ShortDramaStoryboardVo>> generateAllVideos(@NotNull @PathVariable Long projectId,
|
||||
@NotBlank @RequestParam String model) {
|
||||
return R.ok(shortDramaService.generateAllVideos(projectId, model, LoginHelper.getUserId()));
|
||||
@NotBlank @RequestParam String model,
|
||||
@RequestParam(required = false) Integer sceneStart,
|
||||
@RequestParam(required = false) Integer sceneCount) {
|
||||
return R.ok(shortDramaService.generateAllVideos(projectId, model, LoginHelper.getUserId(), sceneStart, sceneCount));
|
||||
}
|
||||
|
||||
@PostMapping("/{projectId}/compose-video")
|
||||
@@ -180,10 +213,11 @@ public class ShortDramaController {
|
||||
|
||||
// ==================== 阶段式流水线端点 ====================
|
||||
|
||||
/** Phase 1: 剧本打磨 */
|
||||
/** 按修改意见重新生成固定格式剧本 */
|
||||
@PostMapping("/{projectId}/polish-script")
|
||||
public R<ShortDramaDetailVo> polishScript(@NotNull @PathVariable Long projectId, @RequestParam(required = false) String model) {
|
||||
return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId(), model));
|
||||
public R<ShortDramaDetailVo> polishScript(@NotNull @PathVariable Long projectId,
|
||||
@Valid @RequestBody ShortDramaScriptRevisionBo revision) {
|
||||
return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId(), revision.getInstruction()));
|
||||
}
|
||||
|
||||
// ==================== 资产分析 ====================
|
||||
@@ -196,27 +230,73 @@ public class ShortDramaController {
|
||||
}
|
||||
|
||||
// ==================== 分镜流水线 ====================
|
||||
@PostMapping("/{projectId}/analyze-assets/stream")
|
||||
public SseEmitter analyzeAssetsStream(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId, @RequestParam(required = false) String model,
|
||||
@RequestParam(required = false) String requestId) {
|
||||
return shortDramaService.analyzeAssetsStream(projectId, scriptId, LoginHelper.getUserId(), model, requestId);
|
||||
}
|
||||
|
||||
@GetMapping("/{projectId}/analyze-assets/status")
|
||||
public R<java.util.Map<String, Object>> assetAnalysisStatus(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId, @RequestParam(required = false) String requestId) {
|
||||
return R.ok(shortDramaService.assetAnalysisStatus(projectId, scriptId, requestId, LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
|
||||
/** Phase 3-6: 分镜规划+摄影规则+表演指导+分镜细化 */
|
||||
@PostMapping("/{projectId}/plan-storyboard")
|
||||
public R<List<ShortDramaStoryboardVo>> planStoryboard(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId,
|
||||
@RequestParam(required = false) String model) {
|
||||
return R.ok(shortDramaService.planStoryboard(projectId, scriptId, model, LoginHelper.getUserId()));
|
||||
@RequestParam(required = false) String model,
|
||||
@Min(1) @Max(15) @RequestParam(defaultValue = "1") Integer minimumShotSeconds) {
|
||||
return R.ok(shortDramaService.planStoryboard(projectId, scriptId, model, LoginHelper.getUserId(), minimumShotSeconds));
|
||||
}
|
||||
|
||||
/** Phase 3-6: SSE 流式生成分镜,持续推送规划和细化进度 */
|
||||
@PostMapping("/{projectId}/plan-storyboard/review")
|
||||
@PostMapping({"/{projectId}/plan-storyboard/review", "/{projectId}/import-reviewed-plan"})
|
||||
public R<Integer> importReviewedPlan(@PathVariable Long projectId,
|
||||
@Valid @RequestBody org.ruoyi.domain.bo.shortdrama.ShortDramaReviewedPlanBo review) {
|
||||
return R.ok(shortDramaService.importReviewedPlan(projectId,review,LoginHelper.getUserId()));
|
||||
try {
|
||||
return R.ok(shortDramaService.importReviewedPlan(projectId,review,LoginHelper.getUserId()));
|
||||
} catch (IllegalArgumentException | IllegalStateException rejected) {
|
||||
// The global handler intentionally hides even ServiceException messages. This normal,
|
||||
// owner-checked endpoint returns only known domain validation feedback, without a stack/cause.
|
||||
String message = org.ruoyi.service.shortdrama.impl.ShortDramaReviewedImportMessages.failureMessage(rejected);
|
||||
if (message == null) throw rejected;
|
||||
return R.fail(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only recovery proof; it never submits a model request or promotes an unchecked draft. */
|
||||
@GetMapping("/{projectId}/checkpoint-status")
|
||||
public R<java.util.Map<String, Object>> storyboardCheckpointStatus(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId, @RequestParam(required = false) String model,
|
||||
@Min(1) @Max(15) @RequestParam(defaultValue = "1") Integer minimumShotSeconds) {
|
||||
return R.ok(shortDramaService.storyboardCheckpointStatus(projectId, scriptId, model, LoginHelper.getUserId(), minimumShotSeconds));
|
||||
}
|
||||
|
||||
/** Unreviewed native output only; viewing it does not validate or import any panel. */
|
||||
@GetMapping("/{projectId}/plan-storyboard/candidates")
|
||||
public R<java.util.Map<String, Object>> storyboardCandidates(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId, @RequestParam(required = false) String model,
|
||||
@Min(1) @Max(15) @RequestParam(defaultValue = "4") Integer minimumShotSeconds) {
|
||||
return R.ok(shortDramaService.storyboardCandidates(projectId, scriptId, model, LoginHelper.getUserId(), minimumShotSeconds));
|
||||
}
|
||||
|
||||
@GetMapping("/{projectId}/plan-storyboard/status")
|
||||
public R<java.util.Map<String, Object>> storyboardPlanningStatus(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId, @RequestParam(required = false) String requestId) {
|
||||
return R.ok(shortDramaService.storyboardPlanningStatus(projectId, scriptId, requestId, LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
@PostMapping("/{projectId}/plan-storyboard/stream")
|
||||
public SseEmitter planStoryboardStream(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId,
|
||||
@RequestParam(required = false) String model) {
|
||||
return shortDramaService.planStoryboardStream(projectId, scriptId, model, LoginHelper.getUserId());
|
||||
@RequestParam(required = false) String model,
|
||||
@Min(1) @Max(15) @RequestParam(defaultValue = "1") Integer minimumShotSeconds,
|
||||
@RequestParam(required = false) String requestId) {
|
||||
return shortDramaService.planStoryboardStream(projectId, scriptId, model, LoginHelper.getUserId(), minimumShotSeconds, requestId);
|
||||
}
|
||||
|
||||
/** Phase 4: 重新生成摄影规则 */
|
||||
@@ -328,15 +408,19 @@ public class ShortDramaController {
|
||||
@PostMapping("/location/{locationId}/generate-image")
|
||||
public R<ShortDramaLocationVo> generateLocationImage(@NotNull @PathVariable Long locationId,
|
||||
@NotBlank @RequestParam String model,
|
||||
@RequestParam(required = false) String referenceImageUrl) {
|
||||
return R.ok(shortDramaService.generateLocationImage(locationId, model, referenceImageUrl, LoginHelper.getUserId()));
|
||||
@RequestParam(required = false) String referenceImageUrl,
|
||||
@Valid @RequestBody(required = false) org.ruoyi.domain.bo.shortdrama.ShortDramaLocationImageRevisionBo revision) {
|
||||
return R.ok(shortDramaService.generateLocationImage(locationId, model, referenceImageUrl,
|
||||
revision == null ? null : revision.getRevisionRequirements(), LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
@PostMapping("/location/{locationId}/regenerate")
|
||||
public R<ShortDramaLocationVo> regenerateLocationImage(@NotNull @PathVariable Long locationId,
|
||||
@NotBlank @RequestParam String model,
|
||||
@RequestParam(required = false) String referenceImageUrl) {
|
||||
return R.ok(shortDramaService.regenerateLocationImage(locationId, model, referenceImageUrl, LoginHelper.getUserId()));
|
||||
@RequestParam(required = false) String referenceImageUrl,
|
||||
@Valid @RequestBody(required = false) org.ruoyi.domain.bo.shortdrama.ShortDramaLocationImageRevisionBo revision) {
|
||||
return R.ok(shortDramaService.regenerateLocationImage(locationId, model, referenceImageUrl,
|
||||
revision == null ? null : revision.getRevisionRequirements(), LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
@PostMapping("/location/{locationId}/select-image")
|
||||
@@ -400,8 +484,12 @@ public class ShortDramaController {
|
||||
public R<MediaGenerationResponse> startImage(@NotBlank @RequestParam String assetType,
|
||||
@NotNull @RequestParam Long assetId,
|
||||
@NotBlank @RequestParam String model,
|
||||
@RequestParam(required = false) String referenceImageUrl) {
|
||||
return R.ok(shortDramaService.startImageGeneration(assetType, assetId, model, referenceImageUrl, LoginHelper.getUserId()));
|
||||
@RequestParam(required = false) String referenceImageUrl,
|
||||
@Valid @RequestBody(required = false) ShortDramaImageRevisionBo revision) {
|
||||
return R.ok(shortDramaService.startImageGeneration(assetType, assetId, model, referenceImageUrl,
|
||||
revision == null ? null : revision.getReferencePurpose(),
|
||||
revision == null ? null : revision.getRevisionRequirements(),
|
||||
revision == null ? null : revision.getStyleReferenceImageUrls(), LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
/** 轮询确认形象图片并保存 */
|
||||
|
||||
@@ -87,7 +87,7 @@ public class ShortDramaMaterialController {
|
||||
ObjectNode image = replace >= 0 ? ((ObjectNode)list.get(replace)).deepCopy() : JSON.createObjectNode();
|
||||
image.remove("items");
|
||||
image.put("url", "/short-drama/"+projectId+"/materials/file/"+name);
|
||||
image.put("filename", Objects.toString(file.getOriginalFilename(), "真实素材"));
|
||||
image.put("filename", Objects.toString(file.getOriginalFilename(), "素材"));
|
||||
image.put("type", type);
|
||||
if (replace >= 0) list.set(replace, image); else list.add(image);
|
||||
}
|
||||
@@ -155,8 +155,8 @@ public class ShortDramaMaterialController {
|
||||
owner(projectId);var shot=boards.selectById(shotId);
|
||||
if(shot==null || !projectId.equals(shot.getProjectId()) || "generating".equals(shot.getVideoStatus()))throw new IllegalArgumentException("镜头不存在或正在生成");
|
||||
var continuity=(ObjectNode)JSON.readTree(Objects.toString(shot.getContinuityJson(),"{}"));
|
||||
if(!"direct_insert".equals(continuity.path("source_media").path("mode").asText()))throw new IllegalArgumentException("仅支持真实素材镜头");
|
||||
var images=items(continuity);if(images.isEmpty())throw new IllegalArgumentException("请先上传真实素材");
|
||||
if(!"direct_insert".equals(continuity.path("source_media").path("mode").asText()))throw new IllegalArgumentException("仅支持素材镜头");
|
||||
var images=items(continuity);if(images.isEmpty())throw new IllegalArgumentException("请先上传素材");
|
||||
Path dir=directory(projectId);String job=UUID.randomUUID().toString();
|
||||
List<String> command=new ArrayList<>(List.of("ffmpeg","-v","error","-y"));List<String> filters=new ArrayList<>();StringBuilder concat=new StringBuilder();
|
||||
double duration=shot.getDurationSeconds(), part=duration/images.size();
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.ruoyi.controller.shortdrama;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.common.satoken.utils.LoginHelper;
|
||||
import org.ruoyi.service.shortdrama.impl.ShortDramaMusicService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}/music")
|
||||
public class ShortDramaMusicController {
|
||||
private final ShortDramaMusicService service;
|
||||
@GetMapping public R<?> list(@PathVariable Long projectId) throws Exception {return R.ok(service.list(projectId,LoginHelper.getUserId()));}
|
||||
@PostMapping public R<?> generate(@PathVariable Long projectId,@RequestBody ShortDramaMusicService.MusicRequest request) throws Exception {return R.ok(service.generate(projectId,LoginHelper.getUserId(),request));}
|
||||
@GetMapping("/{id}") public R<?> poll(@PathVariable Long projectId,@PathVariable String id) throws Exception {return R.ok(service.poll(projectId,LoginHelper.getUserId(),id));}
|
||||
@PostMapping("/{id}/use") public R<?> use(@PathVariable Long projectId,@PathVariable String id,@RequestBody ShortDramaMusicService.Usage usage) throws Exception {return R.ok(service.use(projectId,LoginHelper.getUserId(),id,usage));}
|
||||
@PostMapping("/write") public R<?> write(@PathVariable Long projectId,@RequestBody ShortDramaMusicService.BriefRequest request) {return R.ok(service.write(projectId,LoginHelper.getUserId(),request));}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package org.ruoyi.controller.shortdrama;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.service.shortdrama.impl.ShortDramaSkillCatalog;
|
||||
import org.ruoyi.service.shortdrama.impl.ShortDramaSkillMarket;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.List;
|
||||
|
||||
@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/skills")
|
||||
public class ShortDramaSkillController {
|
||||
private final ShortDramaSkillCatalog catalog;
|
||||
private final ShortDramaSkillMarket market;
|
||||
@GetMapping("/market") public R<List<ShortDramaSkillMarket.Entry>> market() { return R.ok(market.list()); }
|
||||
@GetMapping public R<List<ShortDramaSkillCatalog.Option>> list(@RequestParam(required=false) String type,
|
||||
@RequestParam(defaultValue="false") boolean includeDisabled) { return R.ok(catalog.list(type, includeDisabled)); }
|
||||
@GetMapping("/{name}") public R<ShortDramaSkillCatalog.Skill> detail(@PathVariable String name) { return R.ok(catalog.detail(name)); }
|
||||
@PostMapping @SaCheckPermission("shortDrama:skill:manage")
|
||||
public R<ShortDramaSkillCatalog.Skill> create(@RequestBody ShortDramaSkillCatalog.Save body) { return R.ok(catalog.save(body.name(), body, true)); }
|
||||
@PutMapping("/{name}") @SaCheckPermission("shortDrama:skill:manage")
|
||||
public R<ShortDramaSkillCatalog.Skill> update(@PathVariable String name, @RequestBody ShortDramaSkillCatalog.Save body) { return R.ok(catalog.save(name, body, false)); }
|
||||
}
|
||||
@@ -11,12 +11,20 @@ public class ShortDramaVisualAssetController {
|
||||
private final ShortDramaVisualAssetService service;
|
||||
public record ReviseFrame(String prompt,String model) { }
|
||||
@PostMapping("/{assetId}/revise") public R<Map<String,Object>> revise(@PathVariable Long projectId,@PathVariable Long assetId,@RequestBody ReviseFrame body) {return R.ok(service.reviseFrame(projectId,assetId,body.prompt(),body.model(),LoginHelper.getUserId()));}
|
||||
public record StartFrame(String imageUrl) { }
|
||||
@PostMapping("/{storyboardId}/start-frame") public R<Map<String,Object>> saveStartFrame(@PathVariable Long projectId,@PathVariable Long storyboardId,@RequestBody StartFrame body) {return R.ok(service.saveManualStartFrame(projectId,storyboardId,body.imageUrl(),LoginHelper.getUserId()));}
|
||||
@DeleteMapping("/{storyboardId}/start-frame") public R<Map<String,Object>> removeStartFrame(@PathVariable Long projectId,@PathVariable Long storyboardId) {return R.ok(service.removeManualStartFrame(projectId,storyboardId,LoginHelper.getUserId()));}
|
||||
public record Prop(String title,String prompt,java.util.List<Integer> shotNumbers,String imageUrl) { }
|
||||
@PostMapping("/prop") public R<Map<String,Object>> prop(@PathVariable Long projectId,@RequestBody Prop body) {return R.ok(service.saveProp(projectId,body.title(),body.prompt(),body.shotNumbers(),body.imageUrl(),LoginHelper.getUserId()));}
|
||||
public record PropUpdate(String prompt,String referenceImageUrl) { }
|
||||
@PutMapping("/prop/{assetId}") public R<Map<String,Object>> updateProp(@PathVariable Long projectId,@PathVariable Long assetId,@RequestBody PropUpdate body) {return R.ok(service.updateProp(projectId,assetId,body.prompt(),body.referenceImageUrl(),LoginHelper.getUserId()));}
|
||||
@PostMapping("/prop/{assetId}/regenerate") public R<Map<String,Object>> regenerateProp(@PathVariable Long projectId,@PathVariable Long assetId,@RequestParam String model) {return R.ok(service.regenerateProp(projectId,assetId,model,LoginHelper.getUserId()));}
|
||||
@PostMapping("/prop/{assetId}/undo") public R<Map<String,Object>> undoProp(@PathVariable Long projectId,@PathVariable Long assetId) {return R.ok(service.undoProp(projectId,assetId,LoginHelper.getUserId()));}
|
||||
@PostMapping("/generate-opening") public R<Map<String,Object>> opening(@PathVariable Long projectId,@RequestParam String model) {return R.ok(service.generateOpening(projectId,model,LoginHelper.getUserId()));}
|
||||
@PostMapping("/generate-range") public R<Map<String,Object>> range(@PathVariable Long projectId,@RequestParam String model,@RequestParam int start,@RequestParam int end) {return R.ok(service.generateRange(projectId,model,LoginHelper.getUserId(),start,end));}
|
||||
@GetMapping public R<Map<String,Object>> status(@PathVariable Long projectId) {return R.ok(service.status(projectId,LoginHelper.getUserId()));}
|
||||
@PostMapping("/plan") public R<Map<String,Object>> plan(@PathVariable Long projectId,@RequestParam(required=false) String model) {return R.ok(service.plan(projectId,model,LoginHelper.getUserId()));}
|
||||
@PostMapping("/recover") public R<Map<String,Object>> recover(@PathVariable Long projectId) {return R.ok(service.recover(projectId,LoginHelper.getUserId()));}
|
||||
@PostMapping("/generate-props") public R<Map<String,Object>> missingProps(@PathVariable Long projectId,@RequestParam String model) {return R.ok(service.generateMissingProps(projectId,model,LoginHelper.getUserId()));}
|
||||
@PostMapping("/generate") public R<Map<String,Object>> generate(@PathVariable Long projectId,@RequestParam String model) {return R.ok(service.generate(projectId,model,LoginHelper.getUserId()));}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.ruoyi.controller.shortdrama;
|
||||
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.service.shortdrama.impl.ShortDramaVoiceException;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestControllerAdvice(assignableTypes={ShortDramaCharacterVoiceController.class,ShortDramaController.class})
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class ShortDramaVoiceErrors {
|
||||
@ExceptionHandler(ShortDramaVoiceException.class)
|
||||
public R<Void> validation(ShortDramaVoiceException error) {return R.fail(error.getMessage());}
|
||||
}
|
||||
@@ -15,9 +15,13 @@ public class ShortDramaIdeaBo {
|
||||
private String projectName;
|
||||
|
||||
private String artStyle;
|
||||
private String aestheticSkillName;
|
||||
private String directorSkillName;
|
||||
|
||||
private String aspectRatio;
|
||||
|
||||
/** Stop after script so the author can review before asset/shot planning. */
|
||||
private Boolean scriptOnly = false;
|
||||
/** 兼容旧请求字段;创建接口始终只生成剧本,该值不再控制后续阶段。 */
|
||||
@Deprecated
|
||||
private Boolean scriptOnly = true;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.ruoyi.domain.bo.shortdrama;
|
||||
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/** Optional request-only edit intent; omission preserves approved identity/location continuity. */
|
||||
@Data
|
||||
public class ShortDramaImageRevisionBo {
|
||||
@Pattern(regexp = "identity|identity_revision|location|location_revision")
|
||||
private String referencePurpose;
|
||||
|
||||
@Size(max = 4000)
|
||||
private String revisionRequirements;
|
||||
|
||||
/** Separate look references; the primary reference still owns identity or geometry. */
|
||||
@Size(max = 3)
|
||||
private java.util.List<@Pattern(regexp = "https?://.+") String> styleReferenceImageUrls;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.ruoyi.domain.bo.shortdrama;
|
||||
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/** Request-only image changes. Never persisted into the frozen location description. */
|
||||
@Data
|
||||
public class ShortDramaLocationImageRevisionBo {
|
||||
@Size(max = 4000)
|
||||
private String revisionRequirements;
|
||||
}
|
||||
@@ -19,9 +19,13 @@ public class ShortDramaProjectBo extends BaseEntity {
|
||||
|
||||
private String description;
|
||||
|
||||
private String originalIdea;
|
||||
|
||||
private String status;
|
||||
|
||||
private String artStyle;
|
||||
private String aestheticSkillName;
|
||||
private String directorSkillName;
|
||||
|
||||
private String composeAspectRatio;
|
||||
}
|
||||
|
||||
@@ -10,5 +10,10 @@ public class ShortDramaReviewedPlanBo {
|
||||
@NotNull private Long scriptId;
|
||||
@NotBlank private String expectedScriptText;
|
||||
private String model;
|
||||
@NotNull @Min(1) @Max(15) private Integer minimumShotSeconds = 1;
|
||||
/** Absent means a full reviewed plan; present imports only explicitly reviewed scenes. */
|
||||
@Size(min=1, max=100) private List<@NotNull @Min(1) Integer> sceneNumbers;
|
||||
/** Returned by checkpoint-status. Required for partial imports, ignores temporary image URLs. */
|
||||
@Pattern(regexp="[a-f0-9]{64}") private String expectedAssetSignature;
|
||||
@NotEmpty @Size(max=500) private List<StoryboardPanelData> panels;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ public class ShortDramaRevisionBo {
|
||||
@NotBlank private String expectedScriptText;
|
||||
@NotBlank private String scriptText;
|
||||
private String outlineText;
|
||||
private String tone;
|
||||
private String worldbuilding;
|
||||
private String revisionNotes;
|
||||
@NotEmpty private List<ShortDramaStoryboardBo> storyboards;
|
||||
private List<ShortDramaCharacterBo> characters;
|
||||
private List<ShortDramaLocationBo> locations;
|
||||
|
||||
@@ -24,4 +24,12 @@ public class ShortDramaScriptBo extends BaseEntity {
|
||||
private String tone;
|
||||
|
||||
private String sourceType;
|
||||
|
||||
private String creationMode;
|
||||
|
||||
private String sourceMaterials;
|
||||
|
||||
private String worldbuilding;
|
||||
|
||||
private String revisionNotes;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.ruoyi.domain.bo.shortdrama;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ShortDramaScriptRevisionBo {
|
||||
|
||||
@NotBlank(message = "修改意见不能为空")
|
||||
@Size(max = 10000, message = "修改意见不能超过10000字")
|
||||
private String instruction;
|
||||
}
|
||||
@@ -27,9 +27,14 @@ public class ShortDramaProject extends BaseEntity {
|
||||
|
||||
private String description;
|
||||
|
||||
/** Original user input, independent of generated summaries and script revisions. */
|
||||
private String originalIdea;
|
||||
|
||||
private String status;
|
||||
|
||||
private String artStyle;
|
||||
private String aestheticSkillName;
|
||||
private String directorSkillName;
|
||||
|
||||
private Long composedVideoOssId;
|
||||
|
||||
|
||||
@@ -30,4 +30,12 @@ public class ShortDramaScript extends BaseEntity {
|
||||
private String tone;
|
||||
|
||||
private String sourceType;
|
||||
|
||||
private String creationMode;
|
||||
|
||||
private String sourceMaterials;
|
||||
|
||||
private String worldbuilding;
|
||||
|
||||
private String revisionNotes;
|
||||
}
|
||||
|
||||
@@ -24,9 +24,13 @@ public class ShortDramaProjectVo implements Serializable {
|
||||
|
||||
private String description;
|
||||
|
||||
private String originalIdea;
|
||||
|
||||
private String status;
|
||||
|
||||
private String artStyle;
|
||||
private String aestheticSkillName;
|
||||
private String directorSkillName;
|
||||
|
||||
private Long composedVideoOssId;
|
||||
|
||||
|
||||
@@ -32,4 +32,12 @@ public class ShortDramaScriptVo implements Serializable {
|
||||
private Date createTime;
|
||||
|
||||
private Date updateTime;
|
||||
|
||||
private String creationMode;
|
||||
|
||||
private String sourceMaterials;
|
||||
|
||||
private String worldbuilding;
|
||||
|
||||
private String revisionNotes;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.ruoyi.service.audio.provider;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -20,13 +19,11 @@ import org.ruoyi.service.media.AtlasPredictionService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Atlas Cloud 音频生成(bytedance/seed-audio-1.0)。异步:提交 /model/generateAudio 返回 predictionId,
|
||||
* 轮询 /model/prediction/{id} 拿音频 URL。支持 references(speaker 音色 + 参考音频)做多角色对白配音。
|
||||
* Atlas Cloud TTS:Gemini 单音色与 Seed Audio references 使用各自的请求字段。
|
||||
* 提交返回 predictionId,轮询 /model/prediction/{id} 获取音频;Gemini 付费 POST 不自动重试。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component("atlasAudio")
|
||||
@@ -41,37 +38,17 @@ public class AtlasAudioGenerationServiceImpl extends AbstractAudioGenerationServ
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
private final OkHttpClient geminiHttpClient = okHttpClient.newBuilder()
|
||||
.retryOnConnectionFailure(false)
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build();
|
||||
|
||||
@Override
|
||||
protected MediaGenerationResponse doGenerateSpeech(AudioContext audioContext) {
|
||||
ChatModelVo model = audioContext.getChatModelVo();
|
||||
ObjectNode payload = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode();
|
||||
payload.put("model", model.getModelName());
|
||||
payload.put("text", audioContext.getInput());
|
||||
String format = StrUtil.blankToDefault(audioContext.getResponseFormat(), "mp3");
|
||||
payload.put("format", format);
|
||||
|
||||
// 参考资源:多角色音色 + 参考音频
|
||||
List<Map<String, String>> refs = audioContext.getReferences();
|
||||
if (refs != null && !refs.isEmpty()) {
|
||||
ArrayNode arr = payload.putArray("references");
|
||||
for (Map<String, String> ref : refs) {
|
||||
ObjectNode r = arr.addObject();
|
||||
if (StrUtil.isNotBlank(ref.get("speaker"))) r.put("speaker", ref.get("speaker"));
|
||||
if (StrUtil.isNotBlank(ref.get("audioUrl"))) r.put("audio_url", ref.get("audioUrl"));
|
||||
if (StrUtil.isNotBlank(ref.get("audioData"))) r.put("audio_data", ref.get("audioData"));
|
||||
if (StrUtil.isNotBlank(ref.get("imageData"))) r.put("image_data", ref.get("imageData"));
|
||||
}
|
||||
} else if (StrUtil.isNotBlank(audioContext.getVoice())) {
|
||||
// 没有显式 references 但指定了 voice 音色名:作为单一 speaker
|
||||
ArrayNode arr = payload.putArray("references");
|
||||
ObjectNode r = arr.addObject();
|
||||
r.put("speaker", audioContext.getVoice());
|
||||
}
|
||||
|
||||
if (audioContext.getSampleRate() != null) payload.put("sample_rate", audioContext.getSampleRate());
|
||||
if (audioContext.getPitchRate() != null) payload.put("pitch_rate", audioContext.getPitchRate());
|
||||
if (audioContext.getSpeechRate() != null) payload.put("speech_rate", audioContext.getSpeechRate());
|
||||
if (audioContext.getLoudnessRate() != null) payload.put("loudness_rate", audioContext.getLoudnessRate());
|
||||
boolean gemini = AtlasSpeechPayloadBuilder.isGeminiTts(model.getModelName());
|
||||
ObjectNode payload = AtlasSpeechPayloadBuilder.build(audioContext);
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/generateAudio"))
|
||||
@@ -79,15 +56,26 @@ public class AtlasAudioGenerationServiceImpl extends AbstractAudioGenerationServ
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(RequestBody.create(payload.toString(), AtlasMediaSupport.JSON))
|
||||
.build();
|
||||
try (Response response = okHttpClient.newCall(request).execute()) {
|
||||
try (Response response = (gemini ? geminiHttpClient : okHttpClient).newCall(request).execute()) {
|
||||
ResponseBody body = response.body();
|
||||
String responseText = body == null ? "" : body.string();
|
||||
if (!response.isSuccessful()) {
|
||||
throw new IllegalArgumentException("Atlas Cloud 音频生成任务创建失败: " + response.code() + " - " + responseText);
|
||||
}
|
||||
return atlasPredictionService.toResponse(responseText, "audio");
|
||||
if (!gemini) return atlasPredictionService.toResponse(responseText, "audio");
|
||||
// Public documentation also shows a root-level prediction object.
|
||||
var root = AtlasMediaSupport.OBJECT_MAPPER.readTree(responseText);
|
||||
String normalized = root != null && root.isObject() && !root.has("data") && root.has("id")
|
||||
? AtlasMediaSupport.OBJECT_MAPPER.createObjectNode().set("data", root).toString() : responseText;
|
||||
MediaGenerationResponse result = atlasPredictionService.toResponse(normalized, "audio");
|
||||
if (result == null || (StrUtil.isBlank(result.getId()) && StrUtil.isBlank(result.getUrl()))) {
|
||||
throw new IllegalStateException("Atlas 音频提交结果未知:未返回任务ID或音频URL,请先核对上游任务,禁止重复提交");
|
||||
}
|
||||
result.setRawResponse(responseText);
|
||||
return result;
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Atlas Cloud 音频生成任务创建失败: " + e.getMessage(), e);
|
||||
if (!gemini) throw new RuntimeException("Atlas Cloud 音频生成任务创建失败: " + e.getMessage(), e);
|
||||
throw new RuntimeException("Atlas 音频提交结果未知,请先核对上游任务,禁止重复提交: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package org.ruoyi.service.audio.provider;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.ruoyi.common.chat.entity.audio.AudioContext;
|
||||
import org.ruoyi.service.media.AtlasMediaSupport;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Model-specific Atlas speech contracts; Gemini is not a Seed Audio reference request. */
|
||||
public final class AtlasSpeechPayloadBuilder {
|
||||
public static final String GEMINI_MODEL = "google/gemini-3.1-flash-tts";
|
||||
public static final String DEFAULT_GEMINI_VOICE = "Charon";
|
||||
public static final String DRAMA_INSTRUCTIONS = "使用自然、有力的中文男声,保持同一人的声线;"
|
||||
+ "根据原句表达情绪,语速适合短剧表演,不过度播报,不增加台词或旁白。";
|
||||
private static final Set<String> GEMINI_VOICES = Set.of(
|
||||
"Zephyr", "Puck", "Charon", "Kore", "Fenrir", "Leda", "Orus", "Aoede", "Callirrhoe", "Autonoe",
|
||||
"Enceladus", "Iapetus", "Umbriel", "Algieba", "Despina", "Erinome", "Algenib", "Rasalgethi",
|
||||
"Laomedeia", "Achernar", "Alnilam", "Schedar", "Gacrux", "Pulcherrima", "Achird", "Zubenelgenubi",
|
||||
"Vindemiatrix", "Sadachbia", "Sadaltager", "Sulafat");
|
||||
private static final Pattern AUDIO_LABEL = Pattern.compile("(?i)@audio\\d+\\b\\s*[::]?[\\t ]*");
|
||||
|
||||
private AtlasSpeechPayloadBuilder() { }
|
||||
|
||||
public static boolean isGeminiTts(String model) {
|
||||
return GEMINI_MODEL.equals(model);
|
||||
}
|
||||
|
||||
public static ObjectNode build(AudioContext context) {
|
||||
String model = context.getChatModelVo().getModelName();
|
||||
ObjectNode payload = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode();
|
||||
payload.put("model", model);
|
||||
if (isGeminiTts(model)) {
|
||||
String voice = StrUtil.blankToDefault(context.getVoice(), DEFAULT_GEMINI_VOICE);
|
||||
if (!GEMINI_VOICES.contains(voice)) {
|
||||
throw new IllegalArgumentException("Gemini TTS 音色必须是官方 prebuilt voice,不能使用 Seed Audio 的 speaker 名称");
|
||||
}
|
||||
String spoken = spokenText(context.getInput(), List.of());
|
||||
if (StrUtil.isBlank(spoken)) throw new IllegalArgumentException("语音正文不能为空");
|
||||
String direction = StrUtil.blankToDefault(context.getInstructions(), DRAMA_INSTRUCTIONS);
|
||||
// Atlas exposes one text field. Gemini accepts natural-language performance instructions in it.
|
||||
payload.put("text", "单人配音任务。仅朗读下面“正文”中的原句,不朗读任务说明、表演指令、角色名或标签。\n"
|
||||
+ "表演指令:" + direction + "\n正文:\n" + spoken);
|
||||
payload.put("voice", voice);
|
||||
return payload;
|
||||
}
|
||||
|
||||
payload.put("text", context.getInput());
|
||||
payload.put("format", StrUtil.blankToDefault(context.getResponseFormat(), "mp3"));
|
||||
List<Map<String, String>> refs = context.getReferences();
|
||||
if (refs != null && !refs.isEmpty()) {
|
||||
if (refs.size() > 3) throw new IllegalArgumentException("Seed Audio最多3条声音参考或1张图像参考");
|
||||
ArrayNode arr = payload.putArray("references");
|
||||
boolean image = false;
|
||||
for (Map<String, String> ref : refs) {
|
||||
long sources = List.of("speaker", "audioUrl", "audioData", "imageUrl", "imageData").stream()
|
||||
.filter(key -> StrUtil.isNotBlank(ref.get(key))).count();
|
||||
if (sources != 1) throw new IllegalArgumentException("每条 Seed Audio 参考必须且只能包含一种来源");
|
||||
image |= StrUtil.isNotBlank(ref.get("imageUrl")) || StrUtil.isNotBlank(ref.get("imageData"));
|
||||
ObjectNode r = arr.addObject();
|
||||
if (StrUtil.isNotBlank(ref.get("speaker"))) r.put("speaker", ref.get("speaker"));
|
||||
if (StrUtil.isNotBlank(ref.get("audioUrl"))) r.put("audio_url", ref.get("audioUrl"));
|
||||
if (StrUtil.isNotBlank(ref.get("audioData"))) r.put("audio_data", ref.get("audioData"));
|
||||
if (StrUtil.isNotBlank(ref.get("imageData"))) r.put("image_data", ref.get("imageData"));
|
||||
if (StrUtil.isNotBlank(ref.get("imageUrl"))) r.put("image_url", ref.get("imageUrl"));
|
||||
}
|
||||
if (image && refs.size() != 1) throw new IllegalArgumentException("Seed Audio图像参考最多1张,不能与声音参考混用");
|
||||
} else if (StrUtil.isNotBlank(context.getVoice())) {
|
||||
payload.putArray("references").addObject().put("speaker", context.getVoice());
|
||||
}
|
||||
if (context.getSampleRate() != null) payload.put("sample_rate", context.getSampleRate());
|
||||
if (context.getPitchRate() != null) payload.put("pitch_rate", context.getPitchRate());
|
||||
if (context.getSpeechRate() != null) payload.put("speech_rate", context.getSpeechRate());
|
||||
if (context.getLoudnessRate() != null) payload.put("loudness_rate", context.getLoudnessRate());
|
||||
return payload;
|
||||
}
|
||||
|
||||
/** Remove production speaker labels, never arbitrary text preceding a colon inside a spoken sentence. */
|
||||
public static String spokenText(String input, Collection<String> speakerNames) {
|
||||
if (input == null) return "";
|
||||
String text = AUDIO_LABEL.matcher(input).replaceAll("");
|
||||
for (String alias : List.of("旁白", "画外音", "Narrator", "Narration")) {
|
||||
text = removeSpeakerPrefix(text, alias);
|
||||
}
|
||||
text = text.replaceAll("(?im)^[\\t ]*Speaker[\\t ]*\\d+[\\t ]*[::][\\t ]*", "");
|
||||
if (speakerNames != null) {
|
||||
for (String name : speakerNames) {
|
||||
if (StrUtil.isNotBlank(name)) text = removeSpeakerPrefix(text, name);
|
||||
}
|
||||
}
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
private static String removeSpeakerPrefix(String text, String name) {
|
||||
return text.replaceAll("(?m)^[\\t ]*" + Pattern.quote(name)
|
||||
+ "[\\t ]*(?:[((][^))\\r\\n]*[))][\\t ]*)?[::][\\t ]*", "");
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,11 @@ public interface AbstractChatService {
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Optional request parameters; native providers keep their existing adapter behavior. */
|
||||
default ChatModel buildChatModel(ChatModelVo chatModelVo, ChatRequest request) {
|
||||
return buildChatModel(chatModelVo);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务提供商名称
|
||||
*/
|
||||
|
||||
@@ -110,10 +110,10 @@ public class ChatModelServiceImpl implements IChatModelService {
|
||||
|
||||
private LambdaQueryWrapper<ChatModel> buildQueryWrapper(ChatModelBo bo) {
|
||||
LambdaQueryWrapper<ChatModel> lqw = Wrappers.lambdaQuery();
|
||||
lqw.orderByAsc(ChatModel::getId);
|
||||
lqw.orderByAsc(ChatModel::getSortOrder, ChatModel::getId);
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getCategory()), ChatModel::getCategory, bo.getCategory());
|
||||
lqw.like(StringUtils.isNotBlank(bo.getModelName()), ChatModel::getModelName, bo.getModelName());
|
||||
lqw.like(StringUtils.isNotBlank(bo.getProviderCode()), ChatModel::getProviderCode, bo.getProviderCode());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getProviderCode()), ChatModel::getProviderCode, bo.getProviderCode());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getModelDescribe()), ChatModel::getModelDescribe, bo.getModelDescribe());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getModelShow()), ChatModel::getModelShow, bo.getModelShow());
|
||||
lqw.eq(StringUtils.isNotBlank(bo.getApiHost()), ChatModel::getApiHost, bo.getApiHost());
|
||||
@@ -181,6 +181,7 @@ public class ChatModelServiceImpl implements IChatModelService {
|
||||
}
|
||||
target.setId(source.getId());
|
||||
target.setCategory(source.getCategory());
|
||||
target.setSortOrder(source.getSortOrder());
|
||||
target.setModelName(source.getModelName());
|
||||
target.setProviderCode(source.getProviderCode());
|
||||
target.setModelDescribe(source.getModelDescribe());
|
||||
|
||||
@@ -262,8 +262,8 @@ public class ChatServiceFacade implements IChatService {
|
||||
llmSpan.detach();
|
||||
}
|
||||
finishTraceRun(traceRun, TraceConstants.STATUS_ERROR, e);
|
||||
SseMessageUtils.sendError(String.valueOf(chatRequest.getSessionId()), SAFE_CHAT_ERROR_MESSAGE);
|
||||
SseMessageUtils.completeConnection(String.valueOf(chatRequest.getSessionId()));
|
||||
saveModelChatFailure(chatRequest, "");
|
||||
SseMessageUtils.sendErrorAndComplete(String.valueOf(chatRequest.getSessionId()), SAFE_CHAT_ERROR_MESSAGE);
|
||||
log.error("chat_operation operation=MODEL_CHAT status=FAILED errorType={}", errorType(e));
|
||||
}
|
||||
return chatRequest.getEmitter();
|
||||
@@ -807,20 +807,24 @@ public class ChatServiceFacade implements IChatService {
|
||||
return new StreamingChatResponseHandler() {
|
||||
|
||||
private final StringBuilder messageBuffer = new StringBuilder();
|
||||
private final AtomicBoolean terminal = new AtomicBoolean();
|
||||
|
||||
@Override
|
||||
public void onPartialResponse(String partialResponse) {
|
||||
if (terminal.get()) return;
|
||||
messageBuffer.append(partialResponse);
|
||||
SseMessageUtils.sendContent(sessionId, partialResponse);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPartialThinking(PartialThinking partialThinking) {
|
||||
if (terminal.get()) return;
|
||||
SseMessageUtils.sendReasoning(sessionId, partialThinking.text());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCompleteResponse(ChatResponse completeResponse) {
|
||||
if (!terminal.compareAndSet(false, true)) return;
|
||||
try {
|
||||
String fullMessage = messageBuffer.toString();
|
||||
if (StringUtils.isNotBlank(fullMessage)) {
|
||||
@@ -832,7 +836,7 @@ public class ChatServiceFacade implements IChatService {
|
||||
chatRequest.getModel()
|
||||
);
|
||||
} else {
|
||||
log.warn("chat_stream status=EMPTY_RESPONSE");
|
||||
throw new IllegalStateException("Model completed without text content");
|
||||
}
|
||||
if (llmSpan != null) {
|
||||
llmSpan.finishSuccess(RagTracePayloadBuilder.streamOutputSummary(fullMessage.length()));
|
||||
@@ -844,30 +848,43 @@ public class ChatServiceFacade implements IChatService {
|
||||
llmSpan.finishError(e);
|
||||
}
|
||||
finishTraceRun(traceRun, TraceConstants.STATUS_ERROR, e);
|
||||
SseMessageUtils.sendError(sessionId, SAFE_CHAT_ERROR_MESSAGE);
|
||||
saveModelChatFailure(chatRequest, messageBuffer.toString());
|
||||
SseMessageUtils.sendErrorAndComplete(sessionId, SAFE_CHAT_ERROR_MESSAGE);
|
||||
log.error("chat_stream operation=COMPLETE status=FAILED errorType={}", errorType(e));
|
||||
} finally {
|
||||
if (llmSpan != null) {
|
||||
llmSpan.detach();
|
||||
}
|
||||
SseMessageUtils.completeConnection(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
if (llmSpan != null) {
|
||||
llmSpan.finishError(error);
|
||||
llmSpan.detach();
|
||||
if (!terminal.compareAndSet(false, true)) return;
|
||||
try {
|
||||
if (llmSpan != null) llmSpan.finishError(error);
|
||||
finishTraceRun(traceRun, TraceConstants.STATUS_ERROR, error);
|
||||
saveModelChatFailure(chatRequest, messageBuffer.toString());
|
||||
} finally {
|
||||
if (llmSpan != null) llmSpan.detach();
|
||||
SseMessageUtils.sendErrorAndComplete(sessionId, SAFE_CHAT_ERROR_MESSAGE);
|
||||
}
|
||||
finishTraceRun(traceRun, TraceConstants.STATUS_ERROR, error);
|
||||
SseMessageUtils.sendError(sessionId, SAFE_CHAT_ERROR_MESSAGE);
|
||||
SseMessageUtils.completeConnection(sessionId);
|
||||
log.error("chat_stream operation=MODEL_STREAM status=FAILED errorType={}", errorType(error));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void saveModelChatFailure(ChatRequest request, String partialText) {
|
||||
try {
|
||||
String saved = StringUtils.isBlank(partialText) ? SAFE_CHAT_ERROR_MESSAGE
|
||||
: partialText + "\n\n[响应未完成] " + SAFE_CHAT_ERROR_MESSAGE;
|
||||
chatMessageService.saveChatMessage(request.getUserId(), request.getSessionId(), saved,
|
||||
RoleType.ASSISTANT.getName(), request.getModel());
|
||||
} catch (Exception failure) {
|
||||
log.error("chat_stream operation=SAVE_ERROR status=FAILED errorType={}", errorType(failure));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建组合响应处理器 - 同时发送到 SSE 和外部 handler
|
||||
*
|
||||
|
||||
@@ -2,6 +2,8 @@ package org.ruoyi.service.chat.impl.provider;
|
||||
|
||||
|
||||
import dev.langchain4j.model.chat.StreamingChatModel;
|
||||
import dev.langchain4j.model.chat.ChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -10,9 +12,11 @@ import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.enums.ChatModeType;
|
||||
import org.ruoyi.observability.MyChatModelListener;
|
||||
import org.ruoyi.service.chat.AbstractChatService;
|
||||
import org.ruoyi.service.chat.impl.provider.atlas.AtlasDeadlineHttpClientBuilder;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Atlas Cloud服务调用
|
||||
@@ -28,14 +32,38 @@ public class AtlaServiceImpl implements AbstractChatService {
|
||||
@Override
|
||||
public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) {
|
||||
return OpenAiStreamingChatModel.builder()
|
||||
.httpClientBuilder(new AtlasDeadlineHttpClientBuilder())
|
||||
.baseUrl(chatModelVo.getApiHost())
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(Duration.ofMinutes(6))
|
||||
.maxTokens(16384)
|
||||
.reasoningEffort(chatRequest.getReasoningEffort())
|
||||
.customParameters(SeedThinkingParameters.forRequest(chatModelVo.getModelName(), chatRequest))
|
||||
.listeners(List.of(new MyChatModelListener()))
|
||||
.returnThinking(chatRequest.getEnableThinking())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatModel buildChatModel(ChatModelVo chatModelVo) {
|
||||
return buildChatModel(chatModelVo, new ChatRequest());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatModel buildChatModel(ChatModelVo chatModelVo, ChatRequest request) {
|
||||
return OpenAiChatModel.builder()
|
||||
.baseUrl(chatModelVo.getApiHost())
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(Duration.ofMinutes(20))
|
||||
.maxRetries(0)
|
||||
.maxTokens(16384)
|
||||
.reasoningEffort(request.getReasoningEffort())
|
||||
.customParameters(SeedThinkingParameters.forRequest(chatModelVo.getModelName(), request))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProviderName() {
|
||||
return ChatModeType.ATLAS.getCode();
|
||||
|
||||
@@ -2,6 +2,8 @@ package org.ruoyi.service.chat.impl.provider;
|
||||
|
||||
|
||||
import dev.langchain4j.model.chat.StreamingChatModel;
|
||||
import dev.langchain4j.model.chat.ChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -29,6 +31,23 @@ import java.util.Map;
|
||||
@RequiredArgsConstructor
|
||||
public class DeepseekServiceImpl implements AbstractChatService {
|
||||
|
||||
@Override
|
||||
public ChatModel buildChatModel(ChatModelVo model) {
|
||||
return buildChatModel(model, new ChatRequest());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ChatModel buildChatModel(ChatModelVo model, ChatRequest request) {
|
||||
boolean thinking = Boolean.TRUE.equals(request.getEnableThinking());
|
||||
var builder = OpenAiChatModel.builder().baseUrl(model.getApiHost()).apiKey(model.getApiKey())
|
||||
.modelName(model.getModelName()).listeners(List.of(new MyChatModelListener()))
|
||||
.customParameters(thinkingParameters(thinking)).returnThinking(thinking)
|
||||
.sendThinking(thinking || Boolean.TRUE.equals(request.getReplayThinking()))
|
||||
.timeout(Duration.ofMinutes(3)).maxRetries(0);
|
||||
if (thinking) builder.reasoningEffort("high");
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo, ChatRequest chatRequest) {
|
||||
boolean thinkingEnabled = Boolean.TRUE.equals(chatRequest.getEnableThinking());
|
||||
|
||||
@@ -2,6 +2,8 @@ package org.ruoyi.service.chat.impl.provider;
|
||||
|
||||
|
||||
import dev.langchain4j.model.chat.StreamingChatModel;
|
||||
import dev.langchain4j.model.chat.ChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiChatModel;
|
||||
import dev.langchain4j.model.openai.OpenAiStreamingChatModel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -28,12 +30,24 @@ import java.util.List;
|
||||
@RequiredArgsConstructor
|
||||
public class OpenAIServiceImpl implements AbstractChatService {
|
||||
|
||||
@Override
|
||||
public ChatModel buildChatModel(ChatModelVo config, ChatRequest request) {
|
||||
return OpenAiChatModel.builder()
|
||||
.baseUrl(config.getApiHost()).apiKey(config.getApiKey()).modelName(config.getModelName())
|
||||
.timeout(Duration.ofSeconds(120))
|
||||
.reasoningEffort(request.getReasoningEffort())
|
||||
.customParameters(SeedThinkingParameters.forRequest(config.getModelName(), request))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public StreamingChatModel buildStreamingChatModel(ChatModelVo chatModelVo,ChatRequest chatRequest) {
|
||||
return OpenAiStreamingChatModel.builder()
|
||||
.baseUrl(chatModelVo.getApiHost())
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.reasoningEffort(chatRequest.getReasoningEffort())
|
||||
.customParameters(SeedThinkingParameters.forRequest(chatModelVo.getModelName(), chatRequest))
|
||||
.timeout(Duration.ofMinutes(30))
|
||||
.listeners(List.of(new MyChatModelListener()))
|
||||
.returnThinking(chatRequest.getEnableThinking())
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.ruoyi.service.chat.impl.provider;
|
||||
|
||||
import java.util.Map;
|
||||
import org.ruoyi.common.chat.domain.dto.request.ChatRequest;
|
||||
|
||||
/** Ark's explicit upstream thinking switch is independent of SDK returnThinking. */
|
||||
final class SeedThinkingParameters {
|
||||
private SeedThinkingParameters() {}
|
||||
static Map<String, Object> forRequest(String model, ChatRequest request) {
|
||||
boolean seed21 = model != null && (model.endsWith("doubao-seed-2.1-pro-260628") || model.endsWith("doubao-seed-2-1-pro-260628"));
|
||||
return seed21 && "none".equals(request.getReasoningEffort())
|
||||
? Map.of("thinking", Map.of("type", "disabled")) : Map.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package org.ruoyi.service.chat.impl.provider.atlas;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.langchain4j.exception.HttpException;
|
||||
import dev.langchain4j.http.client.HttpClient;
|
||||
import dev.langchain4j.http.client.HttpClientBuilder;
|
||||
import dev.langchain4j.http.client.HttpRequest;
|
||||
import dev.langchain4j.http.client.SuccessfulHttpResponse;
|
||||
import dev.langchain4j.http.client.jdk.JdkHttpClient;
|
||||
import dev.langchain4j.http.client.sse.ServerSentEvent;
|
||||
import dev.langchain4j.http.client.sse.ServerSentEventContext;
|
||||
import dev.langchain4j.http.client.sse.ServerSentEventListener;
|
||||
import dev.langchain4j.http.client.sse.ServerSentEventParser;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/** Bounds an opened Atlas SSE body as well as the wait for response headers. No request retries. */
|
||||
public final class AtlasDeadlineHttpClientBuilder implements HttpClientBuilder {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final ScheduledExecutorService DEADLINES = Executors.newScheduledThreadPool(2, task -> {
|
||||
Thread thread = new Thread(task, "atlas-stream-deadline"); thread.setDaemon(true); return thread;
|
||||
});
|
||||
private final Duration firstContentTimeout;
|
||||
private final Duration totalTimeout;
|
||||
private Duration connectTimeout = Duration.ofSeconds(30);
|
||||
private Duration readTimeout = Duration.ofMinutes(6);
|
||||
|
||||
// Seed 2.1 Pro 的长总纲实测首正文约 11 分钟、完整响应约 15 分钟。
|
||||
public AtlasDeadlineHttpClientBuilder() { this(Duration.ofMinutes(15), Duration.ofMinutes(30)); }
|
||||
|
||||
public AtlasDeadlineHttpClientBuilder(Duration firstContentTimeout, Duration totalTimeout) {
|
||||
if (firstContentTimeout == null || totalTimeout == null || firstContentTimeout.isNegative()
|
||||
|| firstContentTimeout.isZero() || totalTimeout.compareTo(firstContentTimeout) < 0)
|
||||
throw new IllegalArgumentException("Invalid Atlas stream deadlines");
|
||||
this.firstContentTimeout = firstContentTimeout;
|
||||
this.totalTimeout = totalTimeout;
|
||||
}
|
||||
|
||||
@Override public Duration connectTimeout() { return connectTimeout; }
|
||||
@Override public AtlasDeadlineHttpClientBuilder connectTimeout(Duration timeout) { connectTimeout = timeout; return this; }
|
||||
@Override public Duration readTimeout() { return readTimeout; }
|
||||
@Override public AtlasDeadlineHttpClientBuilder readTimeout(Duration timeout) { readTimeout = timeout; return this; }
|
||||
|
||||
@Override public HttpClient build() {
|
||||
Duration connection = connectTimeout == null || connectTimeout.compareTo(Duration.ofSeconds(30)) > 0
|
||||
? Duration.ofSeconds(30) : connectTimeout;
|
||||
var delegate = java.net.http.HttpClient.newBuilder().connectTimeout(connection).build();
|
||||
var synchronous = JdkHttpClient.builder().connectTimeout(connection).readTimeout(readTimeout).build();
|
||||
return new HttpClient() {
|
||||
@Override public SuccessfulHttpResponse execute(HttpRequest request) { return synchronous.execute(request); }
|
||||
|
||||
@Override public void execute(HttpRequest request, ServerSentEventParser parser, ServerSentEventListener listener) {
|
||||
var call = new StreamCall(listener);
|
||||
call.firstDeadline = DEADLINES.schedule(() -> {
|
||||
if (!call.hasContent.get()) call.fail(new TimeoutException("Atlas 首段正文等待超过配置时限(" + firstContentTimeout.toSeconds() + "秒)"));
|
||||
}, firstContentTimeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||
call.totalDeadline = DEADLINES.schedule(() -> call.fail(new TimeoutException("Atlas 流式请求超过配置时限(" + totalTimeout.toSeconds() + "秒)")),
|
||||
totalTimeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||
try {
|
||||
if (!request.formDataFiles().isEmpty() || !request.formDataFields().isEmpty())
|
||||
throw new IllegalArgumentException("Atlas chat streaming requires a JSON request");
|
||||
var builder = java.net.http.HttpRequest.newBuilder(URI.create(request.url())).timeout(firstContentTimeout);
|
||||
request.headers().forEach((name, values) -> values.forEach(value -> builder.header(name, value)));
|
||||
builder.method(request.method().name(), request.body() == null
|
||||
? java.net.http.HttpRequest.BodyPublishers.noBody()
|
||||
: java.net.http.HttpRequest.BodyPublishers.ofString(request.body()));
|
||||
CompletableFuture<HttpResponse<InputStream>> future = delegate.sendAsync(builder.build(), HttpResponse.BodyHandlers.ofInputStream());
|
||||
call.future.set(future);
|
||||
if (call.terminal.get()) future.cancel(true);
|
||||
future.whenCompleteAsync((response, error) -> {
|
||||
if (error != null) { call.fail(error); return; }
|
||||
call.body.set(response.body());
|
||||
if (call.terminal.get()) { call.closeBody(); return; }
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
call.fail(new HttpException(response.statusCode(), "Atlas HTTP " + response.statusCode())); return;
|
||||
}
|
||||
try (InputStream body = response.body()) {
|
||||
listener.onOpen(SuccessfulHttpResponse.builder().statusCode(response.statusCode())
|
||||
.headers(response.headers().map()).build());
|
||||
parser.parse(body, new ServerSentEventListener() {
|
||||
@Override public void onEvent(ServerSentEvent event, ServerSentEventContext context) {
|
||||
if (call.terminal.get()) return;
|
||||
if (hasTextContent(event.data())) {
|
||||
call.hasContent.set(true); call.firstDeadline.cancel(false);
|
||||
}
|
||||
listener.onEvent(event, context);
|
||||
if ("[DONE]".equals(event.data())) call.complete();
|
||||
}
|
||||
@Override public void onError(Throwable error) { call.fail(error); }
|
||||
});
|
||||
call.complete();
|
||||
} catch (Exception failure) { call.fail(failure); }
|
||||
});
|
||||
} catch (Exception failure) { call.fail(failure); }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static boolean hasTextContent(String data) {
|
||||
try {
|
||||
for (var choice : JSON.readTree(data).path("choices")) {
|
||||
var content = choice.path("delta").path("content");
|
||||
if (content.isTextual() && !content.asText().isBlank()) return true;
|
||||
}
|
||||
} catch (Exception ignored) { }
|
||||
return false; // Transport keepalives, role and reasoning deltas are not screenplay text.
|
||||
}
|
||||
|
||||
private static final class StreamCall {
|
||||
final ServerSentEventListener listener;
|
||||
final AtomicBoolean terminal = new AtomicBoolean();
|
||||
final AtomicBoolean hasContent = new AtomicBoolean();
|
||||
final AtomicReference<CompletableFuture<?>> future = new AtomicReference<>();
|
||||
final AtomicReference<InputStream> body = new AtomicReference<>();
|
||||
volatile ScheduledFuture<?> firstDeadline;
|
||||
volatile ScheduledFuture<?> totalDeadline;
|
||||
StreamCall(ServerSentEventListener listener) { this.listener = listener; }
|
||||
void fail(Throwable failure) {
|
||||
if (!terminal.compareAndSet(false, true)) return;
|
||||
cancelDeadlines();
|
||||
var pending = future.get(); if (pending != null) pending.cancel(true);
|
||||
closeBody();
|
||||
listener.onError(failure);
|
||||
}
|
||||
void complete() {
|
||||
if (!terminal.compareAndSet(false, true)) return;
|
||||
cancelDeadlines();
|
||||
try { listener.onClose(); } finally { closeBody(); }
|
||||
}
|
||||
void cancelDeadlines() {
|
||||
if (firstDeadline != null) firstDeadline.cancel(false);
|
||||
if (totalDeadline != null) totalDeadline.cancel(false);
|
||||
}
|
||||
void closeBody() {
|
||||
var stream = body.getAndSet(null);
|
||||
if (stream != null) try { stream.close(); } catch (Exception ignored) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import org.ruoyi.common.chat.entity.media.MediaGenerationResponse;
|
||||
import org.ruoyi.enums.ChatModeType;
|
||||
import org.ruoyi.service.image.AbstractImageGenerationService;
|
||||
import org.ruoyi.service.media.AtlasMediaSupport;
|
||||
import org.ruoyi.service.media.AtlasPredictionService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -26,6 +27,8 @@ import java.util.concurrent.TimeUnit;
|
||||
@Slf4j
|
||||
@Component("atlasImage")
|
||||
public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationService {
|
||||
private final AtlasPredictionService predictions;
|
||||
public AtlasImageGenerationServiceImpl(AtlasPredictionService predictions) { this.predictions = predictions; }
|
||||
|
||||
/** 比例 → 像素映射(AtlasCloud 要求 width*height,min 768 max 1360) */
|
||||
private static final Map<String, String> SIZE_MAP = Map.of(
|
||||
@@ -42,6 +45,13 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ
|
||||
"16:9", "1536x1024",
|
||||
"9:16", "1024x1536"
|
||||
);
|
||||
private static final String SEEDREAM_47_TEXT_TO_IMAGE = "bytedance/seedream-v4.7/text-to-image";
|
||||
/** Atlas's documented 2K presets; the exact T2I variant produces one image, without references. */
|
||||
private static final Map<String, String> SEEDREAM_47_SIZE_MAP = Map.of(
|
||||
"1:1", "2048*2048", "4:3", "2304*1728", "3:4", "1728*2304",
|
||||
"16:9", "2848*1600", "9:16", "1600*2848", "3:2", "2496*1664",
|
||||
"2:3", "1664*2496", "21:9", "3136*1344"
|
||||
);
|
||||
|
||||
private final OkHttpClient okHttpClient = new OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
@@ -49,8 +59,36 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
@Override
|
||||
public String generateImage(ImageContext context) {
|
||||
if (!isSeedream47TextToImage(context.getChatModelVo().getModelName())) return super.generateImage(context);
|
||||
// The official 4.7 schema is asynchronous. A synchronous caller submits exactly once,
|
||||
// then queries that prediction; it does not rely on an undocumented sync-mode flag.
|
||||
var started = startImageGeneration(context);
|
||||
return awaitSeedream47Image(context.getChatModelVo(), started.getId());
|
||||
}
|
||||
|
||||
String awaitSeedream47Image(ChatModelVo model, String predictionId) {
|
||||
long deadline = System.nanoTime() + TimeUnit.MINUTES.toNanos(5);
|
||||
try {
|
||||
while (System.nanoTime() < deadline) {
|
||||
var result = predictions.retrieve(model, predictionId);
|
||||
if (result != null && ("completed".equals(result.getStatus()) || "succeeded".equals(result.getStatus())) && StrUtil.isNotBlank(result.getUrl())) return result.getUrl();
|
||||
if (result != null && "failed".equals(result.getStatus())) throw new IllegalStateException("Seedream 4.7图片任务失败,predictionId=" + predictionId);
|
||||
Thread.sleep(1500);
|
||||
}
|
||||
throw new IllegalStateException("Seedream 4.7图片任务尚未完成,请查询原任务而勿重新提交:predictionId=" + predictionId);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt(); throw new IllegalStateException("图片等待被中断,请查询原任务:predictionId=" + predictionId, e);
|
||||
} catch (RuntimeException e) {
|
||||
throw new IllegalStateException("Seedream 4.7原任务查询未完成,predictionId=" + predictionId + ";" + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String doGenerateImage(ChatModelVo chatModelVo, String prompt, String size, Integer seed, String image) {
|
||||
if (isSeedream47TextToImage(chatModelVo.getModelName())) return generateImage(ImageContext.builder()
|
||||
.chatModelVo(chatModelVo).prompt(prompt).size(size).seed(seed).image(image).build());
|
||||
ObjectNode payload = buildPayload(chatModelVo, prompt, size, seed, image);
|
||||
payload.put("enable_sync_mode", true);
|
||||
|
||||
@@ -101,18 +139,26 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ
|
||||
return "";
|
||||
}
|
||||
|
||||
private ObjectNode buildPayload(ChatModelVo chatModelVo, String prompt, String size, Integer seed, String image) {
|
||||
static ObjectNode buildPayload(ChatModelVo chatModelVo, String prompt, String size, Integer seed, String image) {
|
||||
String modelName = chatModelVo.getModelName();
|
||||
boolean gptImageModel = isGptImageModel(modelName);
|
||||
boolean seedream47T2i = isSeedream47TextToImage(modelName);
|
||||
if (seedream47T2i && StrUtil.isNotBlank(image)) {
|
||||
throw seedream47ReferenceError();
|
||||
}
|
||||
ObjectNode payload = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode();
|
||||
payload.put("model", modelName);
|
||||
payload.put("prompt", prompt);
|
||||
if (StrUtil.isNotBlank(size)) {
|
||||
payload.put("size", resolveSize(modelName, size));
|
||||
}
|
||||
if (seed != null && !gptImageModel) {
|
||||
if (seed != null && !gptImageModel && !seedream47T2i) {
|
||||
payload.put("seed", seed);
|
||||
}
|
||||
if (seedream47T2i) {
|
||||
payload.put("prompt_expansion_mode", "standard");
|
||||
payload.put("enable_base64_output", false);
|
||||
}
|
||||
if (gptImageModel) {
|
||||
payload.put("enable_base64_output", false);
|
||||
payload.put("output_format", "jpeg");
|
||||
@@ -140,10 +186,22 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ
|
||||
return StrUtil.isNotBlank(modelName) && modelName.startsWith("openai/gpt-image-");
|
||||
}
|
||||
|
||||
static boolean isSeedream47TextToImage(String modelName) {
|
||||
return SEEDREAM_47_TEXT_TO_IMAGE.equals(modelName);
|
||||
}
|
||||
|
||||
private static IllegalArgumentException seedream47ReferenceError() {
|
||||
return new IllegalArgumentException("Seedream 4.7 文生图型号不接收参考图;请使用无参考图的新资产候选流程。身份绑定或多参考首帧需要另行选择支持参考图的编辑型号,不会自动切换模型。");
|
||||
}
|
||||
|
||||
/** 根据 AtlasCloud 模型将比例转换为该模型支持的像素格式。 */
|
||||
static String resolveSize(String modelName, String size) {
|
||||
if (StrUtil.isBlank(size)) return null;
|
||||
String normalizedSize = size.trim();
|
||||
if (isSeedream47TextToImage(modelName)) {
|
||||
String mapped = SEEDREAM_47_SIZE_MAP.get(normalizedSize);
|
||||
return mapped != null ? mapped : resolveSeedream47PixelSize(normalizedSize);
|
||||
}
|
||||
if (isGptImageModel(modelName)) {
|
||||
if (modelName.startsWith("openai/gpt-image-2/") || modelName.startsWith("openai/gpt-image-2.5-")) {
|
||||
if ("16:9".equals(normalizedSize)) return "1536x864";
|
||||
@@ -159,6 +217,22 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ
|
||||
return SIZE_MAP.getOrDefault(normalizedSize, "1024*1024");
|
||||
}
|
||||
|
||||
private static String resolveSeedream47PixelSize(String size) {
|
||||
String[] dimensions = size.toLowerCase(java.util.Locale.ROOT).replace('x', '*').split("\\*", -1);
|
||||
try {
|
||||
if (dimensions.length != 2) throw new NumberFormatException();
|
||||
int width = Integer.parseInt(dimensions[0].trim()), height = Integer.parseInt(dimensions[1].trim());
|
||||
long pixels = (long) width * height;
|
||||
if (width <= 0 || height <= 0 || pixels < 921600L || pixels > 16777216L
|
||||
|| (long) width > (long) height * 16L || (long) height > (long) width * 16L) {
|
||||
throw new NumberFormatException();
|
||||
}
|
||||
return width + "*" + height;
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("Seedream 4.7 尺寸须为有效比例或WIDTH*HEIGHT:总像素921600–16777216,长宽比例在1:16至16:1之间");
|
||||
}
|
||||
}
|
||||
|
||||
private static String resolveGptImagePixelSize(String size) {
|
||||
String[] dimensions = size.toLowerCase().replace('*', 'x').split("x");
|
||||
if (dimensions.length != 2) return "1024x1024";
|
||||
@@ -181,7 +255,7 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ
|
||||
String image = imageContext.getImage();
|
||||
|
||||
ObjectNode payload = buildPayload(chatModelVo, prompt, size, seed, image);
|
||||
payload.put("enable_sync_mode", false);
|
||||
if (!isSeedream47TextToImage(chatModelVo.getModelName())) payload.put("enable_sync_mode", false);
|
||||
applyReferenceImages(payload, imageContext);
|
||||
|
||||
Request request = new Request.Builder()
|
||||
@@ -220,6 +294,7 @@ public class AtlasImageGenerationServiceImpl extends AbstractImageGenerationServ
|
||||
static void applyReferenceImages(ObjectNode payload, ImageContext context) {
|
||||
var references = context.getReferenceImages();
|
||||
if (references == null || references.isEmpty()) return;
|
||||
if (isSeedream47TextToImage(context.getChatModelVo().getModelName())) throw seedream47ReferenceError();
|
||||
int limit = context.getChatModelVo().getModelName().startsWith("openai/gpt-image-2.5-") ? 16 : 10;
|
||||
if (references.size() > limit) throw new IllegalArgumentException("当前模型最多绑定" + limit + "张参考图,请减少不必要道具");
|
||||
if (!isGptImageModel(context.getChatModelVo().getModelName())) {
|
||||
|
||||
@@ -50,10 +50,10 @@ public class AtlasPredictionService {
|
||||
.build();
|
||||
}
|
||||
if (!response.isSuccessful()) {
|
||||
if (response.code() == 400 || response.code() == 422) {
|
||||
MediaGenerationResponse terminal = terminalFailure(responseText, predictionId, mediaType(model.getCategory()));
|
||||
if (terminal != null) return terminal;
|
||||
}
|
||||
// Music validation can be wrapped as HTTP 500 by the provider. A matching
|
||||
// prediction id plus explicit failed status is terminal; unrelated errors are not.
|
||||
MediaGenerationResponse terminal = terminalFailure(responseText, predictionId, mediaType(model.getCategory()));
|
||||
if (terminal != null) return terminal;
|
||||
throw new IllegalArgumentException("Atlas Cloud生成结果查询失败: " + response.code() + " - " + responseText);
|
||||
}
|
||||
return toResponse(responseText, mediaType(model.getCategory()));
|
||||
|
||||
@@ -42,8 +42,16 @@ public interface IShortDramaService {
|
||||
|
||||
ShortDramaStoryboardVo saveStoryboard(ShortDramaStoryboardBo bo, Long userId);
|
||||
|
||||
ShortDramaStoryboardVo addStoryboard(Long projectId, Long scriptId, Long afterId, Long userId);
|
||||
|
||||
void deleteStoryboard(Long storyboardId, Long userId);
|
||||
|
||||
ShortDramaStoryboardVo generateVideo(Long storyboardId, String videoModel, Long userId);
|
||||
|
||||
ShortDramaStoryboardVo generateVideo(Long storyboardId, String videoModel, Long userId, String requestId, boolean regenerate);
|
||||
|
||||
org.ruoyi.service.shortdrama.impl.ShortDramaVideoSubmissionStore.Submission videoSubmission(Long storyboardId, String requestId, Long userId);
|
||||
|
||||
ShortDramaStoryboardVo retrieveVideo(Long storyboardId, String videoModel, Long userId);
|
||||
|
||||
/** 代理流式输出视频,绕过 OSS 强制下载 header */
|
||||
@@ -51,19 +59,31 @@ public interface IShortDramaService {
|
||||
|
||||
List<ShortDramaStoryboardVo> generateAllVideos(Long projectId, String videoModel, Long userId);
|
||||
|
||||
ShortDramaDetailVo analyzeAssets(Long projectId, Long scriptId, Long userId, String model);
|
||||
List<ShortDramaStoryboardVo> generateAllVideos(Long projectId, String videoModel, Long userId, Integer sceneStart, Integer sceneCount);
|
||||
|
||||
/** Phase 1: 剧本打磨,重新生成更丰富的剧本内容 */
|
||||
ShortDramaDetailVo polishScript(Long projectId, Long userId, String model);
|
||||
ShortDramaDetailVo analyzeAssets(Long projectId, Long scriptId, Long userId, String model);
|
||||
SseEmitter analyzeAssetsStream(Long projectId, Long scriptId, Long userId, String model, String requestId);
|
||||
java.util.Map<String, Object> assetAnalysisStatus(Long projectId, Long scriptId, String requestId, Long userId);
|
||||
|
||||
/** 按修改意见重新生成固定格式剧本 */
|
||||
ShortDramaDetailVo polishScript(Long projectId, Long userId, String instruction);
|
||||
|
||||
/** Phase 3-6: 分镜规划+摄影规则+表演指导+分镜细化 */
|
||||
List<ShortDramaStoryboardVo> planStoryboard(Long projectId, Long scriptId, String model, Long userId);
|
||||
List<ShortDramaStoryboardVo> planStoryboard(Long projectId, Long scriptId, String model, Long userId, Integer minimumShotSeconds);
|
||||
|
||||
/** Phase 3-6: 流式生成分镜并推送实时进度 */
|
||||
SseEmitter planStoryboardStream(Long projectId, Long scriptId, String model, Long userId);
|
||||
SseEmitter planStoryboardStream(Long projectId, Long scriptId, String model, Long userId, Integer minimumShotSeconds);
|
||||
SseEmitter planStoryboardStream(Long projectId, Long scriptId, String model, Long userId, Integer minimumShotSeconds, String requestId);
|
||||
java.util.Map<String, Object> storyboardPlanningStatus(Long projectId, Long scriptId, String requestId, Long userId);
|
||||
java.util.Map<String, Object> storyboardCandidates(Long projectId, Long scriptId, String model, Long userId, Integer minimumShotSeconds);
|
||||
|
||||
int importReviewedPlan(Long projectId, org.ruoyi.domain.bo.shortdrama.ShortDramaReviewedPlanBo review, Long userId);
|
||||
|
||||
java.util.Map<String, Object> storyboardCheckpointStatus(Long projectId, Long scriptId, String model, Long userId);
|
||||
java.util.Map<String, Object> storyboardCheckpointStatus(Long projectId, Long scriptId, String model, Long userId, Integer minimumShotSeconds);
|
||||
|
||||
/** Phase 4: 重新生成摄影规则 */
|
||||
List<ShortDramaStoryboardVo> generatePhotographyRules(Long projectId, Long scriptId, Long userId);
|
||||
|
||||
@@ -77,6 +97,7 @@ public interface IShortDramaService {
|
||||
ShortDramaCharacterVo generateCharacterImage(Long characterId, String imageModel, String referenceImageUrl, Long userId);
|
||||
|
||||
ShortDramaLocationVo generateLocationImage(Long locationId, String imageModel, String referenceImageUrl, Long userId);
|
||||
ShortDramaLocationVo generateLocationImage(Long locationId, String imageModel, String referenceImageUrl, String revisionRequirements, Long userId);
|
||||
|
||||
Boolean deleteCharacter(Long characterId, Long userId);
|
||||
|
||||
@@ -97,6 +118,7 @@ public interface IShortDramaService {
|
||||
ShortDramaCharacterAppearanceVo undoAppearanceImage(Long appearanceId, Long userId);
|
||||
|
||||
ShortDramaLocationVo regenerateLocationImage(Long locationId, String imageModel, String referenceImageUrl, Long userId);
|
||||
ShortDramaLocationVo regenerateLocationImage(Long locationId, String imageModel, String referenceImageUrl, String revisionRequirements, Long userId);
|
||||
|
||||
ShortDramaLocationVo selectLocationImage(Long locationId, Integer index, Long userId);
|
||||
|
||||
@@ -107,6 +129,14 @@ public interface IShortDramaService {
|
||||
/** 异步启动图片生成,返回 prediction 信息供前端轮询 */
|
||||
MediaGenerationResponse startImageGeneration(String assetType, Long assetId, String model, String referenceImageUrl, Long userId);
|
||||
|
||||
/** Explicit identity revision produces an unselected candidate and retains approved continuity. */
|
||||
MediaGenerationResponse startImageGeneration(String assetType, Long assetId, String model, String referenceImageUrl,
|
||||
String referencePurpose, String revisionRequirements, Long userId);
|
||||
|
||||
MediaGenerationResponse startImageGeneration(String assetType, Long assetId, String model, String referenceImageUrl,
|
||||
String referencePurpose, String revisionRequirements,
|
||||
java.util.List<String> styleReferenceImageUrls, Long userId);
|
||||
|
||||
/** 上传到图片模型供应商,返回仅用于当前生成会话的临时参考图 URL。 */
|
||||
String uploadReferenceImage(MultipartFile file, String model, Long userId);
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacterAppearance;
|
||||
|
||||
/** Resolve explicit costume bindings identically for keyframes and video references. */
|
||||
final class ShortDramaAppearanceResolver {
|
||||
private ShortDramaAppearanceResolver() { }
|
||||
static ShortDramaCharacterAppearance resolve(List<ShortDramaCharacterAppearance> variants, String binding) {
|
||||
if (variants == null || variants.isEmpty()) return null;
|
||||
String key = binding == null ? "" : binding.strip();
|
||||
if (key.isEmpty() || key.equals("初始形象")) return variants.get(0);
|
||||
for (var item : variants) if (key.equals(item.getChangeReason())) return item;
|
||||
var display = java.util.regex.Pattern.compile("^\\[\\s*(\\d+)\\s*]\\s*(.*)$").matcher(key);
|
||||
if (display.matches()) {
|
||||
int index = Integer.parseInt(display.group(1));
|
||||
String name = display.group(2).strip();
|
||||
var indexed = variants.stream().filter(item -> Objects.equals(item.getAppearanceIndex(), index)).toList();
|
||||
if (indexed.size() == 1 && (name.isEmpty() || name.equals(indexed.get(0).getChangeReason()))) return indexed.get(0);
|
||||
throw new IllegalArgumentException("角色形象编号和名称不匹配:" + key);
|
||||
}
|
||||
String numeric = key.replaceAll("^\\[\\s*|\\s*\\]$", "");
|
||||
if (numeric.matches("\\d+")) {
|
||||
int index = Integer.parseInt(numeric);
|
||||
for (var item : variants) if (Objects.equals(item.getAppearanceIndex(), index)) return item;
|
||||
}
|
||||
var matches = variants.stream().filter(item -> item.getChangeReason() != null
|
||||
&& item.getChangeReason().contains(key)).toList();
|
||||
if (matches.size() == 1) return matches.get(0);
|
||||
throw new IllegalArgumentException("角色形象绑定无法唯一匹配:" + key + ",请使用已存在形象名称或编号");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
|
||||
/** Shared aesthetic direction with separate identity, space, prop and actual-shot responsibilities. */
|
||||
final class ShortDramaAssetAesthetics {
|
||||
private ShortDramaAssetAesthetics() {}
|
||||
|
||||
private static final class Bundled {
|
||||
static final String TEXT = ShortDramaDirectorSkills.read("asset-aesthetics");
|
||||
static final String VERSION = "asset-aesthetics-v1-" + DigestUtil.sha256Hex(TEXT).substring(0, 12);
|
||||
}
|
||||
|
||||
static String version() { return Bundled.VERSION; }
|
||||
|
||||
private static String scope(String instruction) {
|
||||
return "\n[asset-aesthetics:" + version() + "]\n"
|
||||
+ "【本次审美规范适用范围】\n" + instruction
|
||||
+ "审美增强服从用户画风、当前年代、身份、年龄、发展阶段及已批准连续性;只把已有内容的造型、材质与构图做清楚,不新增人物、装备、剧情成果、皮肤记号或豪华规模。\n";
|
||||
}
|
||||
|
||||
static String characterReference() {
|
||||
return scope("人物身份参照:清楚展示本张既定身份的脸形、年龄、发式和服饰裁片、层次、扣结、材质;多个视角是同一身份,不是多名演员。非人形主体按自身几何与材质审美,不套用人脸、服装或人体比例。");
|
||||
}
|
||||
|
||||
static String locationReference() {
|
||||
return scope("场景空间参照:使用本张地点规模、阶段、时点和明确陈设建立可读的空间层次、建筑构造与材质。保持本张无人物参照用途,不套用人物定妆图布局,不扩大贫困或建设初期地点,也不从总世界观加入未来物件。");
|
||||
}
|
||||
|
||||
static String propReference(String artStyle, String worldbuilding) {
|
||||
return scope("物品形制参照:单件已有道具,中性背景,结构、用途、接合方式、材质和尺度感清楚;只展示该物既有形制,不额外增加人物、另一种道具、装饰或可执行武器制造细节。")
|
||||
+ ShortDramaAssetWorldPrompt.style(artStyle) + ShortDramaAssetWorldPrompt.context(worldbuilding)
|
||||
+ ShortDramaAssetWorldPrompt.currentStateRules();
|
||||
}
|
||||
|
||||
static String shotFrame() {
|
||||
return scope("实际镜头首帧:身份图锁身份、已批准外观;空间图锁几何,当前镜头决定机位、景别、光色和0秒状态。只用一幅镜头构图,不套用多视角定妆图。严格保持起始动作、原文、登记演员与匿名群演人数、CG比例、持物、出入口及局部构图指令,不能为了好看提前演结果或复制演员。");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.ruoyi.service.media.AtlasMediaSupport;
|
||||
import java.util.*;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
/** One structured completion. Previews are never authoritative database assets. */
|
||||
final class ShortDramaAssetGeneration {
|
||||
record Result(List<JsonNode> characters, List<JsonNode> locations, List<JsonNode> props) {
|
||||
int size() { return characters.size() + locations.size() + props.size(); }
|
||||
}
|
||||
static String prompt(String script, String tone, String direction) {
|
||||
return ShortDramaDirectorSkills.load("visual-world", "character-art-direction")
|
||||
+ ShortDramaAssetAesthetics.characterReference() + ShortDramaAssetAesthetics.locationReference()
|
||||
+ direction + "\n剧本风格:" + Objects.toString(tone, "") + "\n" + """
|
||||
你是短剧资产制作负责人。一次完成角色、场景、道具提取及它们的出图描述,仅输出一个JSON对象。
|
||||
只提取正文中实际出现、需要保持一致的资产,不补写剧情、分镜、摄影规则、运镜或视频指令。
|
||||
同名角色和同一地点只列一次,场景描述不能带人物。不要把云、风、光线当成道具。
|
||||
角色visualDescription须写清时代身份、年龄体态、五官发型、服装层次及颜色材质,便于独立出图。
|
||||
场景descriptions写空间结构、环境、材料和光色;availableSlots是可用站位名。
|
||||
道具description写形制、尺寸比例、材料、颜色、连接结构和识别特征,不写持有人或摄影指导。
|
||||
组合器物保留完整主体,剧本明确提及且需要单独保持一致的部件也单列,名称不能与主体混淆。
|
||||
逐项核对正文中的实体器物,不因已在主体描述中提到就省略独立道具。
|
||||
具有操作、固定、承重或连接作用的绳索、绑带、扣件也是道具,不能归入普通衣饰而漏掉。
|
||||
所有名称、描述使用中文,描述简洁具体,不复制整段剧本。没有道具时props为空数组。
|
||||
字段格式如下(值按剧本填写,不把示例当实际资产):
|
||||
{"characters":[{"name":"角色名","aliases":"","introduction":"身份与作用","roleLevel":"S",
|
||||
"gender":"男","ageRange":"成年","personalityTags":"","costumeTier":2,"visualDescription":"独立角色出图描述"}],
|
||||
"locations":[{"name":"场景名","summary":"场景用途","hasCrowd":false,"crowdDescription":"",
|
||||
"availableSlots":["站位名"],"descriptions":["独立空场景出图描述"]}],
|
||||
"props":[{"name":"道具名","description":"独立道具出图描述"}]}
|
||||
剧本正文:
|
||||
""" + script;
|
||||
}
|
||||
static Result parse(String raw) {
|
||||
if (raw == null) throw new IllegalStateException("资产分析没有返回正文");
|
||||
int start = raw.indexOf('{'), end = raw.lastIndexOf('}');
|
||||
if (start < 0 || end < start) throw new IllegalStateException("资产分析没有返回有效JSON,原资产保留");
|
||||
try {
|
||||
JsonNode root = AtlasMediaSupport.OBJECT_MAPPER.readTree(raw.substring(start, end + 1));
|
||||
var characters = list(root, "characters", true, 30);
|
||||
var locations = list(root, "locations", true, 40);
|
||||
var props = list(root, "props", false, 30);
|
||||
for (var item : characters) text(item, "visualDescription", 12000);
|
||||
for (var item : locations) {
|
||||
JsonNode descriptions = item.path("descriptions");
|
||||
if (!descriptions.isArray() || descriptions.isEmpty()) throw new IllegalStateException("场景缺少出图描述");
|
||||
for (var description : descriptions)
|
||||
if (!description.isTextual() || description.asText().isBlank() || description.asText().length() > 12000)
|
||||
throw new IllegalStateException("场景出图描述无效");
|
||||
}
|
||||
for (var item : props) text(item, "description", 12000);
|
||||
return new Result(characters, locations, props);
|
||||
} catch (java.io.IOException e) { throw new IllegalStateException("资产清单JSON不完整,原资产保留", e); }
|
||||
}
|
||||
private static List<JsonNode> list(JsonNode root, String field, boolean required, int maximum) {
|
||||
JsonNode array = root.path(field);
|
||||
if (!array.isArray() || (required && array.isEmpty()) || array.size() > maximum)
|
||||
throw new IllegalStateException(field + "清单无效,原资产保留");
|
||||
var unique = new LinkedHashMap<String, JsonNode>();
|
||||
for (var item : array) {
|
||||
String name = text(item, "name", 80);
|
||||
// Validate every original item before de-duplicating, including repeated names.
|
||||
if ("characters".equals(field)) text(item, "visualDescription", 12000);
|
||||
if ("props".equals(field)) text(item, "description", 12000);
|
||||
unique.putIfAbsent(name.toLowerCase(Locale.ROOT), item);
|
||||
}
|
||||
return List.copyOf(unique.values());
|
||||
}
|
||||
private static String text(JsonNode item, String field, int maximum) {
|
||||
JsonNode value = item.path(field);
|
||||
if (!value.isTextual() || value.asText().isBlank() || value.asText().length() > maximum)
|
||||
throw new IllegalStateException("资产字段" + field + "缺少有效内容");
|
||||
return value.asText().trim();
|
||||
}
|
||||
/** Scan each incoming character once; nested arrays and quoted braces do not end an asset. */
|
||||
static final class Preview {
|
||||
private final BiConsumer<Integer, JsonNode> sink;
|
||||
private final StringBuilder buffer = new StringBuilder(), string = new StringBuilder();
|
||||
private int objects, arrays, start = -1, category;
|
||||
private boolean quoted, escape;
|
||||
private String lastString = "";
|
||||
Preview(BiConsumer<Integer, JsonNode> sink) { this.sink = sink; }
|
||||
synchronized void accept(String chunk) {
|
||||
if (buffer.length() + chunk.length() > 500_000) return;
|
||||
for (char c : chunk.toCharArray()) {
|
||||
int index = buffer.length(); buffer.append(c);
|
||||
if (quoted) {
|
||||
if (escape) { escape = false; string.append(c); }
|
||||
else if (c == '\\') { escape = true; string.append(c); }
|
||||
else if (c == '"') { quoted = false; lastString = string.toString(); }
|
||||
else string.append(c);
|
||||
continue;
|
||||
}
|
||||
if (c == '"') { quoted = true; string.setLength(0); }
|
||||
else if (c == '[') {
|
||||
if (objects == 1 && arrays == 0)
|
||||
category = switch (lastString) { case "characters" -> 1; case "locations" -> 2; case "props" -> 3; default -> 0; };
|
||||
arrays++;
|
||||
} else if (c == ']') { arrays--; if (arrays == 0) category = 0; }
|
||||
else if (c == '{') { if (objects == 1 && arrays == 1 && category != 0) start = index; objects++; }
|
||||
else if (c == '}') {
|
||||
objects--;
|
||||
if (objects == 1 && arrays == 1 && category != 0 && start >= 0) {
|
||||
try { sink.accept(category, AtlasMediaSupport.OBJECT_MAPPER.readTree(buffer.substring(start, index + 1))); }
|
||||
catch (java.io.IOException ignored) { /* Full response validation decides success. */ }
|
||||
start = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import org.ruoyi.constant.ShortDramaImageConstants;
|
||||
|
||||
/** World rules constrain an asset's era/materials; they are not a list of things to add to its image. */
|
||||
final class ShortDramaAssetWorldPrompt {
|
||||
private ShortDramaAssetWorldPrompt() {}
|
||||
|
||||
static String context(String worldbuilding) {
|
||||
if (worldbuilding == null || worldbuilding.isBlank()) return "";
|
||||
return "\n【当前项目世界观:年代、材料与发展阶段上下文】\n" + worldbuilding
|
||||
+ "\n【上下文使用边界】以上提供年代、材料可获得性、资源规模与阶段因果约束,不是本图物件清单。"
|
||||
+ "只画下面本张资产描述确定的当前时刻;世界观中的后续发展、未来装备、其他年代人物与计划成果不自动进入本图。"
|
||||
+ "本张描述决定当前可见物件与阶段,未写已建成、已成熟或已引进的内容保持未发生,不从总纲补入。\n";
|
||||
}
|
||||
|
||||
static String style(String artStyle) {
|
||||
String style = artStyle == null ? ShortDramaImageConstants.DEFAULT_ART_STYLE : artStyle;
|
||||
return ShortDramaImageConstants.artStylePrompt(style) + ("chinese-3d".equals(style)
|
||||
? "\n【可见三维动画造型】明确可辨的风格化CG雕塑造型,轮廓与体块有设计,清晰体面、受控色面和材质层次,动画电影的三维布景与角色渲染;"
|
||||
+ "保持描述中的身份比例与年龄,不靠夸大五官改脸。发束成组设计,布料细节服从CG造型,皮肤使用受控色阶与克制散射;"
|
||||
+ "不用仿真人数字人的摄影皮肤、逐根写真发丝或真人棚拍来假扮三维动画,也不变成二维平涂、塑料玩偶。\n"
|
||||
: "");
|
||||
}
|
||||
|
||||
static String currentStateRules() {
|
||||
return "\n【本张年代与当前状态】服装、建筑和物件按本张所处年代及明确剧情依据实现;世界观若包含多个年代,不混用其他时点的服装和材料。"
|
||||
+ "前现代时点且没有明确的已引进依据时,不添现代塑料薄膜、塑料育苗盘、工业玻璃棚或西式翻领衬衫。"
|
||||
+ "贫困或建设初期的空间保持描述中的小规模与未完成状态,不为了电影感增加繁华都市、巨大水面、豪华铺装或已竣工的壮阔城防。"
|
||||
+ "作物种类、生长阶段、叶片数量和密度遵从本张描述;苗床、幼苗与初次试种不能画成成熟稻穗、满田丰收或提前长成的作物。"
|
||||
+ "默认已批准参考的身份与衣物仍保持;发现其与时代约束冲突须明确修订,不以年代规则擅自换脸换衣。\n";
|
||||
}
|
||||
|
||||
static String location(String description, String artStyle, String worldbuilding) {
|
||||
return location(null, description, artStyle, worldbuilding);
|
||||
}
|
||||
|
||||
static String location(String name, String description, String artStyle, String worldbuilding) {
|
||||
String timeAndPlace = name == null || name.isBlank() ? "" : "【本张场景名称与时点】\n" + name
|
||||
+ "\n名称中的年代、季候与昼夜是当前图的时间约束,描述中未重复时也保留;不把深夜画成白昼窗景,不从名称猜测额外建筑或陈设。\n";
|
||||
return ShortDramaDirectorSkills.media("visual-world")
|
||||
+ ShortDramaAssetAesthetics.locationReference()
|
||||
+ "【用途:当前阶段的无人物空间参照图】\n" + style(artStyle) + context(worldbuilding)
|
||||
+ timeAndPlace
|
||||
+ "【本张场景实际描述】\n" + (description == null ? "" : description)
|
||||
+ "\n按上述场景实际规模建立完整空间构图,入口、窗、工作区和主要陈设清楚;不靠扩大城镇、河湖或建筑尺度制造宏大感。"
|
||||
+ ShortDramaImageConstants.LOCATION_PROMPT_SUFFIX + "。牌匾、城名、招牌与可读文字不凭空创造,需精确文字留给后期。"
|
||||
+ currentStateRules();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import org.ruoyi.domain.entity.shortdrama.ShortDramaCharacter;
|
||||
import org.ruoyi.service.shortdrama.impl.ShortDramaServiceImpl.StoryboardPanelData;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Anonymous extras are a first-frame staging description, never an asset identity or speaker replacement. */
|
||||
final class ShortDramaBackgroundExtras {
|
||||
private ShortDramaBackgroundExtras() {}
|
||||
private static final Pattern COUNT = Pattern.compile("^0(?:秒|s)\\s*[::]\\s*可见人数\\s*[=::]\\s*(\\d+)(?:\\s*[-–—~~至]\\s*(\\d+))?\\s*[;;]");
|
||||
// Role/category vocabulary, rather than a list of identities from any particular drama.
|
||||
// A generic occupation alias does not uniquely identify its registered foreground representative.
|
||||
private static final String CATEGORY = "军队|敌军|守军|援军|骑兵|步兵|乱兵|溃兵|散兵|兵卒|军士|士兵|军群|群众|百姓|村民|乡亲|路人|行人|人群|众人|同伴|差役|衙役|工匠|宾客|队伍|护卫|兵士|乡勇|徒弟|学徒|学生|工人|农民|农户|商人|顾客|乘客|医护人员|护士|医生|警员|警察|记者|店员|伙计|侍卫|侍从|仆人|仆役|富户|宫女|老人|老汉|少年|少女|儿童";
|
||||
private static final Pattern GROUP = Pattern.compile(CATEGORY);
|
||||
private static final Pattern GENERIC_NAME = Pattern.compile("^(?:(?:前景|后景|年轻|年老|老|小|普通|匿名|无名|一名|领队的|排首的|带头的|男|女))*(?:" + CATEGORY + ")$");
|
||||
static boolean genericRole(String name) { return name != null && GENERIC_NAME.matcher(name.trim()).matches(); }
|
||||
static String readText(com.fasterxml.jackson.databind.JsonNode continuity) {
|
||||
var value = continuity.path("background_extras");
|
||||
if (!value.isMissingNode() && !value.isNull() && !value.isTextual())
|
||||
throw new IllegalArgumentException("background_extras必须为文字字段");
|
||||
return value.asText("");
|
||||
}
|
||||
record FirstFrame(int minimum, int maximum, String text) { boolean visible() { return maximum > 0; } }
|
||||
static FirstFrame parse(String text) {
|
||||
if (text == null || text.isBlank()) return new FirstFrame(0, 0, "");
|
||||
if (text.length() > 4000) throw new IllegalArgumentException("background_extras不能超过4000字符");
|
||||
var count = COUNT.matcher(text.trim());
|
||||
if (!count.find()) throw new IllegalArgumentException("background_extras须以0秒:可见人数=整数或范围;开头");
|
||||
int min, max;
|
||||
try { min = Integer.parseInt(count.group(1)); max = count.group(2) == null ? min : Integer.parseInt(count.group(2)); }
|
||||
catch (NumberFormatException e) { throw new IllegalArgumentException("匿名群演首帧人数超出可用范围", e); }
|
||||
if (min < 0 || max < min || max > 1_000_000) throw new IllegalArgumentException("匿名群演首帧人数或范围无效");
|
||||
if (Pattern.compile("(?i)character[_-]?id|appearance[_-]?id|asset[_-]?id|角色\\s*ID|@image").matcher(text).find())
|
||||
throw new IllegalArgumentException("匿名群演不能使用角色ID或具名角色参考图");
|
||||
// An explicit zero count describes an empty first-frame extras roster.
|
||||
// There is no visible person whose costume or staging needs validation.
|
||||
if (max == 0) return new FirstFrame(0, 0, text.trim());
|
||||
for (String field : List.of("身份服饰", "位置", "姿态动作")) {
|
||||
if (fieldValue(text, field).isBlank()) throw new IllegalArgumentException("background_extras缺少" + field);
|
||||
}
|
||||
if (!text.contains("匿名") && !text.contains("无名")) throw new IllegalArgumentException("background_extras必须明确匿名群演,不创建命名角色");
|
||||
return new FirstFrame(min, max, text.trim());
|
||||
}
|
||||
static String frameGuidance(String text) {
|
||||
FirstFrame extras = parse(text);
|
||||
if (!extras.visible()) return "0秒匿名群演可见人数=0,不提前画入之后才入场的人物。";
|
||||
return extras.text() + "\n以上只定义0秒静态人数、位置和姿态;不提前执行后续入场、攀登完成或冲锋结果。匿名群演保持不同脸型与服饰细节,不能复用任何具名角色的身份参考。";
|
||||
}
|
||||
static void validateScene(String scene, List<ShortDramaCharacter> registered, List<StoryboardPanelData> panels) {
|
||||
String anonymousSource = Objects.toString(scene, "").replaceAll("「[^」]*」", "");
|
||||
for (var c : registered) {
|
||||
if (c.getName() != null) {
|
||||
// Remove an explicit representative, but preserve category plurals such as “徒弟们”.
|
||||
anonymousSource = removeRepresentative(anonymousSource, c.getName());
|
||||
}
|
||||
for (String alias : Objects.toString(c.getAliases(), "").split("[,,、;;\\n]"))
|
||||
if (uniqueAlias(alias)) anonymousSource = anonymousSource.replace(alias.trim(), "");
|
||||
}
|
||||
boolean sourceHasGroup = GROUP.matcher(anonymousSource).find();
|
||||
for (var panel : panels) {
|
||||
String text = panel.getBackgroundExtras();
|
||||
if (!parse(text).visible()) continue;
|
||||
if (!sourceHasGroup) throw new ShortDramaSceneCheckpoint.BindingMismatch("本场原文未安排匿名群演,不能借background_extras增加人物");
|
||||
String identity = fieldValue(text, "身份服饰");
|
||||
String staging = fieldValue(text, "位置") + ";" + fieldValue(text, "姿态动作");
|
||||
Set<String> visibleNamed = new HashSet<>(panel.getPresentCharacters() == null ? List.of() : panel.getPresentCharacters());
|
||||
for (var c : registered) {
|
||||
if (c.getName() != null && !genericRole(c.getName())) checkIdentityReference(c.getName(), c.getName(), identity, staging, visibleNamed);
|
||||
for (String alias : Objects.toString(c.getAliases(), "").split("[,,、;;\\n]"))
|
||||
if (uniqueAlias(alias)) checkIdentityReference(c.getName(), alias.trim(), identity, staging, visibleNamed);
|
||||
}
|
||||
}
|
||||
}
|
||||
private static void checkIdentityReference(String canonical, String name, String identity, String staging, Set<String> visibleNamed) {
|
||||
if (identity.contains(name)) throw new ShortDramaSceneCheckpoint.BindingMismatch("background_extras不能隐藏具名角色或CG:" + canonical + "(" + name + ")");
|
||||
// Position/anti-cloning instructions may refer to an already visible registered foreground
|
||||
// actor. They cannot turn an absent/later actor or CG into a zero-second physical landmark.
|
||||
if (staging.contains(name) && !visibleNamed.contains(canonical))
|
||||
throw new ShortDramaSceneCheckpoint.BindingMismatch("background_extras定位参照未在0秒具名名册中:" + canonical + "(" + name + ")");
|
||||
}
|
||||
private static String fieldValue(String text, String field) {
|
||||
var value = Pattern.compile(Pattern.quote(field) + "\\s*[=::]([^;;\\n]*)").matcher(text);
|
||||
return value.find() ? value.group(1).trim() : "";
|
||||
}
|
||||
private static boolean uniqueAlias(String alias) {
|
||||
String value = alias.trim();
|
||||
return value.length() >= 2 && !value.equals("无") && !genericRole(value);
|
||||
}
|
||||
private static String removeRepresentative(String source, String name) {
|
||||
var matcher = Pattern.compile(Pattern.quote(name)).matcher(source);
|
||||
StringBuffer result = new StringBuffer();
|
||||
while (matcher.find()) {
|
||||
String before = source.substring(Math.max(0, matcher.start() - 30), matcher.start());
|
||||
String after = source.substring(matcher.end(), Math.min(source.length(), matcher.end() + 3));
|
||||
boolean plural = genericRole(name) && (after.matches("^(?:们|群|队).*")
|
||||
|| before.matches("(?s).*(?:[二两三四五六七八九十百千万]+|[2-9]|[1-9][0-9]+)[名个位户][^。;;\\n]{0,18}")
|
||||
|| before.matches("(?s).*(?:其余|其他|一群|众多|数名|几名|多名|全体|一些|一队)[^。;;\\n]{0,18}"));
|
||||
matcher.appendReplacement(result, plural ? java.util.regex.Matcher.quoteReplacement(name) : "");
|
||||
}
|
||||
matcher.appendTail(result); return result.toString();
|
||||
}
|
||||
static String promptRules() {
|
||||
return "\n【登记身份与匿名群演:最高优先级】characters、present_characters、performance_beats.name只允许当前原文明确安排的精确登记角色名,包含明确可见的必要CG;全剧角色库与上一场名册不是本场出演许可。"
|
||||
+ "原对白画外声保留原说话人/source_text与声源,不为画外声强画实体;同伴、乱兵、溃兵、百姓等未登记匿名群演绝不能自造群像角色名、别名、ID或借用某个头目的身份。"
|
||||
+ "匿名群演在description、起止状态中写可见动作,且用单个background_extras文字字段明确0秒人数、身份服饰、位置和静态姿态;具名/登记演员名单与匿名群演分开,不要求每个可见人头都成为角色资产。"
|
||||
+ "字段格式:0秒:可见人数=6-8;身份服饰=匿名乱兵,粗布军装、不同脸型;位置=梯架两侧;姿态动作=躬身扶梯,尚未登顶。"
|
||||
+ "无匿名群演时字段为空;后续才入场时0秒人数写0,后续动作放video_prompt和story_action。身份服饰段禁止把具名、声源或CG藏入匿名身份;位置/姿态可引用present_characters中0秒已可见的登记演员作为定位,引用不产生新的演员。差役、乡勇、徒弟等职业泛称可同时用于登记前景代表与其余匿名同类,泛称不是每个人的唯一姓名。\n"
|
||||
+ "【单镜地点精确绑定】每镜location只能逐字选择一条真实登记场景名,不自造、缩写、加后缀或把两个名字用及/和/斜杠合并。"
|
||||
+ "原文多地点标题只是本场预算说明,逐镜按当前主体所在空间选择对应资产;城头与城外郊野分别绑定各自地点,必要时拆镜并做桥接,同一原文预算场编号保持。\n";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import org.ruoyi.constant.ShortDramaImageConstants;
|
||||
|
||||
/** Shared casting-reference rules for synchronous, asynchronous and edit image requests. */
|
||||
final class ShortDramaCharacterArtPrompt {
|
||||
// Only an explicit statement about the subject's anatomy selects this branch; decorative animal motifs do not.
|
||||
private static final java.util.regex.Pattern NON_HUMAN_SUBJECT = java.util.regex.Pattern.compile(
|
||||
"(?:^|[。;;,,::\\n])\\s*(?:(?:主体|本体|该角色|其)(?:明确)?(?:为|是)?\\s*)?"
|
||||
+ "(?:(?:无|没有|不具备|不具有|不含)(?:常规)?(?:人类|人形)(?:的)?(?:五官|躯体|身体)"
|
||||
+ "|(?:外观|形态|主体)(?:明确)?(?:为|是|呈)?非人形|非人形(?:虚拟)?(?:主体|角色|实体))");
|
||||
|
||||
private ShortDramaCharacterArtPrompt() {}
|
||||
|
||||
static String reference(String description, String artStyle) {
|
||||
return reference(description, artStyle, null, null);
|
||||
}
|
||||
|
||||
/** An explicit revision uses the supplied image for identity, not for the rejected wardrobe/material. */
|
||||
static String reference(String description, String artStyle, String referencePurpose, String revisionRequirements) {
|
||||
return reference(description, artStyle, referencePurpose, revisionRequirements, null);
|
||||
}
|
||||
|
||||
static String reference(String description, String artStyle, String referencePurpose, String revisionRequirements, String worldbuilding) {
|
||||
boolean revision = isRevision(referencePurpose, revisionRequirements);
|
||||
String style = artStyle == null ? ShortDramaImageConstants.DEFAULT_ART_STYLE : artStyle;
|
||||
String subjectDescription = description == null ? "" : description;
|
||||
// Portraits are independent casting masters, not a small inset in a full-body sheet.
|
||||
boolean portraitReference = subjectDescription.matches("(?s).*(?:单幅|单张)(?:头肩|面部|脸部|胸像)(?:面部)?(?:母版|定妆|肖像|特写).*" );
|
||||
boolean singleReference = subjectDescription.matches("(?s).*(?:单幅(?:角色|人物|定妆|全身)|单张(?:角色|人物|定妆|全身)|单人\\s*3/4\\s*全身|(?:不要|不做|不用)三视图).*" );
|
||||
if (NON_HUMAN_SUBJECT.matcher(subjectDescription).find()) {
|
||||
return nonHumanReference(subjectDescription, style, worldbuilding) + revisionRules(revision, revisionRequirements, true);
|
||||
}
|
||||
String material = switch (style) {
|
||||
case "custom-skill" -> "【风格约束】人物造型、肤质、毛发、衣物材质和光色全部服从选中审美技能正文,不从旧基础画风强加真人写真、二维平涂或国风三维预设;身份、年龄和服装裁片仍服从本张实际描述。\n";
|
||||
case "realistic" -> "【写实身份参照材质】自然未磨皮的成人肤质,细微毛孔、正常细小汗毛和年龄痕迹,眼球湿润但不过度放大,发际线与发束自然;衣料织理、接缝、厚薄和受力褶皱可辨。维护程度及磨损位置以角色描述为准,不统一加旧、加脏或新增皮肤记号。柔和中性光保留体积和肌肤中间调,避免美颜滤镜、塑料皮肤和电商假发感。\n";
|
||||
case "chinese-3d" -> "【三维动画身份参照材质】采用国风动画电影的风格化CG雕塑造型,面部有清晰的体面、轮廓和受控色阶,一看就是三维动画角色;自然东方面部结构与身体比例,CG发束和毛发、PBR布料织理与接缝,发束成组且有设计,衣纹服从建模体块,厚薄和受力褶皱可辨;受控次表面散射与柔和中性体积光塑造动画皮肤,保留立体明暗和材质层次,不出现摄影毛孔或真人实拍肤质,不使用仿真人数字人写真、逐根摄影发丝或真人棚拍,不用二维线稿或平涂表达材质,不做塑料玩偶或油亮蜡像。年龄、服装裁片、配饰及已有标志以角色描述为准,不自动美化或增加污损。\n";
|
||||
default -> "【风格约束】严格采用项目指定画风,脸部、毛发和衣料细节用该画风的线条与上色表达;不混入真人肤质或摄影景深。\n";
|
||||
};
|
||||
return ShortDramaDirectorSkills.media("visual-world", "character-art-direction")
|
||||
+ ShortDramaAssetAesthetics.characterReference()
|
||||
+ "【用途:身份参照图,不是剧情首帧】\n"
|
||||
+ ShortDramaAssetWorldPrompt.style(style) + "\n" + material
|
||||
+ ShortDramaAssetWorldPrompt.context(worldbuilding)
|
||||
+ "【本张角色当前形象描述】\n"
|
||||
+ (portraitReference ? "【本张构图优先】单幅头肩面部母版:只画一位人物,从完整头发帽饰至肩胸;脸部为视觉中心,双眼、眉毛、鼻唇边缘与发际线清楚,头脸占比服从本张描述。通用完整服装规则仅用于全身定妆,本张不附全身小人、不拼接三视图、不加文字标签。"
|
||||
: singleReference ? "单幅身份定妆图:一个完整全身人物,自然静态3/4姿势,完整头发帽饰与双脚在画内,中性简洁背景,清晰脸形、服装裁片及原描述中的既有配饰;不做多视角拼图,不复制成多个演员,不加文字标签。" : ShortDramaImageConstants.CHARACTER_PROMPT_PREFIX)
|
||||
+ subjectDescription
|
||||
+ (portraitReference || singleReference ? "。本张只画一个当前身份,不增加第二视角;脸部、年龄、发际线、衣物和左右标志按本轮描述与批准约束落实。" : ShortDramaImageConstants.CHARACTER_PROMPT_SUFFIX)
|
||||
+ (portraitReference || singleReference ? "" : revision
|
||||
? "本候选的脸部特写与三个全身视角必须同一张脸、同一发际线;各视角采用本轮修订后的同一服装裁片、配饰和左右侧标志。主角身份不自动增加颜值或服装华丽度。\n"
|
||||
: "脸部特写与三个全身视角必须同一张脸、同一发际线、同一服装裁片、同一配饰与左右侧标志。主角身份不自动增加颜值或服装华丽度。")
|
||||
+ ("chinese-3d".equals(style)
|
||||
? "\n【材质优先级】项目国风三维动画画风优先于描述或参考图里的‘写实肤质、细微毛孔、真人写真’等材质表述;这些仅可提供脸部比例和年龄线索,须重建为有体积的三维动画皮肤、CG发束与PBR布料,不复制摄影皮肤纹理。\n"
|
||||
: "")
|
||||
+ ShortDramaAssetWorldPrompt.currentStateRules()
|
||||
+ revisionRules(revision, revisionRequirements, false);
|
||||
}
|
||||
|
||||
static boolean isRevision(String referencePurpose, String revisionRequirements) {
|
||||
String purpose = referencePurpose == null || referencePurpose.isBlank() ? "identity" : referencePurpose.trim();
|
||||
if (!"identity".equals(purpose) && !"identity_revision".equals(purpose)) {
|
||||
throw new IllegalArgumentException("不支持的参考用途: " + purpose);
|
||||
}
|
||||
boolean revision = "identity_revision".equals(purpose);
|
||||
if (revision && (revisionRequirements == null || revisionRequirements.isBlank())) {
|
||||
throw new IllegalArgumentException("修订候选必须填写明确的修订要求");
|
||||
}
|
||||
if (!revision && revisionRequirements != null && !revisionRequirements.isBlank()) {
|
||||
throw new IllegalArgumentException("修订要求须配合 referencePurpose=identity_revision 使用");
|
||||
}
|
||||
if (revisionRequirements != null && revisionRequirements.length() > 4000) {
|
||||
throw new IllegalArgumentException("修订要求不能超过4000字符");
|
||||
}
|
||||
return revision;
|
||||
}
|
||||
|
||||
private static String revisionRules(boolean revision, String requirements, boolean nonHuman) {
|
||||
if (!revision) return "";
|
||||
String identity = nonHuman
|
||||
? "参考图只绑定主体的核心身份与未指定修改的形态辨识点;材质与明确指定的结构修正按本轮要求执行,不添加人体结构。"
|
||||
: "参考图只绑定脸部身份:脸型、眉眼鼻口比例、年龄、发际线及未指定修改的辨识点;不绑定参考图的摄影皮肤材质或被本轮明确纠正的服装。";
|
||||
String roundNeck = !nonHuman && requirements.contains("圆领")
|
||||
? "\n【圆领可见几何】本轮指定圆领时,正面领口为绕颈闭合的圆弧,左右领缘不交叉,前胸没有斜向重叠的V形交领线;用正面及侧面视角清楚展示闭合圆领的领缘和接缝。"
|
||||
: "";
|
||||
return "\n【用途:身份绑定的修订候选,尚未批准连续性】\n" + identity
|
||||
+ "只修改本轮明确列出的材质、服装或结构,未指定的身份特征、配饰及左右标志仍保持。世界规范中的默认服装锁适用于已批准参考;本轮指定修订项以以下要求为准,不沿用被拒参考的旧裁片。新候选各视角保持同一修订结果。\n"
|
||||
+ "【本轮明确修订要求】\n" + requirements.trim() + roundNeck
|
||||
+ "\n此图仅作为待审候选,须人工审阅选择后才能成为后续镜头的批准连续性参考。";
|
||||
}
|
||||
|
||||
private static String nonHumanReference(String description, String style, String worldbuilding) {
|
||||
// Human casting/makeup rules do not apply to a subject explicitly declared to have no human anatomy.
|
||||
String stylePrompt = switch (style) {
|
||||
case "chinese-3d" -> "国风电影级三维动画,Chinese donghua cinematic 3D animation;CG造型和PBR材质,克制的次表面散射、透明度和反射以描述为准,柔和中性体积光保留立体边缘与内部层次,不使用真人摄影实拍、二维赛璐璐平涂或黑色漫画线稿。";
|
||||
case "realistic" -> "影视写实的非人形主体材质参照,真实形体与表面质感,可解释的柔和中性光源和丰富中间调;材质、反射、透明度及维护状态服从描述,不增加人体结构。";
|
||||
case "chinese-comic" -> "现代国漫动画风格,Chinese donghua 2D comic style,赛璐璐平涂上色,干净锐利的黑色线稿、平面化光影与清晰形态边缘;不使用真人摄影、3D渲染或景深虚化。";
|
||||
default -> ShortDramaImageConstants.artStylePrompt(style);
|
||||
};
|
||||
return ShortDramaDirectorSkills.media("visual-world")
|
||||
+ ShortDramaAssetAesthetics.characterReference()
|
||||
+ "【用途:非人形主体形态参照图,不是剧情首帧】\n"
|
||||
+ "世界规范中涉及人体身份与妆造的通用条款仅适用于具备相应结构的主体;本图采用以下专用形态参照规范。\n"
|
||||
+ stylePrompt + "\n" + ShortDramaAssetWorldPrompt.context(worldbuilding)
|
||||
+ "单一主体的中性背景多角度形态参照:主视图展示完整形态,其余视图展示同一主体的正向、侧向、背向或其他有辨识价值的角度,附一处关键材质或几何特征近景。所有角度保持同一结构、相对尺度、颜色、材质和已有视觉锚;不自行增添人体结构或拟人化表情。\n"
|
||||
+ "【主体原始描述】" + description + "\n"
|
||||
+ "完整保留描述中的轮廓、棱角、悬浮状态、透明度、内部纹理、发光强弱与相对尺度。纯白或浅灰中性背景,无文字标签,不添加其他角色、道具、环境或剧情表演,也不用另一人物作尺寸对照。";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import okhttp3.*;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.common.chat.entity.audio.AudioContext;
|
||||
import org.ruoyi.common.chat.service.chat.IChatModelService;
|
||||
import org.ruoyi.domain.entity.shortdrama.*;
|
||||
import org.ruoyi.mapper.shortdrama.*;
|
||||
import org.ruoyi.service.audio.provider.AtlasSpeechPayloadBuilder;
|
||||
import org.ruoyi.service.media.AtlasMediaSupport;
|
||||
import org.ruoyi.service.media.AtlasPredictionService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Versioned role-owned samples. No invented provider voiceId, no automatic replacement of a selected voice. */
|
||||
@Service @RequiredArgsConstructor
|
||||
public class ShortDramaCharacterVoiceService {
|
||||
public static final String MODEL = "bytedance/seed-audio-1.0";
|
||||
private final ShortDramaSoundService sounds;
|
||||
private final ShortDramaCharacterMapper characters;
|
||||
private final ShortDramaCharacterAppearanceMapper appearances;
|
||||
private final ShortDramaStoryboardMapper storyboards;
|
||||
private final IChatModelService models;
|
||||
private final AtlasPredictionService predictions;
|
||||
private final Map<String,Object> roleLocks = new java.util.concurrent.ConcurrentHashMap<>();
|
||||
private Object lock(Long project,Long character) {return roleLocks.computeIfAbsent(project+":"+character,key -> new Object());}
|
||||
private final OkHttpClient http = new OkHttpClient.Builder().connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(180, TimeUnit.SECONDS).retryOnConnectionFailure(false).followRedirects(false).followSslRedirects(false).build();
|
||||
|
||||
public record Settings(String description, String sampleText) {}
|
||||
public record Sample(String id, String label, String model, String predictionId, String source, double duration, long createdAt) {}
|
||||
public record Profile(String characterId, String characterName, String description, String sampleText,
|
||||
String selectedSampleId, int version, List<Sample> samples) {}
|
||||
public record Generate(String requestId, String model, String description, String sampleText, String referenceSampleId) {}
|
||||
public record Job(String id, Generate request, String predictionId, String status, String error, String outputUrl, long createdAt) {}
|
||||
public record View(Profile profile, List<Job> jobs) {}
|
||||
public record Selection(String sampleId) {}
|
||||
public record SpeakersRequest(List<String> characterIds) {}
|
||||
public record Binding(String characterId, String characterName, String sampleId, int version, double duration, int audioIndex) {}
|
||||
public record Plan(List<Binding> bindings, List<ShortDramaSoundService.Reference> references, String direction, List<String> issues, boolean explicit, List<String> speakerIds) {
|
||||
public List<Map<String,Object>> fingerprints() {return references.stream().map(ShortDramaSoundService.Reference::fingerprint).toList();}
|
||||
}
|
||||
private Path directory(Long project, Long character) {return Path.of("data", "short-drama-sounds", project.toString(), "character-voices", character.toString()).toAbsolutePath();}
|
||||
private Path profileFile(Long project, Long character) {return directory(project, character).resolve("profile.json");}
|
||||
private Path jobFile(Long project, Long character, String id) {return directory(project, character).resolve(uuid(id) + ".json");}
|
||||
private Path sampleFile(Long project, Long character, String id) {return directory(project, character).resolve(uuid(id) + ".mp3");}
|
||||
private static String uuid(String id) {
|
||||
try {String canonical = UUID.fromString(id).toString(); if (!canonical.equalsIgnoreCase(id)) throw new ShortDramaVoiceException("样音任务ID必须是完整UUID"); return canonical;}
|
||||
catch (Exception e) {throw new ShortDramaVoiceException("样音任务ID必须是完整UUID");}
|
||||
}
|
||||
private ShortDramaCharacter owner(Long project, Long character, Long user) {
|
||||
sounds.owner(project, user);var actor = characters.selectById(character);
|
||||
if (actor == null || !project.equals(actor.getProjectId())) throw new ShortDramaVoiceException("角色不属于当前项目");
|
||||
return actor;
|
||||
}
|
||||
private List<ShortDramaCharacter> cast(Long project) {return characters.selectList(new LambdaQueryWrapper<ShortDramaCharacter>().eq(ShortDramaCharacter::getProjectId,project).orderByAsc(ShortDramaCharacter::getId));}
|
||||
private Profile read(Long project, ShortDramaCharacter actor) throws Exception {
|
||||
Path file = profileFile(project, actor.getId());
|
||||
if (Files.exists(file)) return AtlasMediaSupport.OBJECT_MAPPER.readValue(file.toFile(), Profile.class);
|
||||
String legacy = appearances.selectList(new LambdaQueryWrapper<ShortDramaCharacterAppearance>().eq(ShortDramaCharacterAppearance::getCharacterId,actor.getId()))
|
||||
.stream().map(ShortDramaCharacterAppearance::getVoice).filter(v -> v != null && !v.isBlank()).findFirst().orElse("");
|
||||
return new Profile(actor.getId().toString(), actor.getName(), legacy.isBlank() ? String.join(",", Objects.toString(actor.getGender(),""), Objects.toString(actor.getAgeRange(),"")) : legacy,
|
||||
"你先听我把话说完。", null, 0, List.of());
|
||||
}
|
||||
private void write(Path file, Object value) throws Exception {
|
||||
Files.createDirectories(file.getParent());Path temporary = Files.createTempFile(file.getParent(), "voice-", ".tmp");
|
||||
try {AtlasMediaSupport.OBJECT_MAPPER.writeValue(temporary.toFile(), value);
|
||||
try {Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);}
|
||||
catch (AtomicMoveNotSupportedException e) {Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING);}
|
||||
} finally {Files.deleteIfExists(temporary);}
|
||||
}
|
||||
public View get(Long project, Long character, Long user) throws Exception {synchronized(lock(project,character)){return getLocked(project,character,user);}}
|
||||
private View getLocked(Long project, Long character, Long user) throws Exception {
|
||||
var actor = owner(project,character,user);List<Job> jobs = new ArrayList<>();Path dir=directory(project,character);
|
||||
if (Files.isDirectory(dir)) try (var files=Files.list(dir)) {
|
||||
for (Path file : files.filter(p -> p.getFileName().toString().matches("[a-f0-9-]{36}\\.json")).toList()) {
|
||||
var job=AtlasMediaSupport.OBJECT_MAPPER.readValue(file.toFile(),Job.class);
|
||||
if ("submitting".equals(job.status()) && System.currentTimeMillis()-job.createdAt()>210000)
|
||||
job=new Job(job.id(),job.request(),job.predictionId(),"submission_unknown","提交结果未知,请核对上游记录;未自动重试",job.outputUrl(),job.createdAt());
|
||||
jobs.add(job);
|
||||
}
|
||||
}
|
||||
jobs.sort(Comparator.comparingLong(Job::createdAt).reversed());return new View(read(project,actor),List.copyOf(jobs));
|
||||
}
|
||||
static void validate(Settings settings) {
|
||||
if (settings==null || settings.description()==null || settings.description().isBlank() || settings.description().length()>1500
|
||||
|| settings.sampleText()==null || settings.sampleText().isBlank() || settings.sampleText().length()>80)
|
||||
throw new ShortDramaVoiceException("请填写声音描述(最多1500字)与简短样音正文(最多80字)");
|
||||
}
|
||||
public Profile settings(Long project,Long character,Long user,Settings settings) throws Exception {synchronized(lock(project,character)){return settingsLocked(project,character,user,settings);}}
|
||||
private Profile settingsLocked(Long project,Long character,Long user,Settings settings) throws Exception {
|
||||
validate(settings);var old=read(project,owner(project,character,user));var updated=new Profile(old.characterId(),old.characterName(),settings.description().trim(),settings.sampleText().trim(),old.selectedSampleId(),old.version(),old.samples());
|
||||
write(profileFile(project,character),updated);return updated;
|
||||
}
|
||||
private ChatModelVo model(String name) {
|
||||
var model=models.selectModelByName(name);
|
||||
if (model==null || !MODEL.equals(name) || !"audio".equals(model.getCategory()) || !"atlas".equalsIgnoreCase(model.getProviderCode()))
|
||||
throw new ShortDramaVoiceException("请在模型配置中启用 Atlas Seed Audio 1.0");
|
||||
return model;
|
||||
}
|
||||
static String prompt(Generate request) {
|
||||
return "生成单人中文角色样音。只朗读正文,不能读说明、角色名或标签,不增加对白,不加音乐和其他说话人。声音设定:"
|
||||
+ request.description() + (request.referenceSampleId()!=null && !request.referenceSampleId().isBlank() ? "。保持 @audio1 的同一人音色,情绪按声音设定表演。" : "。")
|
||||
+ "\n正文:" + request.sampleText();
|
||||
}
|
||||
public Job generate(Long project,Long character,Long user,Generate request) throws Exception {synchronized(lock(project,character)){return generateLocked(project,character,user,request);}}
|
||||
private Job generateLocked(Long project,Long character,Long user,Generate request) throws Exception {
|
||||
var actor=owner(project,character,user);if(request==null)throw new ShortDramaVoiceException("缺少请求");uuid(request.requestId());validate(new Settings(request.description(),request.sampleText()));var model=model(request.model());
|
||||
Path file=jobFile(project,character,request.requestId());
|
||||
if (Files.exists(file)) {var existing=AtlasMediaSupport.OBJECT_MAPPER.readValue(file.toFile(),Job.class);if(!existing.request().equals(request))throw new ShortDramaVoiceException("任务ID已用于不同样音,请创建新任务");return existing;}
|
||||
List<Map<String,String>> references=List.of();
|
||||
if(request.referenceSampleId()!=null && !request.referenceSampleId().isBlank()) {
|
||||
sample(read(project,actor),request.referenceSampleId());
|
||||
references=List.of(Map.of("audioData",Base64.getEncoder().encodeToString(Files.readAllBytes(sampleFile(project,character,request.referenceSampleId())))));
|
||||
}
|
||||
var payload=AtlasSpeechPayloadBuilder.build(AudioContext.builder().chatModelVo(model).input(prompt(request)).references(references).responseFormat("mp3").sampleRate(24000).build());
|
||||
var initial=new Job(request.requestId(),request,null,"submitting","",null,System.currentTimeMillis());
|
||||
// A durable claim precedes the only paid POST; reuse of a UUID never resubmits.
|
||||
Files.createDirectories(file.getParent());Files.writeString(file,AtlasMediaSupport.OBJECT_MAPPER.writeValueAsString(initial),StandardOpenOption.CREATE_NEW);
|
||||
var post=new Request.Builder().url(AtlasMediaSupport.endpoint(model.getApiHost(),"/model/generateAudio"))
|
||||
.header("Authorization","Bearer "+model.getApiKey()).post(RequestBody.create(payload.toString(),AtlasMediaSupport.JSON)).build();
|
||||
Job result;
|
||||
try(Response response=http.newCall(post).execute()) {
|
||||
String raw=response.body()==null?"":response.body().string();
|
||||
if(!response.isSuccessful()) result=new Job(initial.id(),request,null,response.code()>=500?"submission_unknown":"failed","样音提交返回HTTP "+response.code()+",请检查上游任务、模型权限与余额",null,initial.createdAt());
|
||||
else {
|
||||
var root=AtlasMediaSupport.OBJECT_MAPPER.readTree(raw);if(!root.has("data") && root.has("id"))raw=AtlasMediaSupport.OBJECT_MAPPER.createObjectNode().set("data",root).toString();
|
||||
var prediction=predictions.toResponse(raw,"audio");
|
||||
if(prediction.getId()==null || prediction.getId().isBlank())throw new IllegalStateException("未返回任务编号");
|
||||
result=new Job(initial.id(),request,prediction.getId(),"processing","",prediction.getUrl(),initial.createdAt());
|
||||
}
|
||||
} catch(Exception e) {result=new Job(initial.id(),request,null,"submission_unknown","提交结果未知,请核对 Atlas 记录;未自动重试",null,initial.createdAt());}
|
||||
write(file,result);return result;
|
||||
}
|
||||
public Job poll(Long project,Long character,Long user,String id) throws Exception {synchronized(lock(project,character)){return pollLocked(project,character,user,id);}}
|
||||
private Job pollLocked(Long project,Long character,Long user,String id) throws Exception {
|
||||
var actor=owner(project,character,user);Path file=jobFile(project,character,id);var old=AtlasMediaSupport.OBJECT_MAPPER.readValue(file.toFile(),Job.class);
|
||||
if(!Set.of("processing","saving").contains(old.status()))return old;
|
||||
String url=old.outputUrl();
|
||||
if(!"saving".equals(old.status())) {
|
||||
var result=predictions.retrieve(model(old.request().model()),old.predictionId());String status=Objects.toString(result.getStatus(),"");
|
||||
if(Set.of("failed","cancelled","canceled").contains(status)) {
|
||||
var root=AtlasMediaSupport.OBJECT_MAPPER.readTree(result.getRawResponse());var detail=root.path("data").path("error");
|
||||
String message=detail.isTextual()?detail.asText():detail.path("message").asText("上游样音生成失败,请检查 Atlas 任务记录");
|
||||
if(message.contains("concurrency"))message="上游配音并发额度已满,请等其他样音完成后创建新任务;本任务未自动重交";
|
||||
var failed=new Job(id,old.request(),old.predictionId(),"failed",message.substring(0,Math.min(500,message.length())),null,old.createdAt());write(file,failed);return failed;
|
||||
}
|
||||
if(!Set.of("completed","succeeded").contains(status))return old;
|
||||
url=result.getUrl();if(url==null || url.isBlank()){var failed=new Job(id,old.request(),old.predictionId(),"failed","任务完成但未返回音频",null,old.createdAt());write(file,failed);return failed;}
|
||||
}
|
||||
var saving=new Job(id,old.request(),old.predictionId(),"saving","样音已生成,正在保存",url,old.createdAt());write(file,saving);
|
||||
try {
|
||||
var profile=read(project,actor);
|
||||
if(profile.samples().stream().noneMatch(s -> s.id().equals(id))) {
|
||||
Path input=Files.createTempFile(directory(project,character),"audio-",".bin");
|
||||
try {download(url,input);double duration=normalize(input,sampleFile(project,character,id));
|
||||
add(project,character,profile,new Sample(id,actor.getName()+" · 样音 "+(profile.samples().size()+1),MODEL,old.predictionId(),"generated",duration,System.currentTimeMillis()));
|
||||
} finally {Files.deleteIfExists(input);}
|
||||
}
|
||||
var completed=new Job(id,old.request(),old.predictionId(),"completed","",url,old.createdAt());write(file,completed);return completed;
|
||||
} catch(IllegalArgumentException e) {var failed=new Job(id,old.request(),old.predictionId(),"failed",e.getMessage(),url,old.createdAt());write(file,failed);return failed;}
|
||||
catch(Exception e) {
|
||||
String detail=Objects.toString(e.getMessage(),e.getClass().getSimpleName());
|
||||
var retry=new Job(id,old.request(),old.predictionId(),"saving","样音已生成,保存暂未完成;刷新可重试保存:"+detail.substring(0,Math.min(240,detail.length())),url,old.createdAt());
|
||||
write(file,retry);return retry;
|
||||
}
|
||||
}
|
||||
private void download(String url,Path input) throws Exception {
|
||||
var uri=java.net.URI.create(url);if(!"https".equals(uri.getScheme()) || uri.getHost()==null || uri.getUserInfo()!=null)throw new ShortDramaVoiceException("上游样音地址无效");
|
||||
try(var response=http.newCall(new Request.Builder().url(url).build()).execute()) {
|
||||
if(!response.isSuccessful() || response.body()==null)throw new IllegalStateException("上游样音下载HTTP "+response.code());
|
||||
try(var stream=response.body().byteStream();var output=Files.newOutputStream(input)){byte[] buffer=new byte[8192];int n;long size=0;while((n=stream.read(buffer))!=-1){size+=n;if(size>10*1024*1024)throw new ShortDramaVoiceException("样音超过10MB");output.write(buffer,0,n);}}
|
||||
}
|
||||
}
|
||||
private double normalize(Path input,Path target) throws Exception {
|
||||
Path probe=target.resolveSibling(target.getFileName()+".probe");
|
||||
ShortDramaSoundService.run(List.of("ffprobe","-v","error","-show_entries","format=duration","-of","default=nw=1:nk=1",input.toString()),probe);
|
||||
double duration=Double.parseDouble(Files.readString(probe).trim());
|
||||
if(!Double.isFinite(duration) || duration<2 || duration>30.1)throw new ShortDramaVoiceException("样音需要2至30秒,请用更短正文或上传短片段;原输出已保留");
|
||||
ShortDramaSoundService.run(List.of("ffmpeg","-v","error","-y","-i",input.toString(),"-vn","-ar","24000","-ac","1","-c:a","libmp3lame",target.toString()),target.resolveSibling(target.getFileName()+".log"));
|
||||
ShortDramaSoundService.run(List.of("ffprobe","-v","error","-show_entries","format=duration","-of","default=nw=1:nk=1",target.toString()),probe);
|
||||
return Double.parseDouble(Files.readString(probe).trim());
|
||||
}
|
||||
private void add(Long project,Long character,Profile old,Sample sample) throws Exception {
|
||||
List<Sample> samples=new ArrayList<>(old.samples());samples.add(sample);
|
||||
write(profileFile(project,character),new Profile(old.characterId(),old.characterName(),old.description(),old.sampleText(),old.selectedSampleId(),old.version(),List.copyOf(samples)));
|
||||
}
|
||||
public Sample upload(Long project,Long character,Long user,MultipartFile file) throws Exception {synchronized(lock(project,character)){return uploadLocked(project,character,user,file);}}
|
||||
private Sample uploadLocked(Long project,Long character,Long user,MultipartFile file) throws Exception {
|
||||
var old=read(project,owner(project,character,user));if(file.isEmpty() || file.getSize()>10*1024*1024)throw new ShortDramaVoiceException("请上传小于10MB的样音");
|
||||
Files.createDirectories(directory(project,character));String id=UUID.randomUUID().toString();Path input=Files.createTempFile(directory(project,character),"upload-",".bin");
|
||||
try {file.transferTo(input);var sample=new Sample(id,Objects.toString(file.getOriginalFilename(),"上传样音"),null,null,"uploaded",normalize(input,sampleFile(project,character,id)),System.currentTimeMillis());add(project,character,old,sample);return sample;}
|
||||
finally {Files.deleteIfExists(input);}
|
||||
}
|
||||
private Sample sample(Profile profile,String id) {return profile.samples().stream().filter(s -> s.id().equals(id)).findFirst().orElseThrow(() -> new ShortDramaVoiceException("样音不属于当前角色或尚未保存"));}
|
||||
public Profile select(Long project,Long character,Long user,Selection selection) throws Exception {synchronized(lock(project,character)){return selectLocked(project,character,user,selection);}}
|
||||
private Profile selectLocked(Long project,Long character,Long user,Selection selection) throws Exception {
|
||||
var old=read(project,owner(project,character,user));String id=selection.sampleId();if(id!=null && !id.isBlank()){sample(old,id);if(!Files.isRegularFile(sampleFile(project,character,id)))throw new ShortDramaVoiceException("样音文件丢失,请重新保存");}else id=null;
|
||||
var selected=new Profile(old.characterId(),old.characterName(),old.description(),old.sampleText(),id,old.version()+(Objects.equals(id,old.selectedSampleId())?0:1),old.samples());write(profileFile(project,character),selected);return selected;
|
||||
}
|
||||
public Path audio(Long project,Long character,Long user,String id) throws Exception {sample(read(project,owner(project,character,user)),id);return sampleFile(project,character,id);}
|
||||
public record Speech(String text,List<Map<String,String>> references) {}
|
||||
public Speech speech(ShortDramaAudio audio) {
|
||||
if(!"dialogue".equals(audio.getAudioType()) || audio.getLinkedStoryboardId()==null)return new Speech(audio.getText(),List.of());
|
||||
var shot=storyboards.selectById(audio.getLinkedStoryboardId());if(shot==null || !audio.getProjectId().equals(shot.getProjectId()))throw new ShortDramaVoiceException("关联对白分镜不属于当前项目");
|
||||
var plan=plan(shot,null,true);List<Map<String,String>> refs=new ArrayList<>();StringBuilder mapping=new StringBuilder();
|
||||
try {
|
||||
for(var binding:plan.bindings()) {
|
||||
refs.add(Map.of("audioData",Base64.getEncoder().encodeToString(Files.readAllBytes(sampleFile(audio.getProjectId(),Long.valueOf(binding.characterId()),binding.sampleId())))));
|
||||
mapping.append(binding.characterName()).append("使用 @audio").append(refs.size()).append(" 的音色;");
|
||||
}
|
||||
}catch(Exception e){throw new IllegalStateException("对白角色样音无法读取",e);}
|
||||
if(refs.size()>3)throw new ShortDramaVoiceException("Seed Audio最多3个说话人,请按发言段落拆分配音");
|
||||
String text=refs.isEmpty()?audio.getText():"角色配音任务。只说原对白,不朗读说明和角色标签,不增加台词或背景音乐。"+mapping+"\n原对白:\n"+audio.getText();
|
||||
return new Speech(text,List.copyOf(refs));
|
||||
}
|
||||
private ShortDramaStoryboard shot(Long project,Long storyboard,Long user) {sounds.owner(project,user);var shot=storyboards.selectById(storyboard);if(shot==null || !project.equals(shot.getProjectId()))throw new ShortDramaVoiceException("分镜不属于当前项目");return shot;}
|
||||
public synchronized ShortDramaStoryboard speakers(Long project,Long storyboard,Long user,SpeakersRequest request) throws Exception {
|
||||
var shot=shot(project,storyboard,user);String previous=shot.getContinuityJson();ObjectNode continuity=(ObjectNode)AtlasMediaSupport.OBJECT_MAPPER.readTree(Objects.toString(previous,"{}"));
|
||||
if(continuity==null)continuity=AtlasMediaSupport.OBJECT_MAPPER.createObjectNode();
|
||||
if(request.characterIds()==null)continuity.remove("voice_speakers");else continuity.set("voice_speakers",AtlasMediaSupport.OBJECT_MAPPER.valueToTree(request.characterIds().stream().distinct().toList()));
|
||||
shot.setContinuityJson(continuity.toString());ShortDramaVoiceContract.resolve(shot,cast(project));
|
||||
// Only this field changes, and a concurrent edit cannot be overwritten.
|
||||
var condition=new LambdaUpdateWrapper<ShortDramaStoryboard>().eq(ShortDramaStoryboard::getId,storyboard)
|
||||
.eq(previous!=null,ShortDramaStoryboard::getContinuityJson,previous).isNull(previous==null,ShortDramaStoryboard::getContinuityJson).set(ShortDramaStoryboard::getContinuityJson,shot.getContinuityJson());
|
||||
if(storyboards.update(null,condition)!=1)throw new IllegalStateException("分镜已被更新,请刷新后重试");return shot;
|
||||
}
|
||||
public Plan preview(Long project,Long storyboard,Long user,String model) throws Exception {return plan(shot(project,storyboard,user),model,false);}
|
||||
public Plan plan(ShortDramaStoryboard shot,String model,boolean required) {
|
||||
try {
|
||||
var actors=cast(shot.getProjectId());var resolved=ShortDramaVoiceContract.resolve(shot,actors);
|
||||
boolean configured=actors.stream().anyMatch(c -> Files.exists(profileFile(shot.getProjectId(),c.getId())));
|
||||
List<String> issues=new ArrayList<>();if(configured)issues.addAll(resolved.issues());
|
||||
List<Binding> bindings=new ArrayList<>();List<ShortDramaSoundService.Reference> refs=new ArrayList<>();
|
||||
for(var actor:resolved.actors()) {
|
||||
var profile=read(shot.getProjectId(),actor);
|
||||
if(profile.selectedSampleId()==null) {if(configured)issues.add(actor.getName()+"尚未选用样音,请先在角色卡绑定");continue;}
|
||||
var sample=sample(profile,profile.selectedSampleId());Path file=sampleFile(shot.getProjectId(),actor.getId(),sample.id());
|
||||
if(!Files.isRegularFile(file)){issues.add(actor.getName()+"已选样音文件丢失");continue;}
|
||||
refs.add(ShortDramaSoundService.Reference.of("voice:"+actor.getId()+":"+sample.id(),actor.getName(),file,sample.duration()));
|
||||
bindings.add(new Binding(actor.getId().toString(),actor.getName(),sample.id(),profile.version(),sample.duration(),refs.size()));
|
||||
}
|
||||
refs.addAll(sounds.referenceFiles(shot.getProjectId(),shot.getSceneNo()));
|
||||
if(model!=null && !model.isBlank())try {ShortDramaVoiceContract.validateReferences(model,refs.stream().map(ShortDramaSoundService.Reference::duration).toList());}catch(IllegalArgumentException e){issues.add(e.getMessage());}
|
||||
if(required && !issues.isEmpty())throw new ShortDramaVoiceException("镜"+shot.getSceneNo()+"声音绑定未就绪:"+String.join(";",issues));
|
||||
String direction=bindings.isEmpty()?"":"\n【本镜角色声音绑定】\n"+bindings.stream().map(b -> b.characterName()+"(角色ID "+b.characterId()+")使用 @音频"+b.audioIndex()+" 的音色").reduce((a,b)->a+"\n"+b).orElse("")
|
||||
+"\n这些样音只用于同一角色音色参考。按原对白与声源顺序表演,画外对白仍由对应角色发声;不要朗读样音正文或绑定说明,不给静默听者增加台词。";
|
||||
return new Plan(List.copyOf(bindings),List.copyOf(refs),direction,List.copyOf(issues),resolved.explicit(),resolved.actors().stream().map(c -> c.getId().toString()).toList());
|
||||
} catch(RuntimeException e){throw e;}catch(Exception e){throw new IllegalStateException("角色声音档案无法读取,请修复后再生成视频",e);}
|
||||
}
|
||||
}
|
||||
@@ -8,13 +8,44 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
/** Versioned, bundled production skills. Loaded by default, independent of tool calling. */
|
||||
public final class ShortDramaDirectorSkills {
|
||||
private ShortDramaDirectorSkills() {}
|
||||
public static final String VERSION = "director-emotion-v2";
|
||||
public static final String VERSION = "director-video-prompt-v11";
|
||||
public static final String PACING_VERSION = "content-pacing-advisory-v2";
|
||||
public static final String PLANNING_VERSION = "semantic-segment-advisory-v12";
|
||||
private static final Map<String,String> CACHE = new ConcurrentHashMap<>();
|
||||
public static String load(String... names) {
|
||||
StringBuilder result = new StringBuilder("【默认导演业务规范:在保持输出格式和用户意图的前提下优先遵循】\n");
|
||||
for (String name : names) result.append(CACHE.computeIfAbsent(name, ShortDramaDirectorSkills::read)).append("\n");
|
||||
return result.toString();
|
||||
}
|
||||
/** Only remove an exact duplicate in the selected-skill section. Edited bindings and references stay intact. */
|
||||
static String compactSelected(String direction, String... loadedNames) {
|
||||
for (String name : loadedNames) {
|
||||
int marker = direction.indexOf("[selected-skill:" + name + "@");
|
||||
if (marker < 0) continue;
|
||||
String rule = CACHE.computeIfAbsent(name, ShortDramaDirectorSkills::read);
|
||||
String body = rule.substring(rule.indexOf('\n') + 1).strip();
|
||||
int start = direction.indexOf(body, marker);
|
||||
int next = direction.indexOf("[selected-skill:", marker + 1);
|
||||
if (start >= 0 && (next < 0 || start < next))
|
||||
direction = direction.substring(0, start) + "[正文与本次默认技能完全一致,复用已加载正文;选中版本与附属参考仍生效]" + direction.substring(start + body.length());
|
||||
}
|
||||
return direction;
|
||||
}
|
||||
/**
|
||||
* Media models receive the concrete asset/shot instruction plus versioned
|
||||
* bindings. Full skill handbooks belong to planning and review prompts; when
|
||||
* repeated inside every image request they obscure the actual subject.
|
||||
*/
|
||||
public static String media(String... names) {
|
||||
StringBuilder result = new StringBuilder("【媒体执行规则版本】\n");
|
||||
for (String name : names) {
|
||||
String loaded = CACHE.computeIfAbsent(name, ShortDramaDirectorSkills::read);
|
||||
int lineEnd = loaded.indexOf('\n');
|
||||
result.append(lineEnd < 0 ? loaded : loaded.substring(0, lineEnd)).append('\n');
|
||||
}
|
||||
return result.append("技能正文已在规划和审阅阶段应用;本次以当前资产或镜头的具体描述为可见内容。"
|
||||
+ "人物身份与数量、时点、已批准参考及用户明确修订优先,不从技能手册增添人物、道具、年代或剧情。\n").toString();
|
||||
}
|
||||
static String read(String name) {
|
||||
if (!name.matches("[a-z-]+")) throw new IllegalArgumentException("Invalid skill name");
|
||||
// Common-pool threads may not inherit Spring Boot's nested-jar context loader.
|
||||
@@ -23,7 +54,9 @@ public final class ShortDramaDirectorSkills {
|
||||
String text = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
if (text.startsWith("---")) text = text.substring(text.indexOf("---",3)+3).strip();
|
||||
if (text.isBlank()) throw new IllegalStateException("Empty director skill: "+name);
|
||||
return "[skill:"+name+"@"+VERSION+"]\n"+text;
|
||||
String version = "video-prompt".equals(name) ? VERSION + "-" + PACING_VERSION
|
||||
: "cinematic-storyboard".equals(name) ? VERSION + "-" + PLANNING_VERSION : VERSION;
|
||||
return "[skill:"+name+"@"+version+"]\n"+text;
|
||||
} catch (Exception e) { throw new IllegalStateException("Cannot load director skill: "+name,e); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import org.ruoyi.service.media.AtlasMediaSupport;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.*;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Local production evidence, never a provider credential dump or a paid-submission retry mechanism. */
|
||||
final class ShortDramaImagePromptEvidence {
|
||||
record Evidence(String attemptId, String assetType, Long assetId, Long projectId, Long userId,
|
||||
String model, String referencePurpose, String revisionRequirements,
|
||||
String finalPrompt, String promptSha256, String referenceImageSha256,
|
||||
long createdAt, String predictionId, String providerStatus) {
|
||||
boolean revision() { return "identity_revision".equals(referencePurpose) || "location_revision".equals(referencePurpose); }
|
||||
Evidence submitted(String prediction, String status) {
|
||||
return new Evidence(attemptId, assetType, assetId, projectId, userId, model, referencePurpose,
|
||||
revisionRequirements, finalPrompt, promptSha256, referenceImageSha256, createdAt, prediction, status);
|
||||
}
|
||||
}
|
||||
|
||||
private static final Pattern URL = Pattern.compile("(?i)(?:https?://|data:image/)[^\\s<>\"']+");
|
||||
private static final Pattern SECRET = Pattern.compile(
|
||||
"(?i)((?:authorization|api[_-]?key|access[_-]?token|client[_-]?secret|password)\\s*[:=]\\s*)(?:Bearer\\s+)?[^\\s,;\"']+");
|
||||
private static final Pattern BEARER = Pattern.compile("(?i)\\bBearer\\s+[A-Za-z0-9._~+/-]+=*");
|
||||
private final Path root;
|
||||
|
||||
ShortDramaImagePromptEvidence() { this(Path.of("data", "short-drama-image", "prompt-evidence")); }
|
||||
ShortDramaImagePromptEvidence(Path root) { this.root = root.toAbsolutePath().normalize(); }
|
||||
|
||||
Evidence prepare(String assetType, Long assetId, Long projectId, Long userId, String model,
|
||||
String purpose, String requirements, String prompt, String referenceImage) {
|
||||
Evidence evidence = new Evidence(UUID.randomUUID().toString(), assetType, assetId, projectId, userId,
|
||||
model, purpose, redact(requirements), redact(prompt), sha256(prompt),
|
||||
referenceImage == null ? null : sha256(referenceImage), System.currentTimeMillis(), null, "prepared");
|
||||
write(attemptPath(evidence.attemptId()), evidence, false);
|
||||
return evidence;
|
||||
}
|
||||
|
||||
void submitted(Evidence evidence, String prediction, String status) {
|
||||
Evidence submitted = evidence.submitted(prediction, status);
|
||||
// Persist the prediction lookup first so a completed paid task retains its exact edit intent.
|
||||
if (prediction != null && !prediction.isBlank()) {
|
||||
write(predictionPath(evidence.model(), prediction), submitted, true);
|
||||
}
|
||||
write(attemptPath(evidence.attemptId()), submitted, true);
|
||||
}
|
||||
|
||||
Evidence forPrediction(String assetType, Long assetId, Long userId, String model, String prediction) {
|
||||
Path file = predictionPath(model, prediction);
|
||||
if (!Files.exists(file)) return null; // Compatibility with tasks submitted before evidence support.
|
||||
try {
|
||||
Evidence evidence = AtlasMediaSupport.OBJECT_MAPPER.readValue(file.toFile(), Evidence.class);
|
||||
if (!assetType.equals(evidence.assetType()) || !assetId.equals(evidence.assetId()) || !userId.equals(evidence.userId())) {
|
||||
throw new IllegalArgumentException("图片任务与当前资产不匹配,未修改已批准图片");
|
||||
}
|
||||
return evidence;
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("图片提示词证据无法读取,请核对原任务;未替换已批准图片", ex);
|
||||
}
|
||||
}
|
||||
|
||||
static String redact(String text) {
|
||||
if (text == null) return null;
|
||||
String result = URL.matcher(text).replaceAll("[URL redacted]");
|
||||
result = SECRET.matcher(result).replaceAll("$1[redacted]");
|
||||
return BEARER.matcher(result).replaceAll("Bearer [redacted]");
|
||||
}
|
||||
|
||||
static String sha256(String text) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
|
||||
.digest(text.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (java.security.NoSuchAlgorithmException ex) { throw new IllegalStateException(ex); }
|
||||
}
|
||||
|
||||
private Path attemptPath(String attempt) { return root.resolve("attempts").resolve(attempt + ".json"); }
|
||||
private Path predictionPath(String model, String prediction) {
|
||||
return root.resolve("predictions").resolve(sha256(model + "\n" + prediction) + ".json");
|
||||
}
|
||||
|
||||
private void write(Path file, Evidence evidence, boolean replace) {
|
||||
Path temporary = null;
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
if (!replace) {
|
||||
Files.writeString(file, AtlasMediaSupport.OBJECT_MAPPER.writeValueAsString(evidence), StandardOpenOption.CREATE_NEW);
|
||||
return;
|
||||
}
|
||||
temporary = Files.createTempFile(file.getParent(), "image-prompt-", ".tmp");
|
||||
AtlasMediaSupport.OBJECT_MAPPER.writeValue(temporary.toFile(), evidence);
|
||||
try { Files.move(temporary, file, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); }
|
||||
catch (AtomicMoveNotSupportedException ex) { Files.move(temporary, file, StandardCopyOption.REPLACE_EXISTING); }
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException(replace
|
||||
? "图片任务可能已受理,证据保存失败,请核对任务ID,勿重新付费提交"
|
||||
: "图片提示词证据无法保存,未提交生成任务", ex);
|
||||
} finally { if (temporary != null) try { Files.deleteIfExists(temporary); } catch (IOException ignored) { } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Identity/geometry and visual style have distinct roles in the provider's ordered images. */
|
||||
final class ShortDramaImageStyleReferences {
|
||||
static List<String> ordered(String primary, List<String> styles) {
|
||||
if (styles == null || styles.isEmpty()) return primary == null ? List.of() : List.of(primary);
|
||||
if (styles.size() > 3) throw new IllegalArgumentException("最多绑定3张风格参考图");
|
||||
if (primary == null || primary.isBlank()) throw new IllegalArgumentException("风格参考须同时提供身份或空间参考图");
|
||||
var result = new ArrayList<String>(); result.add(primary);
|
||||
for (String style : styles) {
|
||||
if (style == null || style.isBlank()) throw new IllegalArgumentException("风格参考图不能为空");
|
||||
if (!result.contains(style)) result.add(style);
|
||||
}
|
||||
if (result.size() == 1) throw new IllegalArgumentException("风格参考须与身份或空间参考图区分");
|
||||
return List.copyOf(result);
|
||||
}
|
||||
|
||||
static String direction(boolean location, int count) {
|
||||
return "\n【有序参考图用途】第1张仅用于" + (location ? "本场景的门墙、出入口和方位" : "本角色的身份、年龄、服装与配色")
|
||||
+ ";第2至" + count + "张仅用于影视画面中的光色、明暗层次、表面质感与摄影呈现。"
|
||||
+ "将后者的视觉处理用到第1张的内容,不复制后者的人脸、衣装、建筑、道具或构图,输出一个连续单幅。";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
/** Per-request location corrections constrain an image candidate, never the saved world or geometry. */
|
||||
final class ShortDramaLocationArtPrompt {
|
||||
private ShortDramaLocationArtPrompt() {}
|
||||
|
||||
static boolean isRevision(String referencePurpose, String requirements) {
|
||||
String purpose = referencePurpose == null || referencePurpose.isBlank() ? "location" : referencePurpose.trim();
|
||||
// identity was the shared DTO's historical default for all image requests.
|
||||
if (!"location".equals(purpose) && !"identity".equals(purpose) && !"location_revision".equals(purpose))
|
||||
throw new IllegalArgumentException("地点图参考用途须为location或location_revision,不能使用角色身份修订用途");
|
||||
if (requirements != null && requirements.length() > 4000) throw new IllegalArgumentException("修订要求不能超过4000字符");
|
||||
boolean revision = requirements != null && !requirements.isBlank();
|
||||
if ("location_revision".equals(purpose) && !revision) throw new IllegalArgumentException("地点修订候选必须填写明确的修订要求");
|
||||
return revision;
|
||||
}
|
||||
|
||||
static String reference(String name, String description, String artStyle, String worldbuilding, String requirements) {
|
||||
String base = ShortDramaAssetWorldPrompt.location(name, description, artStyle, worldbuilding);
|
||||
if (!isRevision(null, requirements)) return base;
|
||||
return base + "\n【用途:当前地点的局部修订候选,尚未批准连续性】\n"
|
||||
+ "本轮只作用于图片,不改写已冻结的场景名称、场景文案、世界观或镜头连续性。"
|
||||
+ "有参考图时只绑定其建筑几何、空间尺度、入口与窗的位置、道路走向、陈设布局及未指定修改的材质;"
|
||||
+ "无参考图时按上述已冻结地点文案重建同一空间。不得搬动建筑、改造道路、改变空间规模或增加人物。\n"
|
||||
+ "【局部要求优先级】本轮明确列出的时间、季候、光色或物件清理要求优先于参考图和冻结文案中的旧时间、旧光色及被指定删除的错误物件。"
|
||||
+ "参考图的夕阳、夜色、天气与错误物件不锁定本轮结果;不能为了保留参考而恢复已明确要求移除的物件。"
|
||||
+ "未列出的几何、结构、身份与可见内容继续遵守已冻结地点,不能从世界观补入未来装备或新的剧情成果。\n"
|
||||
+ "【本轮明确修订要求】\n" + requirements.trim()
|
||||
+ "\n修订结果仅追加为待审候选,须人工审阅并明确选择后才成为后续镜头的批准参考,不自动替换已有批准图片。";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import okhttp3.*;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.common.chat.service.chat.IChatModelService;
|
||||
import org.ruoyi.domain.entity.shortdrama.ShortDramaScript;
|
||||
import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard;
|
||||
import org.ruoyi.factory.ChatServiceFactory;
|
||||
import org.ruoyi.mapper.shortdrama.ShortDramaScriptMapper;
|
||||
import org.ruoyi.mapper.shortdrama.ShortDramaStoryboardMapper;
|
||||
import org.ruoyi.service.media.AtlasMediaSupport;
|
||||
import org.ruoyi.service.media.AtlasPredictionService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Project-owned, recoverable music tasks. TTS and music deliberately use separate contracts. */
|
||||
@Service @RequiredArgsConstructor
|
||||
public class ShortDramaMusicService {
|
||||
private final ShortDramaSoundService sounds;
|
||||
private final IChatModelService models;
|
||||
private final AtlasPredictionService predictions;
|
||||
private final ChatServiceFactory chats;
|
||||
private final ShortDramaScriptMapper scripts;
|
||||
private final ShortDramaStoryboardMapper storyboards;
|
||||
private final OkHttpClient http = new OkHttpClient.Builder().connectTimeout(30,TimeUnit.SECONDS).readTimeout(180,TimeUnit.SECONDS).build();
|
||||
public record MusicRequest(String requestId,String model,String purpose,String title,String prompt,String style,Integer duration,String vocalGender,String negativeTags) {}
|
||||
public record Variant(int index,String url,String soundId,double duration) {}
|
||||
public record Job(String id,String predictionId,MusicRequest request,String status,String error,List<Variant> variants,long createdAt) {}
|
||||
public record BriefRequest(String model,String purpose,String direction) {}
|
||||
public record Brief(String title,String style,String prompt) {}
|
||||
public record Usage(int variant,List<Integer> shots,double volume,double offset,boolean enabled) {}
|
||||
private Path directory(Long project) {return Path.of("data","short-drama-sounds",project.toString(),"music-jobs").toAbsolutePath();}
|
||||
private Path file(Long project,String id) {UUID.fromString(id);return directory(project).resolve(id+".json");}
|
||||
private void save(Long project,Job job) throws Exception {
|
||||
Files.createDirectories(directory(project));Path temporary=Files.createTempFile(directory(project),"job-",".tmp");
|
||||
try {AtlasMediaSupport.OBJECT_MAPPER.writeValue(temporary.toFile(),job);try {Files.move(temporary,file(project,job.id()),StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING);}catch(AtomicMoveNotSupportedException e){Files.move(temporary,file(project,job.id()),StandardCopyOption.REPLACE_EXISTING);}}
|
||||
finally {Files.deleteIfExists(temporary);}
|
||||
}
|
||||
private Job read(Long project,String id) throws Exception {return AtlasMediaSupport.OBJECT_MAPPER.readValue(file(project,id).toFile(),Job.class);}
|
||||
public List<Job> list(Long project,Long user) throws Exception {
|
||||
sounds.owner(project,user);if(!Files.isDirectory(directory(project)))return List.of();
|
||||
try(var files=Files.list(directory(project))){List<Job> jobs=new ArrayList<>();for(Path path:files.filter(p->p.toString().endsWith(".json")).toList()){var job=AtlasMediaSupport.OBJECT_MAPPER.readValue(path.toFile(),Job.class);if("submitting".equals(job.status()) && System.currentTimeMillis()-job.createdAt()>210000)job=new Job(job.id(),job.predictionId(),job.request(),"submission_unknown","提交尚未取得任务编号,请先核对 Atlas 记录;未自动重试",job.variants(),job.createdAt());jobs.add(job);}jobs.sort(Comparator.comparingLong(Job::createdAt).reversed());return jobs;}
|
||||
}
|
||||
private ChatModelVo musicModel(String name) {
|
||||
var model=models.selectModelByName(name);
|
||||
if(model==null || !"audio".equals(model.getCategory()) || !"atlas".equalsIgnoreCase(model.getProviderCode()) || !"suno/chirp-v6".equals(name))throw new IllegalArgumentException("请选择已配置的 Atlas Suno Chirp V6 音乐模型");
|
||||
return model;
|
||||
}
|
||||
static Map<String,Object> payload(MusicRequest r) {
|
||||
if(r==null || !Set.of("bgm","theme","ending").contains(Objects.toString(r.purpose(),"")))throw new IllegalArgumentException("请选择 BGM、主题曲或片尾曲");
|
||||
if(r.duration()==null || r.duration()<10 || r.duration()>360)throw new IllegalArgumentException("目标时长为10至360秒");
|
||||
if(r.title()==null || r.title().isBlank() || r.title().length()>50 || r.style()==null || r.style().isBlank() || r.style().length()>1000)throw new IllegalArgumentException("请填写标题(最多50字符)与音乐风格(最多1000字符)");
|
||||
if(r.prompt()!=null && r.prompt().length()>3000)throw new IllegalArgumentException("歌词或音乐描述最多3000字符");
|
||||
if(!"bgm".equals(r.purpose()) && (r.prompt()==null || r.prompt().isBlank()))throw new IllegalArgumentException("歌曲需要先填写歌词");
|
||||
if(r.negativeTags()!=null && r.negativeTags().length()>1000)throw new IllegalArgumentException("排除风格最多1000字符");
|
||||
var p=new LinkedHashMap<String,Object>();p.put("model",r.model());p.put("custom",true);p.put("instrumental","bgm".equals(r.purpose()));p.put("title",r.title().trim());p.put("prompt",Objects.toString(r.prompt(),""));p.put("style",r.style().trim());p.put("duration",r.duration());p.put("variety","normal");
|
||||
if(!"bgm".equals(r.purpose())) {if(!Set.of("Male","Female").contains(Objects.toString(r.vocalGender(),"")))throw new IllegalArgumentException("请选择男声或女声");p.put("vocal_gender",r.vocalGender());p.put("auto_lyrics",false);}
|
||||
if(r.negativeTags()!=null && !r.negativeTags().isBlank())p.put("negative_tags",r.negativeTags());return p;
|
||||
}
|
||||
public synchronized Job generate(Long project,Long user,MusicRequest request) throws Exception {
|
||||
sounds.owner(project,user);UUID.fromString(request.requestId());var payload=payload(request);var model=musicModel(request.model());
|
||||
// Reusing the same client request id never submits another paid prediction, even after a timeout.
|
||||
if(Files.exists(file(project,request.requestId()))) {Job old=read(project,request.requestId());if(!old.request().equals(request))throw new IllegalArgumentException("任务ID已用于其他音乐,请创建新任务");return old;}
|
||||
var initial=new Job(request.requestId(),null,request,"submitting","",List.of(),System.currentTimeMillis());save(project,initial);
|
||||
Request post=new Request.Builder().url(AtlasMediaSupport.endpoint(model.getApiHost(),"/model/generateAudio")).header("Authorization","Bearer "+model.getApiKey()).post(RequestBody.create(AtlasMediaSupport.OBJECT_MAPPER.writeValueAsString(payload),AtlasMediaSupport.JSON)).build();
|
||||
try(Response response=http.newCall(post).execute()) {
|
||||
String body=response.body()==null?"":response.body().string();
|
||||
if(!response.isSuccessful()){var failed=new Job(initial.id(),null,request,response.code()>=500?"submission_unknown":"failed","音乐提交返回 HTTP "+response.code()+",请检查模型权限和账户余额",List.of(),initial.createdAt());save(project,failed);return failed;}
|
||||
var data=AtlasMediaSupport.OBJECT_MAPPER.readTree(body).path("data");String prediction=data.path("id").asText("");
|
||||
if(prediction.isBlank())throw new IllegalStateException("服务未返回任务ID");
|
||||
var submitted=new Job(initial.id(),prediction,request,"processing","",List.of(),initial.createdAt());save(project,submitted);return submitted;
|
||||
}catch(Exception e){var unknown=new Job(initial.id(),null,request,"submission_unknown","提交结果未知;未自动重试,请先核对 Atlas 任务记录",List.of(),initial.createdAt());save(project,unknown);return unknown;}
|
||||
}
|
||||
static List<String> outputs(JsonNode data) {
|
||||
List<String> urls=new ArrayList<>();for(JsonNode item:data.path("outputs")){String url=item.isTextual()?item.asText():item.path("audio_url").asText(item.path("url").asText(""));if(url.startsWith("https://") && !urls.contains(url))urls.add(url);}return urls;
|
||||
}
|
||||
public synchronized Job poll(Long project,Long user,String id) throws Exception {
|
||||
sounds.owner(project,user);var job=read(project,id);if(!Set.of("processing","saving").contains(job.status()))return job;
|
||||
var result=predictions.retrieve(musicModel(job.request().model()),job.predictionId());var data=AtlasMediaSupport.OBJECT_MAPPER.readTree(result.getRawResponse()).path("data");String status=Objects.toString(result.getStatus(),"");
|
||||
if(Set.of("failed","canceled","cancelled").contains(status)){String message=data.path("error").asText("音乐生成失败,请查看 Atlas 任务记录");if(message.contains("tags") && message.contains("1000"))message="音乐风格超过上游1000字符限制,请缩短后创建新任务";else message=message.substring(0,Math.min(500,message.length()));var failed=new Job(id,job.predictionId(),job.request(),"failed",message,job.variants(),job.createdAt());save(project,failed);return failed;}
|
||||
if(!Set.of("completed","succeeded").contains(status))return job;
|
||||
var urls=outputs(data);if(urls.isEmpty()){var failed=new Job(id,job.predictionId(),job.request(),"failed","服务已结束但未返回音轨",List.of(),job.createdAt());save(project,failed);return failed;}
|
||||
List<Variant> variants=new ArrayList<>();for(int i=0;i<urls.size();i++) {String soundId=UUID.nameUUIDFromBytes((id+":"+i).getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString();variants.add(new Variant(i,urls.get(i),soundId,0));}
|
||||
save(project,new Job(id,job.predictionId(),job.request(),"saving","",variants,job.createdAt()));
|
||||
try {for(int i=0;i<variants.size();i++){var v=variants.get(i);var sound=sounds.importMusic(project,user,v.soundId(),job.request().title()+" · 版本"+(i+1),v.url());variants.set(i,new Variant(i,v.url(),v.soundId(),sound.duration()));}
|
||||
var complete=new Job(id,job.predictionId(),job.request(),"completed","",variants,job.createdAt());save(project,complete);return complete;
|
||||
}catch(Exception e){var saving=new Job(id,job.predictionId(),job.request(),"saving","音轨已生成,保存暂未完成;刷新任务可重试保存",variants,job.createdAt());save(project,saving);return saving;}
|
||||
}
|
||||
public ShortDramaSoundService.Sound use(Long project,Long user,String id,Usage usage) throws Exception {
|
||||
sounds.owner(project,user);var job=read(project,id);if(!"completed".equals(job.status()) || usage.variant()<0 || usage.variant()>=job.variants().size())throw new IllegalArgumentException("请选择已完成的音乐版本");
|
||||
var numbers=storyboards.selectList(new LambdaQueryWrapper<ShortDramaStoryboard>().eq(ShortDramaStoryboard::getProjectId,project)).stream().map(ShortDramaStoryboard::getSceneNo).toList();
|
||||
if(usage.enabled() && (usage.shots()==null || usage.shots().isEmpty() || !numbers.containsAll(usage.shots())))throw new IllegalArgumentException("请选择本项目中实际存在的镜号");
|
||||
// Applying one variant removes the other variants of this job from the mix.
|
||||
var selected=sounds.musicUsage(project,user,job.variants().get(usage.variant()).soundId(),usage.shots(),usage.volume(),usage.offset(),usage.enabled());
|
||||
if(usage.enabled())for(var v:job.variants())if(v.index()!=usage.variant())sounds.musicUsage(project,user,v.soundId(),List.of(),0,0,false);
|
||||
return selected;
|
||||
}
|
||||
public Brief write(Long project,Long user,BriefRequest request) {
|
||||
sounds.owner(project,user);var model=models.selectModelByName(request.model());if(model==null || !"chat".equals(model.getCategory()))throw new IllegalArgumentException("请选择写作模型");
|
||||
if(!Set.of("bgm","theme","ending").contains(Objects.toString(request.purpose(),"")) || request.direction()!=null && request.direction().length()>2000)throw new IllegalArgumentException("音乐用途或创作要求无效");
|
||||
var script=scripts.selectOne(new LambdaQueryWrapper<ShortDramaScript>().eq(ShortDramaScript::getProjectId,project).orderByDesc(ShortDramaScript::getId).last("limit 1"));if(script==null)throw new IllegalArgumentException("请先保存剧本");
|
||||
String context=Objects.toString(script.getScriptText(),"");if(context.length()>16000)context=context.substring(0,16000);
|
||||
String prompt="你是短剧音乐创作。只输出JSON对象,字段title中文曲名、style英文编曲与演唱风格、prompt。用途="+request.purpose()+"。BGM必须纯器乐,prompt写音乐发展不写歌词;歌曲prompt只能是原创中文歌词和[Verse]、[Chorus]等段落标签,不得插入音乐发展说明或制作解说,最多3000字符。编曲与演唱要求全部写style,最多800字符。遵守当前剧本事实和情感弧线,不虚构已取得的胜利,不照抄小说或现有歌曲,不使用人名口号和产品广告。采用克制具体的意象,适合片尾回味。标题最多50字符。要求:"+Objects.toString(request.direction(),"")+"\n剧本:\n"+context;
|
||||
try {var writer="atlas".equalsIgnoreCase(model.getProviderCode()) ? dev.langchain4j.model.openai.OpenAiChatModel.builder().baseUrl(model.getApiHost()).apiKey(model.getApiKey()).modelName(model.getModelName()).timeout(java.time.Duration.ofMinutes(6)).maxRetries(0).maxTokens(8192).build() : chats.getOriginalService(ShortDramaServiceImpl.shortDramaProviderCode(model.getProviderCode(),model.getModelName())).buildChatModel(model);String raw=writer.chat(prompt);int start=raw.indexOf('{'),end=raw.lastIndexOf('}');if(start<0 || end<=start)throw new IllegalArgumentException("格式无效");return normalizeBrief(AtlasMediaSupport.OBJECT_MAPPER.readValue(raw.substring(start,end+1),Brief.class),request.purpose());
|
||||
}catch(Exception e){throw new IllegalStateException("音乐文案生成失败,请检查写作模型或手动填写",e);}
|
||||
}
|
||||
static Brief normalizeBrief(Brief brief,String purpose) {
|
||||
if(brief.title()==null || brief.title().isBlank() || brief.style()==null || brief.style().isBlank() || brief.prompt()==null)throw new IllegalArgumentException("音乐文案不完整");
|
||||
String lyrics=brief.prompt().trim();if(!"bgm".equals(purpose)){var tags=java.util.regex.Pattern.compile("(?i)\\[(?:intro|verse|chorus|bridge|outro|hook|pre-chorus)\\b[^\\]]*\\]").matcher(lyrics);if(tags.find())lyrics=lyrics.substring(tags.start());if(lyrics.isBlank())throw new IllegalArgumentException("歌词为空");}
|
||||
if(lyrics.length()>3000)throw new IllegalArgumentException("歌词或描述超过3000字符,请缩短文案");
|
||||
String style=brief.style().trim();if(style.length()>1000){style=style.substring(0,1000);int end=Math.max(style.lastIndexOf('\n'),style.lastIndexOf(','));if(end>700)style=style.substring(0,end);}
|
||||
return new Brief(brief.title().substring(0,Math.min(50,brief.title().length())).trim(),style,lyrics);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.ruoyi.service.media.AtlasMediaSupport;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Preview complete panels from legacy arrays or a scene_plan/panels envelope; never approves partial data. */
|
||||
final class ShortDramaPanelStreamParser {
|
||||
private final Consumer<JsonNode> sink;
|
||||
private final StringBuilder object = new StringBuilder();
|
||||
private int arrayDepth, objectDepth, captureParentDepth;
|
||||
private boolean string, escaped, capturing;
|
||||
ShortDramaPanelStreamParser(Consumer<JsonNode> sink) { this.sink = sink; }
|
||||
void accept(String chunk) {
|
||||
for (int i = 0; i < chunk.length(); i++) {
|
||||
char c = chunk.charAt(i);
|
||||
if (capturing) object.append(c);
|
||||
if (string) {
|
||||
if (escaped) escaped = false;
|
||||
else if (c == '\\') escaped = true;
|
||||
else if (c == '"') string = false;
|
||||
continue;
|
||||
}
|
||||
if (c == '"') { string = true; continue; }
|
||||
if (c == '[') arrayDepth++;
|
||||
else if (c == ']') arrayDepth--;
|
||||
else if (c == '{') {
|
||||
if (!capturing && arrayDepth == 1 && objectDepth <= 1) {
|
||||
capturing = true; captureParentDepth = objectDepth; object.setLength(0); object.append(c);
|
||||
}
|
||||
objectDepth++;
|
||||
} else if (c == '}') {
|
||||
objectDepth--;
|
||||
if (capturing && objectDepth == captureParentDepth) {
|
||||
capturing = false;
|
||||
try {
|
||||
JsonNode node = AtlasMediaSupport.OBJECT_MAPPER.readTree(object.toString());
|
||||
if (node.isObject() && (node.has("panel_number") || node.has("panelNumber"))) sink.accept(node);
|
||||
} catch (java.io.IOException ignored) { /* malformed objects await full batch validation */ }
|
||||
object.setLength(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import org.ruoyi.service.media.AtlasMediaSupport;
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
|
||||
/** A scene plan guides generation; it is not a post-generation shot-count limit. */
|
||||
final class ShortDramaPlanResponse {
|
||||
private ShortDramaPlanResponse() {}
|
||||
|
||||
static List<ShortDramaServiceImpl.StoryboardPanelData> parse(String json) throws IOException {
|
||||
var mapper = AtlasMediaSupport.OBJECT_MAPPER;
|
||||
var root = mapper.readTree(json);
|
||||
if (root == null) return null;
|
||||
var panels = root.isArray() ? root : root.path("panels");
|
||||
if (!panels.isArray()) return null;
|
||||
return mapper.readerForListOf(ShortDramaServiceImpl.StoryboardPanelData.class)
|
||||
.without(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(panels);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.ruoyi.common.redis.utils.RedisUtils;
|
||||
import org.ruoyi.service.media.AtlasMediaSupport;
|
||||
import java.time.Duration;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** Read-only progress, separate from the authoritative submission/terminal receipt. No reasoning text. */
|
||||
final class ShortDramaPlanningProgress {
|
||||
record Call(String id, String phase, String label, String state, long startedAt, long firstContentAt,
|
||||
long lastActivityAt, long endedAt, int promptChars, long contentChars, long thinkingChars) {}
|
||||
record Card(String key, int scene, int ordinal, String stage, JsonNode panel) {}
|
||||
record Snapshot(String requestId, String phase, String message, long startedAt, long updatedAt,
|
||||
List<Call> calls, List<Card> cards) {}
|
||||
static String key(Long project, Long script, String request) {
|
||||
return "short-drama:planning-progress:v1:" + project + ":" + script + ":" + request;
|
||||
}
|
||||
static Snapshot read(Long project, Long script, String request) {
|
||||
String json = RedisUtils.getCacheObject(key(project, script, request));
|
||||
if (json == null) return null;
|
||||
try { return AtlasMediaSupport.OBJECT_MAPPER.readValue(json, Snapshot.class); }
|
||||
catch (java.io.IOException e) { return null; } // Progress corruption cannot alter a receipt or trigger resubmission.
|
||||
}
|
||||
static Consumer<Snapshot> persistence(Long project, Long script, String request, Consumer<Snapshot> push) {
|
||||
// The Redis key is captured in the submitter's namespace; provider callbacks do not inherit tenant context.
|
||||
final String cacheKey = key(project, script, request);
|
||||
final String tenant = org.ruoyi.common.tenant.helper.TenantHelper.getTenantId();
|
||||
return snapshot -> {
|
||||
try { persistInTenant(tenant, () -> {
|
||||
RedisUtils.setCacheObject(cacheKey, org.ruoyi.common.json.utils.JsonUtils.toJsonString(snapshot), Duration.ofDays(7));
|
||||
}); }
|
||||
catch (Exception ignored) { /* Observability must not turn successful generation into a failure. */ }
|
||||
push.accept(snapshot);
|
||||
};
|
||||
}
|
||||
static void persistInTenant(String tenant, Runnable write) {
|
||||
String previous = org.ruoyi.common.tenant.helper.TenantHelper.getDynamic();
|
||||
try { org.ruoyi.common.tenant.helper.TenantHelper.dynamic(tenant, write); }
|
||||
finally {
|
||||
// dynamic() clears instead of restoring an outer worker scope. Progress must not
|
||||
// move the subsequent scene cache, database work or terminal receipt to another tenant.
|
||||
if (previous != null && !previous.isBlank())
|
||||
org.ruoyi.common.tenant.helper.TenantHelper.setDynamic(previous);
|
||||
}
|
||||
}
|
||||
static final class Tracker {
|
||||
private final String requestId;
|
||||
private final long startedAt;
|
||||
private final Consumer<Snapshot> sink;
|
||||
private final Map<String, Call> calls = new LinkedHashMap<>();
|
||||
private final Map<String, Card> cards = new LinkedHashMap<>();
|
||||
private final Map<Integer, int[]> globalNumbers = new HashMap<>();
|
||||
private String phase = "storyboard_plan", message = "正在规划镜头";
|
||||
private long publishedAt;
|
||||
private boolean terminal;
|
||||
Tracker(String requestId, long startedAt, Consumer<Snapshot> sink) {
|
||||
this.requestId = requestId; this.startedAt = startedAt; this.sink = sink;
|
||||
}
|
||||
synchronized void phase(String value, String text) {
|
||||
if (terminal) return;
|
||||
phase = value; message = text; publish(true);
|
||||
}
|
||||
synchronized String startCall(String callPhase, String label, int promptChars) {
|
||||
String id = UUID.randomUUID().toString(); long now = System.currentTimeMillis();
|
||||
if (terminal) return id;
|
||||
calls.put(id, new Call(id, callPhase, label, "running", now, 0, now, 0, promptChars, 0, 0));
|
||||
publish(true); return id;
|
||||
}
|
||||
synchronized void activity(String id, String text, boolean thinking) {
|
||||
Call call = calls.get(id); if (terminal || call == null || !"running".equals(call.state()) || text == null || text.isEmpty()) return;
|
||||
long now = System.currentTimeMillis();
|
||||
calls.put(id, new Call(id, call.phase(), call.label(), call.state(), call.startedAt(),
|
||||
!thinking && call.firstContentAt() == 0 ? now : call.firstContentAt(), now, 0, call.promptChars(),
|
||||
call.contentChars() + (thinking ? 0 : text.length()), call.thinkingChars() + (thinking ? text.length() : 0)));
|
||||
publish(false);
|
||||
}
|
||||
synchronized Call finishCall(String id, boolean success) {
|
||||
Call call = calls.get(id); if (call == null || terminal || !"running".equals(call.state())) return call;
|
||||
Call finished = new Call(id, call.phase(), call.label(), success ? "done" : "error", call.startedAt(), call.firstContentAt(),
|
||||
call.lastActivityAt(), System.currentTimeMillis(), call.promptChars(), call.contentChars(), call.thinkingChars());
|
||||
calls.put(id, finished); publish(true); return finished;
|
||||
}
|
||||
synchronized void card(int scene, int ordinal, String stage, JsonNode panel) {
|
||||
if (terminal || panel == null || !panel.isObject()) return;
|
||||
String key = scene + ":" + ordinal;
|
||||
Card old = cards.get(key);
|
||||
if (old != null && rank(old.stage()) > rank(stage)) return;
|
||||
var preview = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode();
|
||||
for (String field : List.of("scene_title", "sceneTitle", "segment_goal", "segmentGoal", "description", "location", "video_prompt", "videoPrompt")) {
|
||||
JsonNode value = panel.get(field);
|
||||
if (value != null && value.isTextual()) preview.put(field, value.asText().substring(0, Math.min(2400, value.asText().length())));
|
||||
}
|
||||
JsonNode design = panel.has("shot_design") ? panel.get("shot_design") : panel.get("shotDesign");
|
||||
if (panel.path("duration").canConvertToInt() && panel.path("duration").asInt() > 0)
|
||||
preview.put("duration", panel.path("duration").asInt());
|
||||
if (design != null && design.isObject()) {
|
||||
var view = preview.putObject("shot_design");
|
||||
for (String field : List.of("focus", "viewer_gain", "framing", "movement", "cut_in", "cut_out"))
|
||||
if (design.path(field).isTextual()) view.put(field, design.path(field).asText());
|
||||
}
|
||||
cards.put(key, new Card(key, scene, ordinal, stage, preview)); publish(true);
|
||||
}
|
||||
synchronized void clearDrafts(int scene) { cards.values().removeIf(card -> card.scene() == scene && "draft".equals(card.stage())); publish(true); }
|
||||
synchronized void assetCard(int category, int ordinal, String stage, JsonNode asset) {
|
||||
if (terminal || asset == null || !asset.isObject() || asset.path("name").asText().isBlank()) return;
|
||||
String key = "asset:" + category + ":" + ordinal;
|
||||
Card old = cards.get(key);
|
||||
if (old != null && rank(old.stage()) > rank(stage)) return;
|
||||
var preview = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode();
|
||||
for (String field : List.of("name", "description", "visualDescription", "summary", "introduction")) {
|
||||
String value = asset.path(field).asText("");
|
||||
if (!value.isBlank()) preview.put(field, value.substring(0, Math.min(2400, value.length())));
|
||||
}
|
||||
cards.put(key, new Card(key, category, ordinal, stage, preview)); publish(true);
|
||||
}
|
||||
synchronized void bindGlobal(int global, int scene, int ordinal) { globalNumbers.put(global, new int[]{scene, ordinal}); }
|
||||
synchronized void ready(int global, JsonNode panel) { int[] address = globalNumbers.get(global); if (address != null) card(address[0], address[1], "ready", panel); }
|
||||
synchronized void heartbeat() { if (!terminal) publish(true); }
|
||||
synchronized void terminal(String state) { if (terminal) return; phase = state; terminal = true; publish(true); }
|
||||
synchronized Snapshot snapshot() {
|
||||
return new Snapshot(requestId, phase, message, startedAt, System.currentTimeMillis(), List.copyOf(calls.values()),
|
||||
cards.values().stream().sorted(Comparator.comparingInt(Card::scene).thenComparingInt(Card::ordinal)).toList());
|
||||
}
|
||||
private void publish(boolean force) {
|
||||
long now = System.currentTimeMillis(); if (!force && now - publishedAt < 1000) return;
|
||||
publishedAt = now; sink.accept(snapshot());
|
||||
}
|
||||
private int rank(String stage) { return "ready".equals(stage) ? 2 : "planned".equals(stage) ? 1 : 0; }
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user