6 Commits

Author SHA1 Message Date
ageerle
8a12be349a docs: correct coding demo URL in READMEs 2026-10-22 01:04:05 +08:00
ageerle
f286f50bbe docs: update demo URLs and QR code login instructions 2026-10-21 06:43:05 +08:00
ageerle
5e66c7bd30 feat(short-drama): add director workspaces and refine video production
Add canvas and director APIs, production skill catalogs, prompt preservation, video references and Atlas H3 support. Include schema migrations, upload limits, regression coverage and the new demo URL.
2026-10-20 09:30:36 +08:00
ageerle
8ab3638012 fix(copilot): 对齐图片限制并修复 Doubao 模型与思考回放 2026-10-13 03:08:34 +08:00
ageerle
432fbb6ea4 fix(short-drama): enforce Atlas isolation and restore missing catalog routes 2026-10-10 10:22:33 +08:00
ageerle
ca6ef96200 feat: 完善短剧创作、技能目录与媒体任务恢复
保存原始创作输入,整合剧本打磨、分场规划与流式进度,接入审美及导演技能、角色声音和音乐;保持已批准媒体并完善请求去重与恢复。

统一增量 SQL 目录,补齐新安装字段并修正示例插入列,更新迁移说明;修复测试夹具与当前审阅、重启恢复行为的断言。验证:相关模块编译通过,短剧及 Atlas 回归 337 项、SSE 回归 3 项均通过。
2026-10-03 19:11:54 +08:00
261 changed files with 14616 additions and 1442 deletions

View File

