mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-11-11 21:40:41 +00:00
Compare commits
6 Commits
91b6b53ce9
...
8a12be349a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a12be349a | ||
|
|
f286f50bbe | ||
|
|
5e66c7bd30 | ||
|
|
8ab3638012 | ||
|
|
432fbb6ea4 | ||
|
|
ca6ef96200 |
@@ -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,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;
|
||||
|
||||
/**
|
||||
* 模型名称
|
||||
*/
|
||||
|
||||
@@ -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,6 +64,11 @@ import java.util.List;
|
||||
@RequestMapping("/short-drama")
|
||||
public class ShortDramaController {
|
||||
|
||||
@ExceptionHandler(ShortDramaTiming.ReviewException.class)
|
||||
public R<Void> handleTimingReview(ShortDramaTiming.ReviewException error) {
|
||||
return R.fail(error.getMessage());
|
||||
}
|
||||
|
||||
private final IShortDramaService shortDramaService;
|
||||
|
||||
private final IShortDramaVideoComposeService videoComposeService;
|
||||
@@ -131,10 +143,30 @@ public class ShortDramaController {
|
||||
return R.ok(shortDramaService.saveStoryboard(bo, LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
@PostMapping("/storyboard/add")
|
||||
public R<ShortDramaStoryboardVo> addStoryboard(@RequestParam Long projectId, @RequestParam Long scriptId,
|
||||
@RequestParam(required = false) Long afterId) {
|
||||
return R.ok(shortDramaService.addStoryboard(projectId, scriptId, afterId, LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/storyboard/{storyboardId}")
|
||||
public R<Void> deleteStoryboard(@PathVariable Long storyboardId) {
|
||||
shortDramaService.deleteStoryboard(storyboardId, LoginHelper.getUserId());
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@PostMapping("/storyboard/{storyboardId}/generate-video")
|
||||
public R<ShortDramaStoryboardVo> generateVideo(@NotNull @PathVariable Long storyboardId,
|
||||
@NotBlank @RequestParam String model) {
|
||||
return R.ok(shortDramaService.generateVideo(storyboardId, model, LoginHelper.getUserId()));
|
||||
@NotBlank @RequestParam String model,
|
||||
@RequestParam(required = false) String requestId,
|
||||
@RequestParam(defaultValue = "false") boolean regenerate) {
|
||||
return R.ok(shortDramaService.generateVideo(storyboardId, model, LoginHelper.getUserId(), requestId, regenerate));
|
||||
}
|
||||
|
||||
@GetMapping("/storyboard/{storyboardId}/video-submissions/{requestId}")
|
||||
public R<org.ruoyi.service.shortdrama.impl.ShortDramaVideoSubmissionStore.Submission> videoSubmission(
|
||||
@NotNull @PathVariable Long storyboardId, @PathVariable String requestId) {
|
||||
return R.ok(shortDramaService.videoSubmission(storyboardId, requestId, LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
@GetMapping("/storyboard/{storyboardId}/video-result")
|
||||
@@ -143,10 +175,27 @@ public class ShortDramaController {
|
||||
return R.ok(shortDramaService.retrieveVideo(storyboardId, model, LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
/** Recover an ID verified in provider history; queries the original provider before releasing the claim. */
|
||||
@PostMapping("/storyboard/{storyboardId}/video-submissions/{requestId}/resolve")
|
||||
public R<ShortDramaStoryboardVo> resolveVideoSubmission(@NotNull @PathVariable Long storyboardId,
|
||||
@PathVariable String requestId, @NotBlank @RequestParam String predictionId,
|
||||
@RequestParam(required = false) String confirmedFailureEvidence) {
|
||||
return R.ok(shortDramaService.resolveVideoSubmission(storyboardId, requestId, predictionId, LoginHelper.getUserId(), confirmedFailureEvidence));
|
||||
}
|
||||
|
||||
/** No prediction ID is invented for an explicit legacy rejection. This only releases its local claim. */
|
||||
@PostMapping("/storyboard/{storyboardId}/video-submissions/{requestId}/confirm-rejection")
|
||||
public R<ShortDramaStoryboardVo> confirmVideoSubmissionRejection(@NotNull @PathVariable Long storyboardId,
|
||||
@PathVariable String requestId, @Valid @RequestBody ShortDramaVideoRejectionBo evidence) {
|
||||
return R.ok(shortDramaService.confirmVideoSubmissionRejection(storyboardId, requestId, LoginHelper.getUserId(), evidence));
|
||||
}
|
||||
|
||||
@PostMapping("/{projectId}/generate-all-videos")
|
||||
public R<List<ShortDramaStoryboardVo>> generateAllVideos(@NotNull @PathVariable Long projectId,
|
||||
@NotBlank @RequestParam String model) {
|
||||
return R.ok(shortDramaService.generateAllVideos(projectId, model, LoginHelper.getUserId()));
|
||||
@NotBlank @RequestParam String model,
|
||||
@RequestParam(required = false) Integer sceneStart,
|
||||
@RequestParam(required = false) Integer sceneCount) {
|
||||
return R.ok(shortDramaService.generateAllVideos(projectId, model, LoginHelper.getUserId(), sceneStart, sceneCount));
|
||||
}
|
||||
|
||||
@PostMapping("/{projectId}/compose-video")
|
||||
@@ -180,10 +229,11 @@ public class ShortDramaController {
|
||||
|
||||
// ==================== 阶段式流水线端点 ====================
|
||||
|
||||
/** Phase 1: 剧本打磨 */
|
||||
/** 按修改意见重新生成固定格式剧本 */
|
||||
@PostMapping("/{projectId}/polish-script")
|
||||
public R<ShortDramaDetailVo> polishScript(@NotNull @PathVariable Long projectId, @RequestParam(required = false) String model) {
|
||||
return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId(), model));
|
||||
public R<ShortDramaDetailVo> polishScript(@NotNull @PathVariable Long projectId,
|
||||
@Valid @RequestBody ShortDramaScriptRevisionBo revision) {
|
||||
return R.ok(shortDramaService.polishScript(projectId, LoginHelper.getUserId(), revision.getInstruction(), revision.getModel()));
|
||||
}
|
||||
|
||||
// ==================== 资产分析 ====================
|
||||
@@ -196,27 +246,73 @@ public class ShortDramaController {
|
||||
}
|
||||
|
||||
// ==================== 分镜流水线 ====================
|
||||
@PostMapping("/{projectId}/analyze-assets/stream")
|
||||
public SseEmitter analyzeAssetsStream(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId, @RequestParam(required = false) String model,
|
||||
@RequestParam(required = false) String requestId) {
|
||||
return shortDramaService.analyzeAssetsStream(projectId, scriptId, LoginHelper.getUserId(), model, requestId);
|
||||
}
|
||||
|
||||
@GetMapping("/{projectId}/analyze-assets/status")
|
||||
public R<java.util.Map<String, Object>> assetAnalysisStatus(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId, @RequestParam(required = false) String requestId) {
|
||||
return R.ok(shortDramaService.assetAnalysisStatus(projectId, scriptId, requestId, LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
|
||||
/** Phase 3-6: 分镜规划+摄影规则+表演指导+分镜细化 */
|
||||
@PostMapping("/{projectId}/plan-storyboard")
|
||||
public R<List<ShortDramaStoryboardVo>> planStoryboard(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId,
|
||||
@RequestParam(required = false) String model) {
|
||||
return R.ok(shortDramaService.planStoryboard(projectId, scriptId, model, LoginHelper.getUserId()));
|
||||
@RequestParam(required = false) String model,
|
||||
@Min(1) @Max(15) @RequestParam(defaultValue = "1") Integer minimumShotSeconds) {
|
||||
return R.ok(shortDramaService.planStoryboard(projectId, scriptId, model, LoginHelper.getUserId(), minimumShotSeconds));
|
||||
}
|
||||
|
||||
/** Phase 3-6: SSE 流式生成分镜,持续推送规划和细化进度 */
|
||||
@PostMapping("/{projectId}/plan-storyboard/review")
|
||||
@PostMapping({"/{projectId}/plan-storyboard/review", "/{projectId}/import-reviewed-plan"})
|
||||
public R<Integer> importReviewedPlan(@PathVariable Long projectId,
|
||||
@Valid @RequestBody org.ruoyi.domain.bo.shortdrama.ShortDramaReviewedPlanBo review) {
|
||||
return R.ok(shortDramaService.importReviewedPlan(projectId,review,LoginHelper.getUserId()));
|
||||
try {
|
||||
return R.ok(shortDramaService.importReviewedPlan(projectId,review,LoginHelper.getUserId()));
|
||||
} catch (IllegalArgumentException | IllegalStateException rejected) {
|
||||
// The global handler intentionally hides even ServiceException messages. This normal,
|
||||
// owner-checked endpoint returns only known domain validation feedback, without a stack/cause.
|
||||
String message = org.ruoyi.service.shortdrama.impl.ShortDramaReviewedImportMessages.failureMessage(rejected);
|
||||
if (message == null) throw rejected;
|
||||
return R.fail(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Read-only recovery proof; it never submits a model request or promotes an unchecked draft. */
|
||||
@GetMapping("/{projectId}/checkpoint-status")
|
||||
public R<java.util.Map<String, Object>> storyboardCheckpointStatus(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId, @RequestParam(required = false) String model,
|
||||
@Min(1) @Max(15) @RequestParam(defaultValue = "1") Integer minimumShotSeconds) {
|
||||
return R.ok(shortDramaService.storyboardCheckpointStatus(projectId, scriptId, model, LoginHelper.getUserId(), minimumShotSeconds));
|
||||
}
|
||||
|
||||
/** Unreviewed native output only; viewing it does not validate or import any panel. */
|
||||
@GetMapping("/{projectId}/plan-storyboard/candidates")
|
||||
public R<java.util.Map<String, Object>> storyboardCandidates(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId, @RequestParam(required = false) String model,
|
||||
@Min(1) @Max(15) @RequestParam(defaultValue = "4") Integer minimumShotSeconds) {
|
||||
return R.ok(shortDramaService.storyboardCandidates(projectId, scriptId, model, LoginHelper.getUserId(), minimumShotSeconds));
|
||||
}
|
||||
|
||||
@GetMapping("/{projectId}/plan-storyboard/status")
|
||||
public R<java.util.Map<String, Object>> storyboardPlanningStatus(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId, @RequestParam(required = false) String requestId) {
|
||||
return R.ok(shortDramaService.storyboardPlanningStatus(projectId, scriptId, requestId, LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
@PostMapping("/{projectId}/plan-storyboard/stream")
|
||||
public SseEmitter planStoryboardStream(@NotNull @PathVariable Long projectId,
|
||||
@NotNull @RequestParam Long scriptId,
|
||||
@RequestParam(required = false) String model) {
|
||||
return shortDramaService.planStoryboardStream(projectId, scriptId, model, LoginHelper.getUserId());
|
||||
@RequestParam(required = false) String model,
|
||||
@Min(1) @Max(15) @RequestParam(defaultValue = "1") Integer minimumShotSeconds,
|
||||
@RequestParam(required = false) String requestId) {
|
||||
return shortDramaService.planStoryboardStream(projectId, scriptId, model, LoginHelper.getUserId(), minimumShotSeconds, requestId);
|
||||
}
|
||||
|
||||
/** Phase 4: 重新生成摄影规则 */
|
||||
@@ -328,15 +424,19 @@ public class ShortDramaController {
|
||||
@PostMapping("/location/{locationId}/generate-image")
|
||||
public R<ShortDramaLocationVo> generateLocationImage(@NotNull @PathVariable Long locationId,
|
||||
@NotBlank @RequestParam String model,
|
||||
@RequestParam(required = false) String referenceImageUrl) {
|
||||
return R.ok(shortDramaService.generateLocationImage(locationId, model, referenceImageUrl, LoginHelper.getUserId()));
|
||||
@RequestParam(required = false) String referenceImageUrl,
|
||||
@Valid @RequestBody(required = false) org.ruoyi.domain.bo.shortdrama.ShortDramaLocationImageRevisionBo revision) {
|
||||
return R.ok(shortDramaService.generateLocationImage(locationId, model, referenceImageUrl,
|
||||
revision == null ? null : revision.getRevisionRequirements(), LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
@PostMapping("/location/{locationId}/regenerate")
|
||||
public R<ShortDramaLocationVo> regenerateLocationImage(@NotNull @PathVariable Long locationId,
|
||||
@NotBlank @RequestParam String model,
|
||||
@RequestParam(required = false) String referenceImageUrl) {
|
||||
return R.ok(shortDramaService.regenerateLocationImage(locationId, model, referenceImageUrl, LoginHelper.getUserId()));
|
||||
@RequestParam(required = false) String referenceImageUrl,
|
||||
@Valid @RequestBody(required = false) org.ruoyi.domain.bo.shortdrama.ShortDramaLocationImageRevisionBo revision) {
|
||||
return R.ok(shortDramaService.regenerateLocationImage(locationId, model, referenceImageUrl,
|
||||
revision == null ? null : revision.getRevisionRequirements(), LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
@PostMapping("/location/{locationId}/select-image")
|
||||
@@ -400,8 +500,12 @@ public class ShortDramaController {
|
||||
public R<MediaGenerationResponse> startImage(@NotBlank @RequestParam String assetType,
|
||||
@NotNull @RequestParam Long assetId,
|
||||
@NotBlank @RequestParam String model,
|
||||
@RequestParam(required = false) String referenceImageUrl) {
|
||||
return R.ok(shortDramaService.startImageGeneration(assetType, assetId, model, referenceImageUrl, LoginHelper.getUserId()));
|
||||
@RequestParam(required = false) String referenceImageUrl,
|
||||
@Valid @RequestBody(required = false) ShortDramaImageRevisionBo revision) {
|
||||
return R.ok(shortDramaService.startImageGeneration(assetType, assetId, model, referenceImageUrl,
|
||||
revision == null ? null : revision.getReferencePurpose(),
|
||||
revision == null ? null : revision.getRevisionRequirements(),
|
||||
revision == null ? null : revision.getStyleReferenceImageUrls(), LoginHelper.getUserId()));
|
||||
}
|
||||
|
||||
/** 轮询确认形象图片并保存 */
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ public class ShortDramaMaterialController {
|
||||
ObjectNode image = replace >= 0 ? ((ObjectNode)list.get(replace)).deepCopy() : JSON.createObjectNode();
|
||||
image.remove("items");
|
||||
image.put("url", "/short-drama/"+projectId+"/materials/file/"+name);
|
||||
image.put("filename", Objects.toString(file.getOriginalFilename(), "真实素材"));
|
||||
image.put("filename", Objects.toString(file.getOriginalFilename(), "素材"));
|
||||
image.put("type", type);
|
||||
if (replace >= 0) list.set(replace, image); else list.add(image);
|
||||
}
|
||||
@@ -155,8 +155,8 @@ public class ShortDramaMaterialController {
|
||||
owner(projectId);var shot=boards.selectById(shotId);
|
||||
if(shot==null || !projectId.equals(shot.getProjectId()) || "generating".equals(shot.getVideoStatus()))throw new IllegalArgumentException("镜头不存在或正在生成");
|
||||
var continuity=(ObjectNode)JSON.readTree(Objects.toString(shot.getContinuityJson(),"{}"));
|
||||
if(!"direct_insert".equals(continuity.path("source_media").path("mode").asText()))throw new IllegalArgumentException("仅支持真实素材镜头");
|
||||
var images=items(continuity);if(images.isEmpty())throw new IllegalArgumentException("请先上传真实素材");
|
||||
if(!"direct_insert".equals(continuity.path("source_media").path("mode").asText()))throw new IllegalArgumentException("仅支持素材镜头");
|
||||
var images=items(continuity);if(images.isEmpty())throw new IllegalArgumentException("请先上传素材");
|
||||
Path dir=directory(projectId);String job=UUID.randomUUID().toString();
|
||||
List<String> command=new ArrayList<>(List.of("ffmpeg","-v","error","-y"));List<String> filters=new ArrayList<>();StringBuilder concat=new StringBuilder();
|
||||
double duration=shot.getDurationSeconds(), part=duration/images.size();
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package org.ruoyi.controller.shortdrama;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.common.satoken.utils.LoginHelper;
|
||||
import org.ruoyi.service.shortdrama.impl.ShortDramaMusicService;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@RestController @RequiredArgsConstructor @RequestMapping("/short-drama/{projectId}/music")
|
||||
public class ShortDramaMusicController {
|
||||
private final ShortDramaMusicService service;
|
||||
@GetMapping public R<?> list(@PathVariable Long projectId) throws Exception {return R.ok(service.list(projectId,LoginHelper.getUserId()));}
|
||||
@PostMapping public R<?> generate(@PathVariable Long projectId,@RequestBody ShortDramaMusicService.MusicRequest request) throws Exception {return R.ok(service.generate(projectId,LoginHelper.getUserId(),request));}
|
||||
@GetMapping("/{id}") public R<?> poll(@PathVariable Long projectId,@PathVariable String id) throws Exception {return R.ok(service.poll(projectId,LoginHelper.getUserId(),id));}
|
||||
@PostMapping("/{id}/use") public R<?> use(@PathVariable Long projectId,@PathVariable String id,@RequestBody ShortDramaMusicService.Usage usage) throws Exception {return R.ok(service.use(projectId,LoginHelper.getUserId(),id,usage));}
|
||||
@PostMapping("/write") public R<?> write(@PathVariable Long projectId,@RequestBody ShortDramaMusicService.BriefRequest request) {return R.ok(service.write(projectId,LoginHelper.getUserId(),request));}
|
||||
}
|
||||
@@ -0,0 +1,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,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);
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,20 @@ public class ShortDramaVisualAssetController {
|
||||
private final ShortDramaVisualAssetService service;
|
||||
public record ReviseFrame(String prompt,String model) { }
|
||||
@PostMapping("/{assetId}/revise") public R<Map<String,Object>> revise(@PathVariable Long projectId,@PathVariable Long assetId,@RequestBody ReviseFrame body) {return R.ok(service.reviseFrame(projectId,assetId,body.prompt(),body.model(),LoginHelper.getUserId()));}
|
||||
public record StartFrame(String imageUrl) { }
|
||||
@PostMapping("/{storyboardId}/start-frame") public R<Map<String,Object>> saveStartFrame(@PathVariable Long projectId,@PathVariable Long storyboardId,@RequestBody StartFrame body) {return R.ok(service.saveManualStartFrame(projectId,storyboardId,body.imageUrl(),LoginHelper.getUserId()));}
|
||||
@DeleteMapping("/{storyboardId}/start-frame") public R<Map<String,Object>> removeStartFrame(@PathVariable Long projectId,@PathVariable Long storyboardId) {return R.ok(service.removeManualStartFrame(projectId,storyboardId,LoginHelper.getUserId()));}
|
||||
public record Prop(String title,String prompt,java.util.List<Integer> shotNumbers,String imageUrl) { }
|
||||
@PostMapping("/prop") public R<Map<String,Object>> prop(@PathVariable Long projectId,@RequestBody Prop body) {return R.ok(service.saveProp(projectId,body.title(),body.prompt(),body.shotNumbers(),body.imageUrl(),LoginHelper.getUserId()));}
|
||||
public record PropUpdate(String prompt,String referenceImageUrl) { }
|
||||
@PutMapping("/prop/{assetId}") public R<Map<String,Object>> updateProp(@PathVariable Long projectId,@PathVariable Long assetId,@RequestBody PropUpdate body) {return R.ok(service.updateProp(projectId,assetId,body.prompt(),body.referenceImageUrl(),LoginHelper.getUserId()));}
|
||||
@PostMapping("/prop/{assetId}/regenerate") public R<Map<String,Object>> regenerateProp(@PathVariable Long projectId,@PathVariable Long assetId,@RequestParam String model) {return R.ok(service.regenerateProp(projectId,assetId,model,LoginHelper.getUserId()));}
|
||||
@PostMapping("/prop/{assetId}/undo") public R<Map<String,Object>> undoProp(@PathVariable Long projectId,@PathVariable Long assetId) {return R.ok(service.undoProp(projectId,assetId,LoginHelper.getUserId()));}
|
||||
@PostMapping("/generate-opening") public R<Map<String,Object>> opening(@PathVariable Long projectId,@RequestParam String model) {return R.ok(service.generateOpening(projectId,model,LoginHelper.getUserId()));}
|
||||
@PostMapping("/generate-range") public R<Map<String,Object>> range(@PathVariable Long projectId,@RequestParam String model,@RequestParam int start,@RequestParam int end) {return R.ok(service.generateRange(projectId,model,LoginHelper.getUserId(),start,end));}
|
||||
@GetMapping public R<Map<String,Object>> status(@PathVariable Long projectId) {return R.ok(service.status(projectId,LoginHelper.getUserId()));}
|
||||
@PostMapping("/plan") public R<Map<String,Object>> plan(@PathVariable Long projectId,@RequestParam(required=false) String model) {return R.ok(service.plan(projectId,model,LoginHelper.getUserId()));}
|
||||
@PostMapping("/recover") public R<Map<String,Object>> recover(@PathVariable Long projectId) {return R.ok(service.recover(projectId,LoginHelper.getUserId()));}
|
||||
@PostMapping("/generate-props") public R<Map<String,Object>> missingProps(@PathVariable Long projectId,@RequestParam String model) {return R.ok(service.generateMissingProps(projectId,model,LoginHelper.getUserId()));}
|
||||
@PostMapping("/generate") public R<Map<String,Object>> generate(@PathVariable Long projectId,@RequestParam String model) {return R.ok(service.generate(projectId,model,LoginHelper.getUserId()));}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.ruoyi.controller.shortdrama;
|
||||
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.service.shortdrama.impl.ShortDramaVoiceException;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@RestControllerAdvice(assignableTypes={ShortDramaCharacterVoiceController.class,ShortDramaController.class})
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class ShortDramaVoiceErrors {
|
||||
@ExceptionHandler(ShortDramaVoiceException.class)
|
||||
public R<Void> validation(ShortDramaVoiceException error) {return R.fail(error.getMessage());}
|
||||
}
|
||||
@@ -15,9 +15,14 @@ public class ShortDramaIdeaBo {
|
||||
private String projectName;
|
||||
|
||||
private String artStyle;
|
||||
private String aestheticSkillName;
|
||||
private String directorSkillName;
|
||||
private java.util.List<String> storyboardSkillNames;
|
||||
|
||||
private String aspectRatio;
|
||||
|
||||
/** Stop after script so the author can review before asset/shot planning. */
|
||||
private Boolean scriptOnly = false;
|
||||
/** 兼容旧请求字段;创建接口始终只生成剧本,该值不再控制后续阶段。 */
|
||||
@Deprecated
|
||||
private Boolean scriptOnly = true;
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -10,5 +10,10 @@ public class ShortDramaReviewedPlanBo {
|
||||
@NotNull private Long scriptId;
|
||||
@NotBlank private String expectedScriptText;
|
||||
private String model;
|
||||
@NotNull @Min(1) @Max(15) private Integer minimumShotSeconds = 1;
|
||||
/** Absent means a full reviewed plan; present imports only explicitly reviewed scenes. */
|
||||
@Size(min=1, max=100) private List<@NotNull @Min(1) Integer> sceneNumbers;
|
||||
/** Returned by checkpoint-status. Required for partial imports, ignores temporary image URLs. */
|
||||
@Pattern(regexp="[a-f0-9]{64}") private String expectedAssetSignature;
|
||||
@NotEmpty @Size(max=500) private List<StoryboardPanelData> panels;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ public class ShortDramaRevisionBo {
|
||||
@NotBlank private String expectedScriptText;
|
||||
@NotBlank private String scriptText;
|
||||
private String outlineText;
|
||||
private String tone;
|
||||
private String worldbuilding;
|
||||
private String revisionNotes;
|
||||
@NotEmpty private List<ShortDramaStoryboardBo> storyboards;
|
||||
private List<ShortDramaCharacterBo> characters;
|
||||
private List<ShortDramaLocationBo> locations;
|
||||
|
||||
@@ -24,4 +24,12 @@ public class ShortDramaScriptBo extends BaseEntity {
|
||||
private String tone;
|
||||
|
||||
private String sourceType;
|
||||
|
||||
private String creationMode;
|
||||
|
||||
private String sourceMaterials;
|
||||
|
||||
private String worldbuilding;
|
||||
|
||||
private String revisionNotes;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
|
||||
|
||||
@@ -30,4 +30,12 @@ public class ShortDramaScript extends BaseEntity {
|
||||
private String tone;
|
||||
|
||||
private String sourceType;
|
||||
|
||||
private String creationMode;
|
||||
|
||||
private String sourceMaterials;
|
||||
|
||||
private String worldbuilding;
|
||||
|
||||
private String revisionNotes;
|
||||
}
|
||||
|
||||
@@ -24,9 +24,14 @@ public class ShortDramaProjectVo implements Serializable {
|
||||
|
||||
private String description;
|
||||
|
||||
private String originalIdea;
|
||||
|
||||
private String status;
|
||||
|
||||
private String artStyle;
|
||||
private String aestheticSkillName;
|
||||
private String directorSkillName;
|
||||
private java.util.List<String> storyboardSkillNames;
|
||||
|
||||
private Long composedVideoOssId;
|
||||
|
||||
|
||||
@@ -32,4 +32,12 @@ public class ShortDramaScriptVo implements Serializable {
|
||||
private Date createTime;
|
||||
|
||||
private Date updateTime;
|
||||
|
||||
private String creationMode;
|
||||
|
||||
private String sourceMaterials;
|
||||
|
||||
private String worldbuilding;
|
||||
|
||||
private String revisionNotes;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.ruoyi.service.audio.provider;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -20,13 +19,11 @@ import org.ruoyi.service.media.AtlasPredictionService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Atlas Cloud 音频生成(bytedance/seed-audio-1.0)。异步:提交 /model/generateAudio 返回 predictionId,
|
||||
* 轮询 /model/prediction/{id} 拿音频 URL。支持 references(speaker 音色 + 参考音频)做多角色对白配音。
|
||||
* Atlas Cloud TTS:Gemini 单音色与 Seed Audio references 使用各自的请求字段。
|
||||
* 提交返回 predictionId,轮询 /model/prediction/{id} 获取音频;Gemini 付费 POST 不自动重试。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component("atlasAudio")
|
||||
@@ -41,37 +38,17 @@ public class AtlasAudioGenerationServiceImpl extends AbstractAudioGenerationServ
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
private final OkHttpClient geminiHttpClient = okHttpClient.newBuilder()
|
||||
.retryOnConnectionFailure(false)
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.build();
|
||||
|
||||
@Override
|
||||
protected MediaGenerationResponse doGenerateSpeech(AudioContext audioContext) {
|
||||
ChatModelVo model = audioContext.getChatModelVo();
|
||||
ObjectNode payload = AtlasMediaSupport.OBJECT_MAPPER.createObjectNode();
|
||||
payload.put("model", model.getModelName());
|
||||
payload.put("text", audioContext.getInput());
|
||||
String format = StrUtil.blankToDefault(audioContext.getResponseFormat(), "mp3");
|
||||
payload.put("format", format);
|
||||
|
||||
// 参考资源:多角色音色 + 参考音频
|
||||
List<Map<String, String>> refs = audioContext.getReferences();
|
||||
if (refs != null && !refs.isEmpty()) {
|
||||
ArrayNode arr = payload.putArray("references");
|
||||
for (Map<String, String> ref : refs) {
|
||||
ObjectNode r = arr.addObject();
|
||||
if (StrUtil.isNotBlank(ref.get("speaker"))) r.put("speaker", ref.get("speaker"));
|
||||
if (StrUtil.isNotBlank(ref.get("audioUrl"))) r.put("audio_url", ref.get("audioUrl"));
|
||||
if (StrUtil.isNotBlank(ref.get("audioData"))) r.put("audio_data", ref.get("audioData"));
|
||||
if (StrUtil.isNotBlank(ref.get("imageData"))) r.put("image_data", ref.get("imageData"));
|
||||
}
|
||||
} else if (StrUtil.isNotBlank(audioContext.getVoice())) {
|
||||
// 没有显式 references 但指定了 voice 音色名:作为单一 speaker
|
||||
ArrayNode arr = payload.putArray("references");
|
||||
ObjectNode r = arr.addObject();
|
||||
r.put("speaker", audioContext.getVoice());
|
||||
}
|
||||
|
||||
if (audioContext.getSampleRate() != null) payload.put("sample_rate", audioContext.getSampleRate());
|
||||
if (audioContext.getPitchRate() != null) payload.put("pitch_rate", audioContext.getPitchRate());
|
||||
if (audioContext.getSpeechRate() != null) payload.put("speech_rate", audioContext.getSpeechRate());
|
||||
if (audioContext.getLoudnessRate() != null) payload.put("loudness_rate", audioContext.getLoudnessRate());
|
||||
boolean gemini = AtlasSpeechPayloadBuilder.isGeminiTts(model.getModelName());
|
||||
ObjectNode payload = AtlasSpeechPayloadBuilder.build(audioContext);
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/generateAudio"))
|
||||
@@ -79,15 +56,26 @@ public class AtlasAudioGenerationServiceImpl extends AbstractAudioGenerationServ
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(RequestBody.create(payload.toString(), AtlasMediaSupport.JSON))
|
||||
.build();
|
||||
try (Response response = okHttpClient.newCall(request).execute()) {
|
||||
try (Response response = (gemini ? geminiHttpClient : okHttpClient).newCall(request).execute()) {
|
||||
ResponseBody body = response.body();
|
||||
String responseText = body == null ? "" : body.string();
|
||||
if (!response.isSuccessful()) {
|
||||
throw new IllegalArgumentException("Atlas Cloud 音频生成任务创建失败: " + response.code() + " - " + responseText);
|
||||
}
|
||||
return atlasPredictionService.toResponse(responseText, "audio");
|
||||
if (!gemini) return atlasPredictionService.toResponse(responseText, "audio");
|
||||
// Public documentation also shows a root-level prediction object.
|
||||
var root = AtlasMediaSupport.OBJECT_MAPPER.readTree(responseText);
|
||||
String normalized = root != null && root.isObject() && !root.has("data") && root.has("id")
|
||||
? AtlasMediaSupport.OBJECT_MAPPER.createObjectNode().set("data", root).toString() : responseText;
|
||||
MediaGenerationResponse result = atlasPredictionService.toResponse(normalized, "audio");
|
||||
if (result == null || (StrUtil.isBlank(result.getId()) && StrUtil.isBlank(result.getUrl()))) {
|
||||
throw new IllegalStateException("Atlas 音频提交结果未知:未返回任务ID或音频URL,请先核对上游任务,禁止重复提交");
|
||||
}
|
||||
result.setRawResponse(responseText);
|
||||
return result;
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("Atlas Cloud 音频生成任务创建失败: " + e.getMessage(), e);
|
||||
if (!gemini) throw new RuntimeException("Atlas Cloud 音频生成任务创建失败: " + e.getMessage(), e);
|
||||
throw new RuntimeException("Atlas 音频提交结果未知,请先核对上游任务,禁止重复提交: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user