mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-11-14 15:00:40 +00:00
Compare commits
7 Commits
7c7e64311e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a12be349a | ||
|
|
f286f50bbe | ||
|
|
5e66c7bd30 | ||
|
|
8ab3638012 | ||
|
|
432fbb6ea4 | ||
|
|
ca6ef96200 | ||
|
|
91b6b53ce9 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -57,3 +57,4 @@ logs/
|
||||
.flattened-pom.xml
|
||||
/.claude/settings.local.json
|
||||
/docs/docker/milvus/volumes/
|
||||
/.codex-runtime/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/) | 扫码登录 |
|
||||
|
||||
## ✨ 核心亮点
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
@@ -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='道具资产和逐镜关键帧,可续跑';
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
@@ -0,0 +1,7 @@
|
||||
-- PC 管理后台和短剧工作台:无操作 12 小时后登录失效(单位:秒)。
|
||||
-- 仅迁移原 30 分钟策略,保留已自定义的客户端设置。
|
||||
UPDATE sys_client
|
||||
SET active_timeout = 43200
|
||||
WHERE device_type = 'pc' AND active_timeout = 1800;
|
||||
-- 已部署环境通过 /system/client 更新相同字段以同步清理客户端缓存。
|
||||
-- 已签发的令牌需重新登录后使用新策略。
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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数组)';
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -35,6 +35,9 @@ public class ChatModelBo extends BaseEntity {
|
||||
@NotBlank(message = "模型分类不能为空", groups = { AddGroup.class, EditGroup.class })
|
||||
private String category;
|
||||
|
||||
/** 默认模型优先级,数值越小越优先;同值按ID排序。 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/**
|
||||
* 模型名称
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -39,6 +39,9 @@ public class ChatModelVo implements Serializable {
|
||||
@ExcelProperty(value = "模型分类")
|
||||
private String category;
|
||||
|
||||
/** 默认模型优先级,数值越小越优先;同值按ID排序。 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/**
|
||||
* 模型名称
|
||||
*/
|
||||
|
||||
@@ -34,6 +34,9 @@ public class ChatModel extends TenantEntity {
|
||||
*/
|
||||
private String category;
|
||||
|
||||
/** 默认模型优先级,数值越小越优先;同值按ID排序。 */
|
||||
private Integer sortOrder;
|
||||
|
||||
/**
|
||||
* 模型名称
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否开启
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package org.ruoyi.agent;
|
||||
|
||||
import dev.langchain4j.service.SystemMessage;
|
||||
import dev.langchain4j.service.UserMessage;
|
||||
import dev.langchain4j.service.V;
|
||||
import org.ruoyi.domain.bo.shortdrama.ShortDramaScriptResult;
|
||||
|
||||
/**
|
||||
* 短剧剧本打磨 Agent —— 使用 langchain4j AiServices 结构化输出
|
||||
* <p>
|
||||
* 框架自动生成 JSON Schema 并强制 LLM 返回符合结构的数据,无需手工 parse。
|
||||
*
|
||||
* @author ageerle
|
||||
*/
|
||||
public interface ShortDramaScriptAgent {
|
||||
|
||||
@SystemMessage("""
|
||||
你是顶级短剧编剧和创意总监。请根据用户的一个创意想法,创作完整的短剧剧本。
|
||||
|
||||
【核心原则 - 最高优先级】
|
||||
1. 剧本必须完整、有张力、有画面感
|
||||
2. 角色要有鲜明性格,不是工具人
|
||||
3. 情节紧凑,每句台词都推动剧情
|
||||
4. 场景描写具体,让分镜师能直接画出画面
|
||||
5. 对话自然有力,避免废话
|
||||
|
||||
【剧本格式】
|
||||
使用标准剧本格式,包含以下元素:
|
||||
1. 场景头(Scene Heading):内景/外景+地点+时间,如"内景 客厅 清晨"
|
||||
2. 场景描述(Scene Description):简洁描述场景环境、布局、关键道具
|
||||
3. 动作描述(Action):描述角色的动作、表情、行为,连续段落形式
|
||||
4. 对话(Dialogue):角色名: 台词内容
|
||||
5. 画外音(Voiceover):旁白、独白、回忆中的声音
|
||||
|
||||
【剧本长度要求】
|
||||
- scriptText:1000-3000字,含完整的开场、冲突发展、高潮、结尾
|
||||
- outlineText:400-800字,概述完整故事线
|
||||
|
||||
【角色塑造要求】
|
||||
- 每个角色要有明确的性格标签(如:霸道总裁、温柔女医、腹黑谋士)
|
||||
- 角色之间要有清晰的关系和冲突
|
||||
- 对话要符合角色性格
|
||||
|
||||
【情节要求】
|
||||
- 必须有清晰的冲突和反转
|
||||
- 情绪节奏要有起伏(紧张→舒缓→爆发)
|
||||
- 结尾要有记忆点(反转/留白/情感升华)
|
||||
""")
|
||||
@UserMessage("用户期望项目名:{{projectName}}\n用户创意:{{idea}}")
|
||||
ShortDramaScriptResult polish(@V("projectName") String projectName, @V("idea") String idea);
|
||||
}
|
||||
@@ -44,9 +44,10 @@ public final class ShortDramaImageConstants {
|
||||
/** 项目视觉风格 → 生图 prompt 后缀映射,确保同项目所有图片风格一致 */
|
||||
public static final java.util.Map<String, String> ART_STYLE_PROMPTS = java.util.Map.of(
|
||||
"american-comic", "美式漫画风格,粗线条,高饱和度色彩,强烈光影对比",
|
||||
"chinese-3d", "国风电影级三维动画,Chinese donghua cinematic 3D animation,统一的角色三维建模和辨识度,东方人物面部结构与自然身体比例,PBR衣料、木石金属材质和细腻服饰纹理,立体体积光与可解释的环境灯光,克制的次表面散射,自然表情和电影镜头景深,国风建筑与服饰遵循当前故事年代,色彩清透且光色统一;不使用真人摄影实拍、2D赛璐璐平涂、黑色漫画线稿、塑料玩偶或油亮蜡像肤质",
|
||||
"chinese-comic", "现代国漫动画风格,Chinese donghua 2D comic style,赛璐璐平涂上色,干净锐利的黑色线稿,平面化光影无真实景深,动漫人物比例(略放大双眼、修长身形),皮肤平滑无毛孔无写实肤质,国风服饰剪裁与材质细节清晰,色彩饱满通透,画面精致干净;禁止真人写实、摄影实拍、3D渲染、CGI、厚涂油画、写实皮肤纹理、景深虚化",
|
||||
"japanese-anime", "现代日系动漫风格,赛璐璐上色,清晰干净的线条,视觉小说CG感,高质量2D风格",
|
||||
"realistic", "真实电影级画面质感,真实现实场景,色彩饱满通透,画面干净精致,真实感"
|
||||
"realistic", "真人影视写实,自然人物比例与未磨皮肤质,真实衣料与环境材质,可解释的光源和丰富中间调,色彩与维护程度服从故事,不使用美颜滤镜、塑料CG或电商棚拍质感"
|
||||
);
|
||||
|
||||
/** 默认视觉风格 */
|
||||
|
||||
@@ -67,6 +67,7 @@ public class ChatModelController extends BaseController {
|
||||
} else {
|
||||
internalQuery.setCategory(query.getCategory());
|
||||
}
|
||||
if (query != null) internalQuery.setProviderCode(query.getProviderCode());
|
||||
return R.ok(chatModelService.queryAvailableList(internalQuery).stream()
|
||||
.map(ChatModelSelectVo::from)
|
||||
.toList());
|
||||
|
||||
@@ -0,0 +1,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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.ruoyi.controller.shortdrama;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.common.satoken.utils.LoginHelper;
|
||||
import org.ruoyi.service.shortdrama.impl.ShortDramaCharacterVoiceService;
|
||||
import org.springframework.http.*;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import java.nio.file.Files;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}")
|
||||
public class ShortDramaCharacterVoiceController {
|
||||
private final ShortDramaCharacterVoiceService service;
|
||||
@GetMapping("/characters/{characterId}/voice")
|
||||
public R<?> get(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId) throws Exception {return R.ok(service.get(projectId,characterId,LoginHelper.getUserId()));}
|
||||
@PutMapping("/characters/{characterId}/voice")
|
||||
public R<?> settings(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@RequestBody ShortDramaCharacterVoiceService.Settings request) throws Exception {return R.ok(service.settings(projectId,characterId,LoginHelper.getUserId(),request));}
|
||||
@PostMapping("/characters/{characterId}/voice/generate")
|
||||
public R<?> generate(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@RequestBody ShortDramaCharacterVoiceService.Generate request) throws Exception {return R.ok(service.generate(projectId,characterId,LoginHelper.getUserId(),request));}
|
||||
@GetMapping("/characters/{characterId}/voice/jobs/{id}")
|
||||
public R<?> poll(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@PathVariable("id") String id) throws Exception {return R.ok(service.poll(projectId,characterId,LoginHelper.getUserId(),id));}
|
||||
@PostMapping("/characters/{characterId}/voice/upload")
|
||||
public R<?> upload(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@RequestPart("file") MultipartFile file) throws Exception {return R.ok(service.upload(projectId,characterId,LoginHelper.getUserId(),file));}
|
||||
@PostMapping("/characters/{characterId}/voice/select")
|
||||
public R<?> select(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@RequestBody ShortDramaCharacterVoiceService.Selection request) throws Exception {return R.ok(service.select(projectId,characterId,LoginHelper.getUserId(),request));}
|
||||
@GetMapping("/characters/{characterId}/voice/samples/{id}")
|
||||
public ResponseEntity<byte[]> audio(@PathVariable("projectId") Long projectId,@PathVariable("characterId") Long characterId,@PathVariable("id") String id) throws Exception {
|
||||
return ResponseEntity.ok().contentType(MediaType.valueOf("audio/mpeg")).cacheControl(CacheControl.noStore()).body(Files.readAllBytes(service.audio(projectId,characterId,LoginHelper.getUserId(),id)));
|
||||
}
|
||||
@GetMapping("/voices/storyboards/{storyboardId}")
|
||||
public R<?> preview(@PathVariable("projectId") Long projectId,@PathVariable("storyboardId") Long storyboardId,@RequestParam(value="model",required=false) String model) throws Exception {
|
||||
var plan=service.preview(projectId,storyboardId,LoginHelper.getUserId(),model);
|
||||
return R.ok(Map.of("bindings",plan.bindings(),"direction",plan.direction(),"issues",plan.issues(),"explicit",plan.explicit(),"references",plan.fingerprints(),"speakerIds",plan.speakerIds()));
|
||||
}
|
||||
@PutMapping("/voices/storyboards/{storyboardId}")
|
||||
public R<?> speakers(@PathVariable("projectId") Long projectId,@PathVariable("storyboardId") Long storyboardId,@RequestBody ShortDramaCharacterVoiceService.SpeakersRequest request) throws Exception {
|
||||
return R.ok(Map.of("continuityJson",service.speakers(projectId,storyboardId,LoginHelper.getUserId(),request).getContinuityJson()));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.ruoyi.common.chat.entity.media.MediaGenerationResponse;
|
||||
@@ -18,8 +20,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()));
|
||||
}
|
||||
|
||||
/** 轮询确认形象图片并保存 */
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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+",";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.ruoyi.controller.shortdrama;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.common.satoken.utils.LoginHelper;
|
||||
import org.ruoyi.service.shortdrama.impl.ShortDramaMusicService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}/music")
|
||||
public class ShortDramaMusicController {
|
||||
private final ShortDramaMusicService service;
|
||||
@GetMapping public R<?> list(@PathVariable Long projectId) throws Exception {return R.ok(service.list(projectId,LoginHelper.getUserId()));}
|
||||
@PostMapping public R<?> generate(@PathVariable Long projectId,@RequestBody ShortDramaMusicService.MusicRequest request) throws Exception {return R.ok(service.generate(projectId,LoginHelper.getUserId(),request));}
|
||||
@GetMapping("/{id}") public R<?> poll(@PathVariable Long projectId,@PathVariable String id) throws Exception {return R.ok(service.poll(projectId,LoginHelper.getUserId(),id));}
|
||||
@PostMapping("/{id}/use") public R<?> use(@PathVariable Long projectId,@PathVariable String id,@RequestBody ShortDramaMusicService.Usage usage) throws Exception {return R.ok(service.use(projectId,LoginHelper.getUserId(),id,usage));}
|
||||
@PostMapping("/write") public R<?> write(@PathVariable Long projectId,@RequestBody ShortDramaMusicService.BriefRequest request) {return R.ok(service.write(projectId,LoginHelper.getUserId(),request));}
|
||||
}
|
||||
@@ -0,0 +1,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)); }
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()));}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.ruoyi.controller.shortdrama;
|
||||
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.service.shortdrama.impl.ShortDramaVoiceException;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestControllerAdvice(assignableTypes={ShortDramaCharacterVoiceController.class,ShortDramaController.class})
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class ShortDramaVoiceErrors {
|
||||
@ExceptionHandler(ShortDramaVoiceException.class)
|
||||
public R<Void> validation(ShortDramaVoiceException error) {return R.fail(error.getMessage());}
|
||||
}
|
||||
@@ -15,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;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package org.ruoyi.domain.bo.shortdrama;
|
||||
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/** Optional request-only edit intent; omission preserves approved identity/location continuity. */
|
||||
@Data
|
||||
public class ShortDramaImageRevisionBo {
|
||||
@Pattern(regexp = "identity|identity_revision|location|location_revision")
|
||||
private String referencePurpose;
|
||||
|
||||
@Size(max = 4000)
|
||||
private String revisionRequirements;
|
||||
|
||||
/** Separate look references; the primary reference still owns identity or geometry. */
|
||||
@Size(max = 3)
|
||||
private java.util.List<@Pattern(regexp = "https?://.+") String> styleReferenceImageUrls;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.ruoyi.domain.bo.shortdrama;
|
||||
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
/** Request-only image changes. Never persisted into the frozen location description. */
|
||||
@Data
|
||||
public class ShortDramaLocationImageRevisionBo {
|
||||
@Size(max = 4000)
|
||||
private String revisionRequirements;
|
||||
}
|
||||
@@ -19,9 +19,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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -24,4 +24,12 @@ public class ShortDramaScriptBo extends BaseEntity {
|
||||
private String tone;
|
||||
|
||||
private String sourceType;
|
||||
|
||||
private String creationMode;
|
||||
|
||||
private String sourceMaterials;
|
||||
|
||||
private String worldbuilding;
|
||||
|
||||
private String revisionNotes;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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) { }
|
||||
@@ -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;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user