@@ -25,11 +25,11 @@
## 🚀 Live Demo
| Service | URL | Default Account |
| Service | URL | Login Method |
|---|---|---|
| Admin Panel | http://129.226.199.247:25666 | admin / admin123 |
| User Frontend | http://129.226.199.247:25137 | admin / admin123 |
| Commercial Edition | https://web.ruoyiai.chat | WeChat QR code login |
| Short Drama Demo | [drama.ruoyiai.chat](https://drama.ruoyiai.chat/) | QR code login |
| User Frontend Demo | [web.ruoyiai.chat](https://web.ruoyiai.chat/) | QR code login |
| Coding Demo | [code.ruoyiai.chat](https://code.ruoyiai.chat/) | QR code login |
## ✨ Core Features

View File

@@ -25,11 +25,11 @@
## 🚀 演示地址
| 服务 | 访问地址 | 默认账号 |
| 服务 | 访问地址 | 登录方式 |
|---|---|---|
| 管理端 | http://129.226.199.247:25666 | admin / admin123 |
| 用户端 | http://129.226.199.247:25137 | admin / admin123 |
| 商业版 | https://web.ruoyiai.chat | 微信扫码登录 |
| 短剧演示环境 | [drama.ruoyiai.chat](https://drama.ruoyiai.chat/) | 扫码登录 |
| 用户端演示环境 | [web.ruoyiai.chat](https://web.ruoyiai.chat/) | 扫码登录 |
| 编程演示环境 | [code.ruoyiai.chat](https://code.ruoyiai.chat/) | 扫码登录 |
## ✨ 核心亮点

View File

@@ -8,7 +8,7 @@
mysql -uroot -p < ruoyi-ai.sql
```
主 SQL 已合并下表中的全部更新,无需再次执行 `update` 目录中的脚本。[snail_job_mysql.sql](snail_job_mysql.sql) 仅用于单独初始化调度库,执行主 SQL 后无需再执行它。
主 SQL 已合并下表列出的基础更新,以及原始想法、技能绑定、剧本打磨字段、模型优先级和 PC 登录超时结构。新安装仍需执行下面“后续功能更新”中注明的补充脚本。[snail_job_mysql.sql](snail_job_mysql.sql) 仅用于单独初始化调度库,执行主 SQL 后无需再执行它。
全量初始化包含 `DROP TABLE` 和调度库的 `DROP DATABASE`,已有数据库升级应使用对应的增量脚本。
@@ -43,3 +43,18 @@ mysql -uroot -p < ruoyi-ai.sql
| [2026-09-01-sys-url.sql](update/2026-09-01-sys-url.sql) | `sys_url` 表、5 个菜单权限和 2 条公开链接 |
后续增加更新脚本时,同步修改主 SQL 中对应的表结构或初始化数据,并更新本清单及主 SQL 文件头。
## 后续功能更新
以下脚本统一位于 `update/`。已有数据库按日期升级,保留原始输入、已批准媒体和提供商密钥;不要执行全量初始化。
| 脚本 | 内容与新安装要求 |
| --- | --- |
| [知识库检索节点](update/2026-09-11-knowledge-retrieval-node.sql) | 补充节点注册,新安装也需执行 |
| [短剧视觉资产](update/2026-09-30-short-drama-visual-asset.sql) | 补充异步视觉资产表,新安装也需执行 |
| [剧本打磨与 Seedance 2.5](update/2026-09-30-script-refinement-seedance-25.sql) | 打磨字段已并入全量结构;新安装执行以补充视频模型记录 |
| [技能绑定](update/2026-10-01-short-drama-skill-bindings.sql) | 项目审美与导演绑定,幂等;全量结构已包含 |
| [模型优先级](update/2026-10-02-chat-model-sort-order.sql) | 幂等增加优先级列,保留已自定义优先级;全量结构已包含 |
| [PC 登录空闲时间](update/2026-10-02-pc-login-idle-timeout.sql) | 仅调整原 30 分钟配置;全量默认已更新,已签发令牌需重新登录 |
| [文字模型默认优先级](update/2026-10-03-short-drama-flash-default.sql) | 先应用模型优先级迁移,再调整已有 Flash / Seed 模型排序,不新增模型或密钥 |
| [原始创作输入](update/2026-10-03-short-drama-original-idea.sql) | 幂等增加独立原文列,不用生成概要回填;全量结构已包含 |

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,19 @@
-- Run after the base ruoyi-ai.sql. Preserves existing scripts and provider keys.
DROP PROCEDURE IF EXISTS migrate_drama_preparation;
DELIMITER $$
CREATE PROCEDURE migrate_drama_preparation()
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='short_drama_script' AND column_name='creation_mode') THEN
ALTER TABLE short_drama_script ADD creation_mode varchar(32) DEFAULT 'standard', ADD source_materials longtext, ADD worldbuilding longtext, ADD revision_notes longtext;
END IF;
END$$
DELIMITER ;
CALL migrate_drama_preparation();
DROP PROCEDURE migrate_drama_preparation;
INSERT INTO chat_model (id,category,model_name,provider_code,model_describe,model_show,api_host,api_key,create_dept,create_by,create_time,update_by,update_time,remark,tenant_id)
SELECT 2106002500000000001,'video','bytedance/seedance-2.5/reference-to-video','atlas','Seedance 2.5 多模态参考生视频(字节跳动)','Y',
COALESCE((SELECT api_host FROM (SELECT api_host FROM chat_model WHERE provider_code='atlas' AND category='video' AND tenant_id=0 ORDER BY id DESC LIMIT 1) h),'https://api.atlascloud.ai/api/v1'),
COALESCE((SELECT api_key FROM (SELECT api_key FROM chat_model WHERE provider_code='atlas' AND category='video' AND tenant_id=0 ORDER BY id DESC LIMIT 1) k),''),
103,1,NOW(),1,NOW(),'Atlas Seedance 2.5 reference-to-video; 4-30s, up to 30 images and 10 audio references',0
WHERE NOT EXISTS (SELECT 1 FROM chat_model WHERE model_name='bytedance/seedance-2.5/reference-to-video' AND tenant_id=0);

View File

@@ -0,0 +1,13 @@
-- MySQL idempotent migration; run in the configured application database.
SET @skill_aesthetic_sql = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE short_drama_project ADD COLUMN aesthetic_skill_name VARCHAR(64) NULL COMMENT ''Selected aesthetic skill catalog name''',
'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'short_drama_project' AND column_name = 'aesthetic_skill_name');
PREPARE skill_aesthetic_stmt FROM @skill_aesthetic_sql;
EXECUTE skill_aesthetic_stmt;
DEALLOCATE PREPARE skill_aesthetic_stmt;
SET @skill_director_sql = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE short_drama_project ADD COLUMN director_skill_name VARCHAR(64) NULL COMMENT ''Selected director skill catalog name''',
'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'short_drama_project' AND column_name = 'director_skill_name');
PREPARE skill_director_stmt FROM @skill_director_sql;
EXECUTE skill_director_stmt;
DEALLOCATE PREPARE skill_director_stmt;

View File

@@ -0,0 +1,8 @@
-- Idempotent schema upgrade; preserve credentials and explicitly customized priorities.
SET @sort_order_sql = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE chat_model ADD COLUMN sort_order INT NOT NULL DEFAULT 100 COMMENT ''默认模型优先级,升序'' AFTER category',
'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'chat_model' AND column_name = 'sort_order');
PREPARE sort_order_stmt FROM @sort_order_sql;
EXECUTE sort_order_stmt;
DEALLOCATE PREPARE sort_order_stmt;
UPDATE chat_model SET sort_order = 0 WHERE provider_code = 'atlas' AND sort_order = 100 AND model_name IN ('bytedance/doubao-seed-2.1-pro-260628', 'openai/gpt-image-2.5-flare/text-to-image', 'bytedance/seedance-2.0-mini/reference-to-video');

View File

@@ -0,0 +1,7 @@
-- PC 管理后台和短剧工作台:无操作 12 小时后登录失效(单位:秒)。
-- 仅迁移原 30 分钟策略,保留已自定义的客户端设置。
UPDATE sys_client
SET active_timeout = 43200
WHERE device_type = 'pc' AND active_timeout = 1800;
-- 已部署环境通过 /system/client 更新相同字段以同步清理客户端缓存。
-- 已签发的令牌需重新登录后使用新策略。

View File

@@ -0,0 +1,6 @@
-- Existing Atlas keys/hosts remain intact. Default priority is maintained through model management.
-- Apply after 2026-10-02-chat-model-sort-order.sql.
UPDATE chat_model SET sort_order = 10
WHERE provider_code = 'atlas' AND category = 'chat' AND model_name = 'bytedance/doubao-seed-2.1-pro-260628' AND sort_order = 0;
UPDATE chat_model SET sort_order = 0
WHERE provider_code = 'atlas' AND category = 'chat' AND model_name = 'deepseek-ai/deepseek-v4.1-flash';

View File

@@ -0,0 +1,8 @@
-- Store the exact creation input separately from generated project descriptions.
-- Existing rows stay NULL unless their original request can be recovered.
SET @original_idea_sql = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE short_drama_project ADD COLUMN original_idea LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT ''Original story creation input'' AFTER description',
'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'short_drama_project' AND column_name = 'original_idea');
PREPARE original_idea_stmt FROM @original_idea_sql;
EXECUTE original_idea_stmt;
DEALLOCATE PREPARE original_idea_stmt;

View File

@@ -0,0 +1,8 @@
-- Full image prompt histories can exceed TEXT's 65535 byte capacity after a few revisions.
-- Enlarge both the history and undo columns while retaining all earlier candidates.
ALTER TABLE short_drama_character_appearance
MODIFY COLUMN image_descriptions MEDIUMTEXT NULL COMMENT '每张图片对应的提示词(JSON数组)',
MODIFY COLUMN previous_descriptions MEDIUMTEXT NULL COMMENT '上一轮提示词列表(撤销用,JSON数组)';
ALTER TABLE short_drama_location
MODIFY COLUMN image_descriptions MEDIUMTEXT NULL COMMENT '每张图片对应的提示词(JSON数组)',
MODIFY COLUMN previous_descriptions MEDIUMTEXT NULL COMMENT '上一轮提示词列表(撤销用,JSON数组)';

View File

@@ -0,0 +1,7 @@
-- Add ordered multi-selection without rewriting existing project bindings.
SET @storyboard_skills_sql = (SELECT IF(COUNT(*) = 0,
'ALTER TABLE short_drama_project ADD COLUMN storyboard_skill_names TEXT NULL COMMENT ''Ordered storyboard skill catalog names as JSON''',
'SELECT 1') FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'short_drama_project' AND column_name = 'storyboard_skill_names');
PREPARE storyboard_skills_stmt FROM @storyboard_skills_sql;
EXECUTE storyboard_skills_stmt;
DEALLOCATE PREPARE storyboard_skills_stmt;

View File

@@ -122,9 +122,9 @@ spring:
servlet:
multipart:
# 单个文件大小
max-file-size: 10MB
max-file-size: 30MB
# 设置总上传的文件大小
max-request-size: 20MB
max-request-size: 40MB
mvc:
# 设置静态资源路径 防止所有请求都去查静态资源
static-path-pattern: /static/**
@@ -415,4 +415,4 @@ short-drama:
max-redirects: ${SHORT_DRAMA_DOWNLOAD_MAX_REDIRECTS:5}
# 生产环境建议配置为视频供应商或 OSS/CDN 域名,多个值用逗号分隔。
allowed-hosts: ${SHORT_DRAMA_DOWNLOAD_ALLOWED_HOSTS:}
fake-ip-allowed-hosts: ${SHORT_DRAMA_DOWNLOAD_FAKE_IP_ALLOWED_HOSTS:atlas-media.oss-us-west-1.aliyuncs.com}
fake-ip-allowed-hosts: ${SHORT_DRAMA_DOWNLOAD_FAKE_IP_ALLOWED_HOSTS:atlas-media.oss-us-west-1.aliyuncs.com,ark-acg-ap-southeast-1.tos-ap-southeast-1.volces.com}

View File

@@ -35,6 +35,9 @@ public class ChatModelBo extends BaseEntity {
@NotBlank(message = "模型分类不能为空", groups = { AddGroup.class, EditGroup.class })
private String category;
/** 默认模型优先级,数值越小越优先;同值按ID排序。 */
private Integer sortOrder;
/**
* 模型名称
*/

View File

@@ -8,4 +8,7 @@ public class ChatModelSelectQuery {
/** Optional model category; defaults to chat at the controller boundary. */
private String category;
/** Optional exact provider filter. */
private String providerCode;
}

View File

@@ -29,9 +29,14 @@ public class ChatModelSelectVo implements Serializable {
/** Provider identifier used for display. */
private String providerCode;
private String category;
private Integer sortOrder;
public static ChatModelSelectVo from(ChatModelVo source) {
ChatModelSelectVo target = new ChatModelSelectVo();
target.setId(source.getId());
target.setCategory(source.getCategory());
target.setSortOrder(source.getSortOrder());
target.setModelName(source.getModelName());
target.setModelDescribe(source.getModelDescribe());
target.setProviderCode(source.getProviderCode());

View File

@@ -39,6 +39,9 @@ public class ChatModelVo implements Serializable {
@ExcelProperty(value = "模型分类")
private String category;
/** 默认模型优先级,数值越小越优先;同值按ID排序。 */
private Integer sortOrder;
/**
* 模型名称
*/

View File

@@ -34,6 +34,9 @@ public class ChatModel extends TenantEntity {
*/
private String category;
/** 默认模型优先级,数值越小越优先;同值按ID排序。 */
private Integer sortOrder;
/**
* 模型名称
*/

View File

@@ -25,12 +25,18 @@ public class VideoContext {
private String quality;
/** Provider output resolution; distinct from aspect ratio and quality. */
private String resolution;
/** 参考图 URL(图生视频模式,单图) */
private String imageUrl;
/** 多参考图 URL(多参考图生视频模式,配合 @imageN prompt 使用) */
private java.util.List<String> referenceImages;
/** Motion/camera reference clips; distinct from appearance reference images. */
private java.util.List<String> referenceVideos;
/** 参考音频 URL 列表(对白口型对齐模式) */
private java.util.List<String> referenceAudios;

View File

@@ -50,9 +50,9 @@ public class SaTokenExceptionHandler {
*/
@ExceptionHandler(NotLoginException.class)
public R<Void> handleNotLoginException(NotLoginException e, HttpServletRequest request) {
log.error("authorization_failed category=NOT_LOGIN method={} path={} exceptionType={}",
log.debug("authorization_failed category=NOT_LOGIN method={} path={} exceptionType={}",
request.getMethod(), safePath(request), e.getClass().getName());
return R.fail(HttpStatus.HTTP_UNAUTHORIZED, "认证失败,无法访问系统资源");
return R.fail(HttpStatus.HTTP_UNAUTHORIZED, "请先登录");
}
private static String safePath(HttpServletRequest request) {

View File

@@ -111,25 +111,36 @@ public class SseEmitterManager {
* @param eventDto SSE事件对象
*/
public void sendEvent(String sessionId, SseEventDto eventDto) {
if (!sendLocalEvent(sessionId, eventDto)) {
log.warn("sse_delivery routeType=SESSION status=SKIPPED reason=NO_ACTIVE_CONNECTION activeSessionCount={}",
SESSION_EMITTERS.size());
}
}
/** Deliver locally in order; a terminal event is flushed before its connection is removed. */
public boolean sendLocalEvent(String sessionId, SseEventDto eventDto) {
if (sessionId == null || eventDto == null) {
return;
return false;
}
SseEmitter emitter = SESSION_EMITTERS.get(sessionId);
if (emitter == null) {
log.warn("sse_delivery routeType=SESSION status=SKIPPED reason=NO_ACTIVE_CONNECTION activeSessionCount={}",
SESSION_EMITTERS.size());
return;
return false;
}
try {
log.debug("sse_delivery routeType=SESSION status=SENDING eventType={} payloadChars={}",
safeEventType(eventDto), eventPayloadLength(eventDto));
emitter.send(SseEmitter.event()
.name(eventDto.getEvent())
.data(JSONUtil.toJsonStr(eventDto)));
synchronized (emitter) {
if (SESSION_EMITTERS.get(sessionId) != emitter) return false;
emitter.send(SseEmitter.event()
.name(eventDto.getEvent())
.data(JSONUtil.toJsonStr(eventDto)));
if (Boolean.TRUE.equals(eventDto.getDone())) removeSessionEmitter(sessionId, emitter);
}
} catch (Exception e) {
log.error("sse_delivery routeType=SESSION status=FAILED errorType={}", errorType(e));
removeSessionEmitter(sessionId, emitter);
}
return true;
}
private void removeSessionEmitter(String sessionId, SseEmitter emitter) {

View File

@@ -126,6 +126,8 @@ public class SseMessageUtils {
if (!isEnable() || sessionId == null) {
return;
}
// The producer normally owns this emitter. Do not race a Redis delivery against disconnect.
if (MANAGER.sendLocalEvent(sessionId, eventDto)) return;
SseMessageDto dto = new SseMessageDto();
dto.setSessionId(sessionId);
dto.setEventDto(eventDto);
@@ -210,6 +212,13 @@ public class SseMessageUtils {
sendEvent(sessionId, SseEventDto.error(SAFE_ERROR_MESSAGE));
}
/** Final error only: unlike recoverable error events, this flushes and closes the stream. */
public static void sendErrorAndComplete(String sessionId, String error) {
SseEventDto terminal = SseEventDto.error(SAFE_ERROR_MESSAGE);
terminal.setDone(true);
sendEvent(sessionId, terminal);
}
/**
* 是否开启
*/

View File

@@ -0,0 +1,51 @@
package org.ruoyi.common.sse.core;
import cn.hutool.extra.spring.SpringUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.ruoyi.common.sse.dto.SseEventDto;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@Tag("dev")
class SseTerminalDeliveryTest {
private SseEmitterManager manager;
private Map<String, SseEmitter> emitters;
@BeforeEach @SuppressWarnings("unchecked") void setup() {
var beans = new DefaultListableBeanFactory();
beans.registerSingleton("scheduler", mock(ScheduledExecutorService.class));
new SpringUtil().postProcessBeanFactory(beans);
manager = new SseEmitterManager();
emitters = (Map<String, SseEmitter>) ReflectionTestUtils.getField(SseEmitterManager.class, "SESSION_EMITTERS");
emitters.clear();
}
@Test void doneIsSentBeforeRemovalAndCompletion() throws Exception { assertTerminal(SseEventDto.done(), false); }
@Test void redisTerminalErrorIsSentBeforeRemovalAndCompletion() throws Exception {
var error = SseEventDto.error("safe failure"); error.setDone(true); assertTerminal(error, true);
}
private void assertTerminal(SseEventDto terminal, boolean viaRedisListener) throws Exception {
var emitter = mock(SseEmitter.class); emitters.put("s", emitter);
doAnswer(call -> { assertSame(emitter, emitters.get("s")); return null; }).when(emitter).send(any(SseEmitter.SseEventBuilder.class));
doAnswer(call -> { assertFalse(emitters.containsKey("s")); return null; }).when(emitter).complete();
if (viaRedisListener) manager.sendEvent("s", terminal); else assertTrue(manager.sendLocalEvent("s", terminal));
var ordered = inOrder(emitter); ordered.verify(emitter).send(any(SseEmitter.SseEventBuilder.class)); ordered.verify(emitter).complete();
assertFalse(manager.sendLocalEvent("s", terminal));
}
@Test void normalContentAndRecoverableErrorKeepTheConnectionOpen() throws Exception {
var emitter = mock(SseEmitter.class); emitters.put("s", emitter);
assertTrue(manager.sendLocalEvent("s", SseEventDto.content("正文")));
assertTrue(manager.sendLocalEvent("s", SseEventDto.error("recoverable")));
assertSame(emitter, emitters.get("s")); verify(emitter, never()).complete();
}
}

View File

@@ -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);
}

View File

@@ -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或电商棚拍质感"
);
/** 默认视觉风格 */

View File

@@ -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());

View File

@@ -0,0 +1,18 @@
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.ShortDramaCanvasService;
import org.springframework.web.bind.annotation.*;
@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}/canvas")
public class ShortDramaCanvasController {
private final ShortDramaCanvasService service;
@GetMapping public R<ShortDramaCanvasService.Canvas> read(@PathVariable Long projectId) throws Exception {
return R.ok(service.read(projectId, LoginHelper.getUserId()));
}
@PutMapping public R<ShortDramaCanvasService.Canvas> save(@PathVariable Long projectId, @RequestBody ShortDramaCanvasService.Canvas request) throws Exception {
return R.ok(service.save(projectId, LoginHelper.getUserId(), request));
}
}

View File

@@ -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()));
}
}

View File

@@ -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,11 @@ 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.ShortDramaVideoRejectionBo;
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 +36,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 +64,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 +143,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")
@@ -143,10 +175,27 @@ public class ShortDramaController {
return R.ok(shortDramaService.retrieveVideo(storyboardId, model, LoginHelper.getUserId()));
}
/** Recover an ID verified in provider history; queries the original provider before releasing the claim. */
@PostMapping("/storyboard/{storyboardId}/video-submissions/{requestId}/resolve")
public R<ShortDramaStoryboardVo> resolveVideoSubmission(@NotNull @PathVariable Long storyboardId,
@PathVariable String requestId, @NotBlank @RequestParam String predictionId,
@RequestParam(required = false) String confirmedFailureEvidence) {
return R.ok(shortDramaService.resolveVideoSubmission(storyboardId, requestId, predictionId, LoginHelper.getUserId(), confirmedFailureEvidence));
}
/** No prediction ID is invented for an explicit legacy rejection. This only releases its local claim. */
@PostMapping("/storyboard/{storyboardId}/video-submissions/{requestId}/confirm-rejection")
public R<ShortDramaStoryboardVo> confirmVideoSubmissionRejection(@NotNull @PathVariable Long storyboardId,
@PathVariable String requestId, @Valid @RequestBody ShortDramaVideoRejectionBo evidence) {
return R.ok(shortDramaService.confirmVideoSubmissionRejection(storyboardId, requestId, LoginHelper.getUserId(), evidence));
}
@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 +229,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(), revision.getModel()));
}
// ==================== 资产分析 ====================
@@ -196,27 +246,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 +424,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 +500,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()));
}
/** 轮询确认形象图片并保存 */

View File

@@ -0,0 +1,18 @@
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.ShortDramaDirectorService;
import org.springframework.web.bind.annotation.*;
@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}/director/{id}")
public class ShortDramaDirectorController {
private final ShortDramaDirectorService service;
@GetMapping public R<ShortDramaDirectorService.Director> read(@PathVariable Long projectId, @PathVariable String id) throws Exception {
return R.ok(service.read(projectId, id, LoginHelper.getUserId()));
}
@PutMapping public R<ShortDramaDirectorService.Director> save(@PathVariable Long projectId, @PathVariable String id, @RequestBody ShortDramaDirectorService.Director request) throws Exception {
return R.ok(service.save(projectId, id, LoginHelper.getUserId(), request));
}
}

View File

@@ -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();

View File

@@ -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));}
}

View File

@@ -0,0 +1,24 @@
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("/categories") public R<List<ShortDramaSkillCatalog.Category>> categories() { return R.ok(catalog.categories()); }
@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)); }
}

View File

@@ -0,0 +1,32 @@
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.ShortDramaVideoReferenceService;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@RestController @RequiredArgsConstructor
@RequestMapping("/short-drama/{projectId}/video-references")
public class ShortDramaVideoReferenceController {
private final ShortDramaVideoReferenceService references;
@GetMapping public R<List<ShortDramaVideoReferenceService.Clip>> list(@PathVariable Long projectId) throws Exception {
return R.ok(references.list(projectId, LoginHelper.getUserId()));
}
@PostMapping(consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
public R<ShortDramaVideoReferenceService.Clip> upload(@PathVariable Long projectId, @RequestPart("file") MultipartFile file) throws Exception {
return R.ok(references.upload(projectId, LoginHelper.getUserId(), file));
}
@GetMapping("/{id}/content") public ResponseEntity<Resource> content(@PathVariable Long projectId, @PathVariable String id) {
references.owner(projectId, LoginHelper.getUserId());
var resource = new FileSystemResource(references.file(projectId, id));
if (!resource.exists()) return ResponseEntity.notFound().build();
return ResponseEntity.ok().contentType(MediaType.valueOf("video/mp4")).body(resource);
}
}

View File

@@ -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()));}
}

View File

@@ -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());}
}

View File

@@ -15,9 +15,14 @@ public class ShortDramaIdeaBo {
private String projectName;
private String artStyle;
private String aestheticSkillName;
private String directorSkillName;
private java.util.List<String> storyboardSkillNames;
private String aspectRatio;
/** Stop after script so the author can review before asset/shot planning. */
private Boolean scriptOnly = false;
/** 兼容旧请求字段;创建接口始终只生成剧本,该值不再控制后续阶段。 */
@Deprecated
private Boolean scriptOnly = true;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -19,9 +19,14 @@ public class ShortDramaProjectBo extends BaseEntity {
private String description;
private String originalIdea;
private String status;
private String artStyle;
private String aestheticSkillName;
private String directorSkillName;
private java.util.List<String> storyboardSkillNames;
private String composeAspectRatio;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -0,0 +1,16 @@
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;
/** Optional exact writing model; omission preserves the configured default. */
private String model;
}

View File

@@ -0,0 +1,10 @@
package org.ruoyi.domain.bo.shortdrama;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
/** Owner attestation for a legacy HTTP rejection whose body was retained in runtime logs. */
public record ShortDramaVideoRejectionBo(@NotNull Integer httpStatus,
@NotBlank @Size(max = 2000) String responseBody,
@NotBlank @Size(min = 20, max = 2000) String evidence) { }

View File

@@ -2,6 +2,8 @@ package org.ruoyi.domain.entity.shortdrama;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
@@ -12,7 +14,7 @@ import java.util.Date;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("short_drama_project")
@TableName(value = "short_drama_project", autoResultMap = true)
public class ShortDramaProject extends BaseEntity {
@Serial
@@ -27,9 +29,17 @@ 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;
/** Ordered supplementary skills for storyboard planning and review. */
@TableField(typeHandler = JacksonTypeHandler.class)
private java.util.List<String> storyboardSkillNames;
private Long composedVideoOssId;

View File

@@ -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;
}

View File

@@ -24,9 +24,14 @@ public class ShortDramaProjectVo implements Serializable {
private String description;
private String originalIdea;
private String status;
private String artStyle;
private String aestheticSkillName;
private String directorSkillName;
private java.util.List<String> storyboardSkillNames;
private Long composedVideoOssId;

View File

@@ -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;
}

View File

@@ -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);
}
}

Some files were not shown because too many files have changed in this diff Show More