9 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
ageerle
91b6b53ce9 feat: 增强短剧导演规则与素材生成及合成 2026-09-30 14:54:26 +08:00
ageerle
7c7e64311e Merge pull request #340 from ageerle/codex/mcp-remote-bearer-key
feat: support bearerKey in remote MCP tool JSON
2026-09-23 17:39:00 +08:00
ageerle
46714df604 feat: support bearer key in remote MCP tool config 2026-09-23 17:35:58 +08:00
279 changed files with 16719 additions and 1332 deletions

1
.gitignore vendored
View File

@@ -57,3 +57,4 @@ logs/
.flattened-pom.xml
/.claude/settings.local.json
/docs/docker/milvus/volumes/
/.codex-runtime/

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,21 @@
CREATE TABLE IF NOT EXISTS short_drama_visual_asset (
id bigint NOT NULL PRIMARY KEY,
project_id bigint NOT NULL,
tenant_id varchar(20) NOT NULL DEFAULT '000000',
storyboard_id bigint DEFAULT NULL,
asset_key varchar(180) NOT NULL,
kind varchar(24) NOT NULL,
title varchar(255) NOT NULL,
prompt longtext,
reference_images longtext,
source_hash varchar(64),
status varchar(24) NOT NULL DEFAULT 'pending',
image_url text,
model varchar(255),
prediction_id varchar(255),
error_message text,
create_time datetime DEFAULT CURRENT_TIMESTAMP,
update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_project_asset (project_id,asset_key),
KEY idx_storyboard (storyboard_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='道具资产和逐镜关键帧,可续跑';

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

@@ -47,4 +47,6 @@ public class ImageContext {
* 参考图 URL(图生图用)
*/
private String image;
/** Ordered reference images for multi-subject image editing. */
private java.util.List<String> referenceImages;
}

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,12 +64,25 @@ 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;
private final OssService ossService;
private final org.ruoyi.service.shortdrama.impl.ShortDramaRevisionService revisionService;
@PostMapping("/{projectId}/revision")
public R<ShortDramaDetailVo> applyRevision(@PathVariable Long projectId,
@Valid @RequestBody org.ruoyi.domain.bo.shortdrama.ShortDramaRevisionBo revision) {
return R.ok(revisionService.apply(projectId, revision, LoginHelper.getUserId()));
}
// ==================== 项目 ====================
@GetMapping("/projects")
@@ -123,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")
@@ -135,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")
@@ -172,10 +229,11 @@ public class ShortDramaController {
// ==================== 阶段式流水线端点 ====================
/** Phase 1: 剧本打磨 */
/** 按修改意见重新生成固定格式剧本 */
@PostMapping("/{projectId}/polish-script")
public R<ShortDramaDetailVo> polishScript(@NotNull @PathVariable Long projectId) {
return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId()));
public R<ShortDramaDetailVo> polishScript(@NotNull @PathVariable Long projectId,
@Valid @RequestBody ShortDramaScriptRevisionBo revision) {
return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId(), revision.getInstruction(), revision.getModel()));
}
// ==================== 资产分析 ====================
@@ -183,26 +241,78 @@ public class ShortDramaController {
/** Phase 2: 资产分析(角色+场景提取) */
@PostMapping("/{projectId}/analyze-assets")
public R<ShortDramaDetailVo> analyzeAssets(@NotNull @PathVariable Long projectId,
@NotNull @RequestParam Long scriptId) {
return R.ok(shortDramaService.analyzeAssets(projectId, scriptId, LoginHelper.getUserId()));
@NotNull @RequestParam Long scriptId, @RequestParam(required = false) String model) {
return R.ok(shortDramaService.analyzeAssets(projectId, scriptId, LoginHelper.getUserId(), model));
}
// ==================== 分镜流水线 ====================
@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", "/{projectId}/import-reviewed-plan"})
public R<Integer> importReviewedPlan(@PathVariable Long projectId,
@Valid @RequestBody org.ruoyi.domain.bo.shortdrama.ShortDramaReviewedPlanBo review) {
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: 重新生成摄影规则 */
@@ -314,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")
@@ -386,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

@@ -0,0 +1,186 @@
package org.ruoyi.controller.shortdrama;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import org.ruoyi.common.core.domain.R;
import org.ruoyi.common.satoken.utils.LoginHelper;
import org.ruoyi.domain.entity.shortdrama.ShortDramaStoryboard;
import org.ruoyi.mapper.shortdrama.ShortDramaProjectMapper;
import org.ruoyi.mapper.shortdrama.ShortDramaStoryboardMapper;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.io.ByteArrayInputStream;
import java.nio.file.*;
import java.util.*;
/** Original screen captures are stored locally and never sent through an image model. */
@RestController @RequiredArgsConstructor
@RequestMapping("/short-drama/{projectId}/materials")
public class ShortDramaMaterialController {
private final ShortDramaProjectMapper projects;
private final ShortDramaStoryboardMapper boards;
private static final ObjectMapper JSON = new ObjectMapper();
private void owner(Long id) {
var p=projects.selectById(id);
if(p==null || !Objects.equals(p.getUserId(),LoginHelper.getUserId())) throw new IllegalArgumentException("项目不存在或无权限");
}
private Path directory(Long project) { return Path.of("data","short-drama-materials",project.toString()).toAbsolutePath().normalize(); }
private ShortDramaStoryboard editableShot(Long projectId, Long shotId) {
owner(projectId);
var shot = boards.selectById(shotId);
if (shot == null || !projectId.equals(shot.getProjectId())) throw new IllegalArgumentException("镜头不属于当前项目");
if (!"pending".equals(shot.getVideoStatus())) throw new IllegalStateException("请先停止该镜头的视频任务");
return shot;
}
static com.fasterxml.jackson.databind.node.ArrayNode items(ObjectNode continuity) {
var media = continuity.path("source_media");
if (media.path("items").isArray()) return ((com.fasterxml.jackson.databind.node.ArrayNode) media.path("items")).deepCopy();
var result = JSON.createArrayNode();
if (media.hasNonNull("url")) result.add(media.deepCopy());
return result;
}
static void setItems(ObjectNode continuity, com.fasterxml.jackson.databind.node.ArrayNode items) {
if (items.isEmpty()) { continuity.remove("source_media"); return; }
ObjectNode media = ((ObjectNode) items.get(0)).deepCopy();
media.remove("items");
media.put("mode", "direct_insert");
media.set("items", items);
continuity.set("source_media", media);
}
private void save(ShortDramaStoryboard shot, ObjectNode continuity) {
// Update only material metadata; do not overwrite unrelated shot fields.
boards.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ShortDramaStoryboard>()
.eq(ShortDramaStoryboard::getId, shot.getId()).set(ShortDramaStoryboard::getContinuityJson, continuity.toString()));
}
@PostMapping(value="/{shotId}",consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
public synchronized R<Object> upload(@PathVariable Long projectId, @PathVariable Long shotId,
@RequestPart("file") MultipartFile[] files, @RequestParam(required=false) String replaceUrl) throws Exception {
var shot = editableShot(projectId, shotId);
ObjectNode continuity = (ObjectNode) JSON.readTree(Objects.toString(shot.getContinuityJson(), "{}"));
var list = items(continuity);
int replace = -1;
if (replaceUrl != null) {
for (int i=0; i<list.size(); i++) if (replaceUrl.equals(list.get(i).path("url").asText())) replace=i;
if (replace < 0) throw new IllegalArgumentException("素材已变化,请刷新后重试");
if (files.length != 1) throw new IllegalArgumentException("更换时请选择一张图片");
}
if (files.length == 0 || files.length > 20 || (replace < 0 && list.size()+files.length > 20))
throw new IllegalArgumentException("每个镜头最多20张素材");
// Validate the whole selection before storing or changing any bindings.
List<byte[]> contents = new ArrayList<>();
for (var file : files) {
if (file.isEmpty() || file.getSize()>10*1024*1024) throw new IllegalArgumentException("请选择10MB以内的PNG或JPEG图片");
byte[] bytes = file.getBytes();
if (!Set.of("image/png", "image/jpeg").contains(Objects.toString(file.getContentType(), "")) || ImageIO.read(new ByteArrayInputStream(bytes))==null)
throw new IllegalArgumentException("图片格式无效:"+file.getOriginalFilename());
contents.add(bytes);
}
Path dir=directory(projectId); Files.createDirectories(dir);
for (int i=0; i<files.length; i++) {
var file=files[i]; String type=file.getContentType();
String name=UUID.randomUUID()+("image/png".equals(type)?".png":".jpg");
Files.write(dir.resolve(name),contents.get(i),StandardOpenOption.CREATE_NEW);
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("type", type);
if (replace >= 0) list.set(replace, image); else list.add(image);
}
setItems(continuity, list); save(shot, continuity);
return R.ok(continuity.get("source_media"));
}
@DeleteMapping("/{shotId}")
public synchronized R<Object> remove(@PathVariable Long projectId, @PathVariable Long shotId, @RequestParam String url) throws Exception {
var shot=editableShot(projectId, shotId);
ObjectNode continuity=(ObjectNode)JSON.readTree(Objects.toString(shot.getContinuityJson(),"{}"));
var list=items(continuity);
for (int i=list.size()-1; i>=0; i--) if (url.equals(list.get(i).path("url").asText())) list.remove(i);
setItems(continuity, list); save(shot, continuity);
// Unbind only. Original files remain available for recovery.
return R.ok(continuity.get("source_media"));
}
@GetMapping("/file/{name}")
public ResponseEntity<byte[]> content(@PathVariable Long projectId,@PathVariable String name) throws Exception {
owner(projectId);
if(!name.matches("[a-f0-9-]{36}\\.(png|jpg)")) return ResponseEntity.badRequest().build();
Path file=directory(projectId).resolve(name).normalize();
if(!file.startsWith(directory(projectId)) || !Files.isRegularFile(file)) return ResponseEntity.notFound().build();
return ResponseEntity.ok().contentType(name.endsWith(".png")?MediaType.IMAGE_PNG:MediaType.IMAGE_JPEG).header("Cache-Control","private, max-age=3600").body(Files.readAllBytes(file));
}
@GetMapping("/video/{id}")
public ResponseEntity<org.springframework.core.io.Resource> video(@PathVariable Long projectId,@PathVariable String id) {
owner(projectId);if(!id.matches("[a-f0-9-]{36}"))return ResponseEntity.badRequest().build();
var file=directory(projectId).resolve(id+".mp4");if(!Files.isRegularFile(file))return ResponseEntity.notFound().build();
return ResponseEntity.ok().contentType(MediaType.valueOf("video/mp4")).body(new org.springframework.core.io.FileSystemResource(file));
}
@PostMapping(value="/{shotId}/video", consumes=MediaType.MULTIPART_FORM_DATA_VALUE)
public synchronized R<?> importVideo(@PathVariable Long projectId, @PathVariable Long shotId,
@RequestPart("file") MultipartFile upload, @RequestParam String expectedVideoId) throws Exception {
owner(projectId);
var shot=boards.selectById(shotId);
if(shot==null || !projectId.equals(shot.getProjectId()))throw new IllegalArgumentException("镜头不属于当前项目");
if("generating".equals(shot.getVideoStatus()) || !Objects.equals(expectedVideoId,Objects.toString(shot.getVideoId(),"")))
throw new IllegalArgumentException("视频状态已变化,请刷新后重试");
var project=projects.selectById(projectId);
if(Set.of("pending","processing").contains(Objects.toString(project.getComposeStatus(),"")))throw new IllegalArgumentException("请等待成片合成结束");
if(upload.isEmpty() || upload.getSize()>19*1024*1024)throw new IllegalArgumentException("请选择19MB以内的视频");
Path dir=directory(projectId);Files.createDirectories(dir);
String id=UUID.randomUUID().toString();Path input=dir.resolve(id+".upload"),output=dir.resolve(id+".mp4"),probe=dir.resolve(id+".probe");
try {
upload.transferTo(input);
org.ruoyi.service.shortdrama.impl.ShortDramaSoundService.run(List.of("ffprobe","-v","error","-show_entries","format=duration:stream=codec_type,width,height","-of","json",input.toString()),probe);
var info=JSON.readTree(Files.readString(probe));double duration=info.path("format").path("duration").asDouble(-1);
boolean video=false,audio=false;
for(var stream:info.path("streams")) {if("video".equals(stream.path("codec_type").asText())){video=true;if(stream.path("width").asInt()>3840 || stream.path("height").asInt()>3840)throw new IllegalArgumentException("视频尺寸超过4K");}if("audio".equals(stream.path("codec_type").asText()))audio=true;}
if(!video || !audio || !Double.isFinite(duration) || Math.abs(duration-shot.getDurationSeconds())>0.25)throw new IllegalArgumentException("视频须包含音轨,且时长与本镜预算一致(允许0.25秒误差)");
org.ruoyi.service.shortdrama.impl.ShortDramaSoundService.run(List.of("ffmpeg","-v","error","-y","-i",input.toString(),"-map","0:v:0","-map","0:a:0","-t",shot.getDurationSeconds().toString(),"-c:v","libx264","-preset","fast","-crf","18","-pix_fmt","yuv420p","-c:a","aac","-movflags","+faststart",output.toString()),dir.resolve(id+".log"));
String url="/short-drama/"+projectId+"/materials/video/"+id;
var update=new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ShortDramaStoryboard>().eq(ShortDramaStoryboard::getId,shotId)
.eq(ShortDramaStoryboard::getVideoStatus,shot.getVideoStatus());
if(shot.getVideoId()==null)update.isNull(ShortDramaStoryboard::getVideoId);else update.eq(ShortDramaStoryboard::getVideoId,shot.getVideoId());
int changed=boards.update(null,update.set(ShortDramaStoryboard::getVideoUrl,url).set(ShortDramaStoryboard::getVideoId,"material:"+id)
.set(ShortDramaStoryboard::getVideoStatus,"done").set(ShortDramaStoryboard::getLastFrameUrl,null).set(ShortDramaStoryboard::getUpdateTime,new Date()));
if(changed!=1)throw new IllegalStateException("镜头已更新,修订片段未覆盖当前版本");
return R.ok(Map.of("videoUrl",url));
} finally {Files.deleteIfExists(input);}
}
@PostMapping("/{shotId}/render")
public synchronized R<?> render(@PathVariable Long projectId,@PathVariable Long shotId) throws Exception {
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("请先上传素材");
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();
for(int i=0;i<images.size();i++) {
String prefix="/short-drama/"+projectId+"/materials/file/",url=images.get(i).path("url").asText();
if(!url.startsWith(prefix))throw new IllegalArgumentException("只允许当前项目已上传的原图");
String name=url.substring(prefix.length());if(!name.matches("[a-f0-9-]{36}\\.(png|jpg)"))throw new IllegalArgumentException("素材路径无效");
command.addAll(List.of("-loop","1","-t",Double.toString(part),"-i",dir.resolve(name).toString()));
filters.add("["+i+":v]"+cropFilter(images.get(i).path("crop"))+"scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:color=white,setsar=1,fps=24,trim=duration="+part+",setpts=PTS-STARTPTS[v"+i+"]");concat.append("[v").append(i).append("]");
}
command.addAll(List.of("-f","lavfi","-t",Double.toString(duration),"-i","anullsrc=r=48000:cl=stereo"));
filters.add(concat+"concat=n="+images.size()+":v=1:a=0[out]");Path output=dir.resolve(job+".mp4");
command.addAll(List.of("-filter_complex",String.join(";",filters),"-map","[out]","-map",images.size()+":a","-t",Double.toString(duration),"-c:v","libx264","-preset","fast","-crf","18","-pix_fmt","yuv420p","-c:a","aac","-movflags","+faststart",output.toString()));
org.ruoyi.service.shortdrama.impl.ShortDramaSoundService.run(command,dir.resolve(job+".log"));
String videoUrl="/short-drama/"+projectId+"/materials/video/"+job;
boards.update(null,new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ShortDramaStoryboard>().eq(ShortDramaStoryboard::getId,shotId)
.set(ShortDramaStoryboard::getVideoUrl,videoUrl).set(ShortDramaStoryboard::getVideoStatus,"done").set(ShortDramaStoryboard::getVideoId,"material:"+job));
return R.ok(Map.of("videoUrl",videoUrl,"duration",duration));
}
static String cropFilter(com.fasterxml.jackson.databind.JsonNode crop) {
if(crop.isMissingNode() || crop.isNull())return "";
if(!crop.isObject())throw new IllegalArgumentException("素材裁切区域格式无效");
double x=crop.path("x").asDouble(-1),y=crop.path("y").asDouble(-1),w=crop.path("width").asDouble(-1),h=crop.path("height").asDouble(-1);
if(!Double.isFinite(x+y+w+h) || x<0 || y<0 || w<=0 || h<=0 || x+w>1.00001 || y+h>1.00001)throw new IllegalArgumentException("素材裁切区域须在原图内");
return "crop=iw*"+w+":ih*"+h+":iw*"+x+":ih*"+y+",";
}
}

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,21 @@
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.ShortDramaSoundService;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Files;
import java.util.List;
@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}/sounds")
public class ShortDramaSoundController {
private final ShortDramaSoundService service;
@GetMapping public R<?> list(@PathVariable Long projectId) throws Exception {service.owner(projectId,LoginHelper.getUserId());return R.ok(service.list(projectId));}
@PostMapping public R<?> upload(@PathVariable Long projectId,@RequestPart MultipartFile file,@RequestParam String purpose,@RequestParam List<Integer> shots,@RequestParam(defaultValue="0.12") double volume,@RequestParam(defaultValue="0") double offset) throws Exception {
return R.ok(service.upload(projectId,LoginHelper.getUserId(),file,purpose,shots,volume,offset));
}
@GetMapping("/{id}") public ResponseEntity<byte[]> audio(@PathVariable Long projectId,@PathVariable String id) throws Exception {
service.owner(projectId,LoginHelper.getUserId());return ResponseEntity.ok().contentType(MediaType.valueOf("audio/mpeg")).body(Files.readAllBytes(service.file(projectId,id)));
}
}

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

@@ -0,0 +1,30 @@
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.ShortDramaVisualAssetService;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}/visual-assets")
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,6 +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;
/** 兼容旧请求字段;创建接口始终只生成剧本,该值不再控制后续阶段。 */
@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

@@ -0,0 +1,19 @@
package org.ruoyi.domain.bo.shortdrama;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.util.List;
import org.ruoyi.service.shortdrama.impl.ShortDramaServiceImpl.StoryboardPanelData;
@Data
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

@@ -0,0 +1,21 @@
package org.ruoyi.domain.bo.shortdrama;
import lombok.Data;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import java.util.List;
@Data
public class ShortDramaRevisionBo {
@NotNull private Long scriptId;
@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

@@ -47,7 +47,7 @@ public class McpTool extends TenantEntity {
/**
* 配置信息(JSON格式)
* LOCAL: {"command": "npx", "args": ["-y", "@example/mcp-server"], "env": {...}}
* REMOTE: {"baseUrl": "http://localhost:8080/mcp"}
* REMOTE: {"baseUrl": "https://example.com/mcp", "bearerKey": "optional-key"}
* BUILTIN: null
*/
private String configJson;

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