mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-11-11 21:40:41 +00:00
fix(short-drama): enforce Atlas isolation and restore missing catalog routes
This commit is contained in:
@@ -13,6 +13,7 @@ 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)); }
|
||||
|
||||
@@ -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,53 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.common.chat.service.chat.IChatModelService;
|
||||
import java.net.URI;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/** Resolve short-drama models inside Atlas, never by a name shared across providers. */
|
||||
public final class ShortDramaAtlasModels {
|
||||
private ShortDramaAtlasModels() {}
|
||||
|
||||
public static ChatModelVo require(IChatModelService models, String name, String... categories) {
|
||||
if (name == null || name.isBlank()) throw new IllegalArgumentException("请选择已配置的 Atlas " + String.join("/", categories) + " 模型");
|
||||
return resolve(models, name, categories);
|
||||
}
|
||||
|
||||
public static ChatModelVo defaultModel(IChatModelService models, String category) {
|
||||
return resolve(models, null, category);
|
||||
}
|
||||
|
||||
private static ChatModelVo resolve(IChatModelService models, String name, String... categories) {
|
||||
var allowed = Set.of(categories);
|
||||
var query = new ChatModelBo();
|
||||
query.setProviderCode("atlas"); query.setCategory(categories.length == 1 ? categories[0] : null); query.setModelName(name);
|
||||
var matches = models.queryAvailableList(query).stream()
|
||||
.filter(model -> "atlas".equalsIgnoreCase(model.getProviderCode()) && model.getCategory() != null && allowed.contains(model.getCategory())
|
||||
&& (name == null || Objects.equals(name, model.getModelName())))
|
||||
.toList();
|
||||
if (matches.isEmpty()) throw new IllegalArgumentException("短剧仅支持已启用的 Atlas " + String.join("/", categories) + " 模型,请检查后台配置:" + Objects.toString(name, "默认模型"));
|
||||
var selected = matches.get(0);
|
||||
if (matches.stream().filter(model -> Objects.equals(selected.getModelName(), model.getModelName())).count() > 1)
|
||||
throw new IllegalArgumentException("Atlas 同类模型存在重复配置,请保留唯一配置:" + selected.getModelName());
|
||||
requireProvider(selected);
|
||||
return selected;
|
||||
}
|
||||
|
||||
public static void requireProvider(ChatModelVo model) {
|
||||
if (model == null || !"atlas".equalsIgnoreCase(model.getProviderCode()))
|
||||
throw new IllegalArgumentException("短剧仅支持 Atlas 模型");
|
||||
try {
|
||||
var uri = URI.create(Objects.toString(model.getApiHost(), ""));
|
||||
if (!"https".equalsIgnoreCase(uri.getScheme()) || !"api.atlascloud.ai".equalsIgnoreCase(uri.getHost())
|
||||
|| (uri.getPort() != -1 && uri.getPort() != 443) || uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null)
|
||||
throw new IllegalArgumentException("短剧 Atlas 模型接口地址必须使用 https://api.atlascloud.ai");
|
||||
} catch (IllegalArgumentException error) {
|
||||
throw new IllegalArgumentException("短剧 Atlas 模型接口地址必须使用 https://api.atlascloud.ai");
|
||||
}
|
||||
if (model.getApiKey() == null || model.getApiKey().isBlank())
|
||||
throw new IllegalArgumentException("Atlas 模型 API Key 未配置,请在后台填写后重试");
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ public class ShortDramaCharacterVoiceService {
|
||||
write(profileFile(project,character),updated);return updated;
|
||||
}
|
||||
private ChatModelVo model(String name) {
|
||||
var model=models.selectModelByName(name);
|
||||
var model=ShortDramaAtlasModels.require(models,name,"audio");
|
||||
if (model==null || !MODEL.equals(name) || !"audio".equals(model.getCategory()) || !"atlas".equalsIgnoreCase(model.getProviderCode()))
|
||||
throw new ShortDramaVoiceException("请在模型配置中启用 Atlas Seed Audio 1.0");
|
||||
return model;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import dev.langchain4j.exception.AuthenticationException;
|
||||
|
||||
final class ShortDramaModelErrors {
|
||||
private ShortDramaModelErrors() {}
|
||||
static String message(Throwable failure) {
|
||||
for (Throwable current = failure; current != null; current = current.getCause()) {
|
||||
if (current instanceof AuthenticationException)
|
||||
return "Atlas 模型鉴权失败,请检查后台该模型的 API Key、接口地址和账号权限";
|
||||
}
|
||||
return failure.getMessage();
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,7 @@ public class ShortDramaMusicService {
|
||||
try(var files=Files.list(directory(project))){List<Job> jobs=new ArrayList<>();for(Path path:files.filter(p->p.toString().endsWith(".json")).toList()){var job=AtlasMediaSupport.OBJECT_MAPPER.readValue(path.toFile(),Job.class);if("submitting".equals(job.status()) && System.currentTimeMillis()-job.createdAt()>210000)job=new Job(job.id(),job.predictionId(),job.request(),"submission_unknown","提交尚未取得任务编号,请先核对 Atlas 记录;未自动重试",job.variants(),job.createdAt());jobs.add(job);}jobs.sort(Comparator.comparingLong(Job::createdAt).reversed());return jobs;}
|
||||
}
|
||||
private ChatModelVo musicModel(String name) {
|
||||
var model=models.selectModelByName(name);
|
||||
var model=ShortDramaAtlasModels.require(models,name,"audio");
|
||||
if(model==null || !"audio".equals(model.getCategory()) || !"atlas".equalsIgnoreCase(model.getProviderCode()) || !"suno/chirp-v6".equals(name))throw new IllegalArgumentException("请选择已配置的 Atlas Suno Chirp V6 音乐模型");
|
||||
return model;
|
||||
}
|
||||
@@ -101,7 +101,7 @@ public class ShortDramaMusicService {
|
||||
return selected;
|
||||
}
|
||||
public Brief write(Long project,Long user,BriefRequest request) {
|
||||
sounds.owner(project,user);var model=models.selectModelByName(request.model());if(model==null || !"chat".equals(model.getCategory()))throw new IllegalArgumentException("请选择写作模型");
|
||||
sounds.owner(project,user);var model=ShortDramaAtlasModels.require(models,request.model(),"chat");if(model==null || !"chat".equals(model.getCategory()))throw new IllegalArgumentException("请选择写作模型");
|
||||
if(!Set.of("bgm","theme","ending").contains(Objects.toString(request.purpose(),"")) || request.direction()!=null && request.direction().length()>2000)throw new IllegalArgumentException("音乐用途或创作要求无效");
|
||||
var script=scripts.selectOne(new LambdaQueryWrapper<ShortDramaScript>().eq(ShortDramaScript::getProjectId,project).orderByDesc(ShortDramaScript::getId).last("limit 1"));if(script==null)throw new IllegalArgumentException("请先保存剧本");
|
||||
String context=Objects.toString(script.getScriptText(),"");if(context.length()>16000)context=context.substring(0,16000);
|
||||
|
||||
@@ -1075,7 +1075,7 @@ public class ShortDramaServiceImpl implements IShortDramaService {
|
||||
if (ShortDramaVisualAssetService.directInsert(storyboard.getContinuityJson())) throw new IllegalStateException("本镜使用素材,不能通过AI视频模型重绘;请在剪辑阶段使用已绑定原图");
|
||||
if ("script_changed".equals(project.getStatus())) throw new IllegalStateException("剧本已修改,请重新分析资产和生成分镜后再生成视频");
|
||||
if (storyboardGenerationStates.containsKey(storyboard.getScriptId())) throw new IllegalStateException("分镜正在更新,请完成审阅后再生成视频");
|
||||
ChatModelVo modelVo = chatModelService.selectModelByName(videoModel);
|
||||
ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, videoModel, "video");
|
||||
if (modelVo == null) throw new IllegalArgumentException("未找到视频模型配置: " + videoModel);
|
||||
|
||||
Integer videoSeconds = ShortDramaVideoDuration.seconds(storyboard.getContinuityJson());
|
||||
@@ -1095,7 +1095,7 @@ public class ShortDramaServiceImpl implements IShortDramaService {
|
||||
.replace("/reference-to-video", "/image-to-video");
|
||||
}
|
||||
if (!targetModel.equals(videoModel)) {
|
||||
ChatModelVo switched = chatModelService.selectModelByName(targetModel);
|
||||
ChatModelVo switched = ShortDramaAtlasModels.require(chatModelService, targetModel, "video");
|
||||
if (switched != null) {
|
||||
modelVo = switched;
|
||||
log.info("参考图({}张) → 切换模型: {}", referenceImages == null ? 0 : referenceImages.size(), targetModel);
|
||||
@@ -1715,8 +1715,10 @@ public class ShortDramaServiceImpl implements IShortDramaService {
|
||||
return MapstructUtils.convert(storyboard, ShortDramaStoryboardVo.class);
|
||||
}
|
||||
// Never reconstruct the queried provider/model from a client dropdown after a switch.
|
||||
if (receipt != null && !"atlas".equalsIgnoreCase(receipt.providerCode()))
|
||||
throw new IllegalArgumentException("短剧仅支持查询 Atlas 视频任务;原有视频文件保留");
|
||||
String actualModel = receipt != null ? receipt.actualModel() : videoModel;
|
||||
ChatModelVo modelVo = chatModelService.selectModelByName(actualModel);
|
||||
ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, actualModel, "video");
|
||||
if (modelVo == null) {
|
||||
throw new IllegalArgumentException("未找到原视频任务模型配置: " + actualModel);
|
||||
}
|
||||
@@ -2139,7 +2141,7 @@ public class ShortDramaServiceImpl implements IShortDramaService {
|
||||
ShortDramaCharacter character = characterMapper.selectById(characterId);
|
||||
if (character == null) throw new IllegalArgumentException("角色不存在");
|
||||
validateProjectOwner(character.getProjectId(), userId);
|
||||
ChatModelVo modelVo = chatModelService.selectModelByName(imageModel);
|
||||
ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, imageModel, "image");
|
||||
if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel);
|
||||
String basePrompt = firstNotBlank(character.getVisualDescription(), character.getName());
|
||||
String finalPrompt = buildCharacterPrompt(basePrompt, character.getProjectId());
|
||||
@@ -2172,7 +2174,7 @@ public class ShortDramaServiceImpl implements IShortDramaService {
|
||||
ShortDramaLocation location = locationMapper.selectById(locationId);
|
||||
if (location == null) throw new IllegalArgumentException("场景不存在");
|
||||
validateProjectOwner(location.getProjectId(), userId);
|
||||
ChatModelVo modelVo = chatModelService.selectModelByName(imageModel);
|
||||
ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, imageModel, "image");
|
||||
if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel);
|
||||
String prompt = firstNotBlank(primaryLocationDescription(location), location.getSummary(), location.getName());
|
||||
String finalPrompt = buildLocationPrompt(location.getName(), prompt, location.getProjectId(), revisionRequirements);
|
||||
@@ -2258,7 +2260,7 @@ public class ShortDramaServiceImpl implements IShortDramaService {
|
||||
ShortDramaCharacter character = characterMapper.selectById(appearance.getCharacterId());
|
||||
if (character == null) throw new IllegalArgumentException("角色不存在");
|
||||
validateProjectOwner(character.getProjectId(), userId);
|
||||
ChatModelVo modelVo = chatModelService.selectModelByName(imageModel);
|
||||
ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, imageModel, "image");
|
||||
if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel);
|
||||
String basePrompt = firstNotBlank(appearance.getDescription(), character.getVisualDescription(), character.getName());
|
||||
String finalPrompt = buildCharacterPrompt(basePrompt, character.getProjectId());
|
||||
@@ -2292,7 +2294,7 @@ public class ShortDramaServiceImpl implements IShortDramaService {
|
||||
ShortDramaCharacter character = characterMapper.selectById(appearance.getCharacterId());
|
||||
if (character == null) throw new IllegalArgumentException("角色不存在");
|
||||
validateProjectOwner(character.getProjectId(), userId);
|
||||
ChatModelVo modelVo = chatModelService.selectModelByName(imageModel);
|
||||
ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, imageModel, "image");
|
||||
if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + imageModel);
|
||||
String basePrompt = firstNotBlank(appearance.getDescription(), character.getVisualDescription(), character.getName());
|
||||
String finalPrompt = buildCharacterPrompt(basePrompt, character.getProjectId());
|
||||
@@ -2457,7 +2459,7 @@ public class ShortDramaServiceImpl implements IShortDramaService {
|
||||
|| "image/webp".equalsIgnoreCase(contentType))) {
|
||||
throw new IllegalArgumentException("参考图仅支持JPEG、PNG或WebP");
|
||||
}
|
||||
ChatModelVo modelVo = chatModelService.selectModelByName(model);
|
||||
ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, model, "image", "video");
|
||||
if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + model);
|
||||
try {
|
||||
return imageServiceFactory.getOriginalService(modelVo.getProviderCode())
|
||||
@@ -2493,7 +2495,7 @@ public class ShortDramaServiceImpl implements IShortDramaService {
|
||||
if (revision && !locationAsset && (!"appearance".equals(assetType) || StrUtil.isBlank(referenceImage))) {
|
||||
throw new IllegalArgumentException("身份修订仅用于角色形象,且必须提供身份参考图");
|
||||
}
|
||||
ChatModelVo modelVo = chatModelService.selectModelByName(model);
|
||||
ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, model, "image");
|
||||
if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + model);
|
||||
|
||||
String prompt;
|
||||
@@ -2559,7 +2561,7 @@ public class ShortDramaServiceImpl implements IShortDramaService {
|
||||
if (character == null) throw new IllegalArgumentException("角色不存在");
|
||||
validateProjectOwner(character.getProjectId(), userId);
|
||||
|
||||
ChatModelVo modelVo = chatModelService.selectModelByName(model);
|
||||
ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, model, "image");
|
||||
if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + model);
|
||||
|
||||
MediaGenerationResponse result = atlasPredictionService.retrieve(modelVo, predictionId);
|
||||
@@ -2598,7 +2600,7 @@ public class ShortDramaServiceImpl implements IShortDramaService {
|
||||
if (location == null) throw new IllegalArgumentException("场景不存在");
|
||||
validateProjectOwner(location.getProjectId(), userId);
|
||||
|
||||
ChatModelVo modelVo = chatModelService.selectModelByName(model);
|
||||
ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, model, "image");
|
||||
if (modelVo == null) throw new IllegalArgumentException("未找到图片模型配置: " + model);
|
||||
|
||||
MediaGenerationResponse result = atlasPredictionService.retrieve(modelVo, predictionId);
|
||||
@@ -3040,7 +3042,7 @@ public class ShortDramaServiceImpl implements IShortDramaService {
|
||||
try {
|
||||
streamDone.join();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("剧本生成失败:" + e.getMessage(), e);
|
||||
throw new RuntimeException("剧本生成失败:" + ShortDramaModelErrors.message(e), e);
|
||||
}
|
||||
return requireFixedScript(result[0]);
|
||||
}
|
||||
@@ -4712,7 +4714,7 @@ public class ShortDramaServiceImpl implements IShortDramaService {
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
private ChatModelVo validateAndGetModel(String modelName) {
|
||||
ChatModelVo modelVo = chatModelService.selectModelByName(modelName);
|
||||
ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, modelName, "chat");
|
||||
if (modelVo == null) {
|
||||
throw new IllegalArgumentException("未找到模型配置: " + modelName);
|
||||
}
|
||||
@@ -4720,19 +4722,16 @@ public class ShortDramaServiceImpl implements IShortDramaService {
|
||||
}
|
||||
|
||||
private ChatModelVo findChatModel() {
|
||||
org.ruoyi.common.chat.domain.bo.chat.ChatModelBo query = new org.ruoyi.common.chat.domain.bo.chat.ChatModelBo();
|
||||
query.setCategory("chat");
|
||||
query.setProviderCode("atlas");
|
||||
List<ChatModelVo> models = chatModelService.queryAvailableList(query);
|
||||
if (models != null && !models.isEmpty()) return models.get(0);
|
||||
throw new IllegalArgumentException("无可用聊天模型");
|
||||
return ShortDramaAtlasModels.defaultModel(chatModelService, "chat");
|
||||
}
|
||||
|
||||
private AbstractChatService getChatService(ChatModelVo modelVo) {
|
||||
ShortDramaAtlasModels.requireProvider(modelVo);
|
||||
return chatServiceFactory.getOriginalService(shortDramaProviderCode(modelVo.getProviderCode(), modelVo.getModelName()));
|
||||
}
|
||||
|
||||
public static String shortDramaProviderCode(String provider, String model) {
|
||||
if (!"atlas".equalsIgnoreCase(provider)) throw new IllegalArgumentException("短剧仅支持 Atlas 模型");
|
||||
return (org.ruoyi.enums.ChatModeType.OPEN_AI.getCode().equalsIgnoreCase(provider)
|
||||
|| org.ruoyi.enums.ChatModeType.ATLAS.getCode().equalsIgnoreCase(provider))
|
||||
&& StrUtil.containsIgnoreCase(model, "deepseek")
|
||||
@@ -4781,7 +4780,7 @@ public class ShortDramaServiceImpl implements IShortDramaService {
|
||||
if (audio == null) throw new IllegalArgumentException("语音资产不存在");
|
||||
validateProjectOwner(audio.getProjectId(), userId);
|
||||
if (StrUtil.isBlank(audio.getText())) throw new IllegalArgumentException("语音文案不能为空");
|
||||
ChatModelVo modelVo = chatModelService.selectModelByName(audioModel);
|
||||
ChatModelVo modelVo = ShortDramaAtlasModels.require(chatModelService, audioModel, "audio");
|
||||
if (modelVo == null) throw new IllegalArgumentException("未找到语音模型配置: " + audioModel);
|
||||
if (audioModel.startsWith("suno/")) throw new IllegalArgumentException("Suno 是音乐模型,请使用音乐创作面板;对白配音需选择语音模型");
|
||||
if (!org.ruoyi.enums.ModelType.AUDIO.getKey().equals(modelVo.getCategory())) {
|
||||
|
||||
@@ -16,7 +16,16 @@ import java.util.*;
|
||||
@Service
|
||||
public class ShortDramaSkillCatalog {
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final Set<String> TYPES = Set.of("aesthetic", "director");
|
||||
public record Category(String type, String title, boolean editable, boolean projectSelectable) {}
|
||||
private static final List<Category> CATEGORIES = List.of(
|
||||
new Category("system", "系统提示词", false, false),
|
||||
new Category("production", "默认制作技能", false, false),
|
||||
new Category("screenwriting", "编剧风格", true, true),
|
||||
new Category("director", "导演风格", true, true),
|
||||
new Category("aesthetic", "视觉风格", true, true));
|
||||
private static final Set<String> TYPES = CATEGORIES.stream().filter(Category::editable)
|
||||
.map(Category::type).collect(java.util.stream.Collectors.toUnmodifiableSet());
|
||||
public List<Category> categories() { return CATEGORIES; }
|
||||
private static final Set<String> STYLES = org.ruoyi.constant.ShortDramaImageConstants.ART_STYLE_PROMPTS.keySet();
|
||||
private static final int MAX_TEXT = 200_000, MAX_FILES = 32, MAX_TOTAL = 1_000_000, MAX_MEDIA_BODY = 2_000;
|
||||
private final Path root;
|
||||
|
||||
@@ -84,6 +84,7 @@ public class ShortDramaSoundService {
|
||||
catch(Exception e){throw new IllegalStateException("参考音频读取失败:"+e.getMessage(),e);}
|
||||
}
|
||||
public synchronized List<String> uploadReferences(List<Reference> references,org.ruoyi.common.chat.domain.vo.chat.ChatModelVo model) {
|
||||
ShortDramaAtlasModels.requireProvider(model);
|
||||
try {
|
||||
ShortDramaVoiceContract.validateReferences(model.getModelName(),references.stream().map(Reference::duration).toList());
|
||||
List<String> result=new ArrayList<>();
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.common.chat.factory.ImageServiceFactory;
|
||||
import org.ruoyi.mapper.shortdrama.ShortDramaProjectMapper;
|
||||
import org.ruoyi.service.media.AtlasMediaSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.nio.file.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Immutable, project-owned motion references. Uploading a clip does not generate media. */
|
||||
@Service @RequiredArgsConstructor
|
||||
public class ShortDramaVideoReferenceService {
|
||||
private final ShortDramaProjectMapper projects;
|
||||
private final ImageServiceFactory images;
|
||||
public record Clip(String id, String name, double duration, int width, int height, double fps, long bytes, String sha256) {}
|
||||
|
||||
private Path directory(Long project) { return Path.of("data", "short-drama-motion", project.toString()).toAbsolutePath(); }
|
||||
public void owner(Long project, Long user) {
|
||||
var entity = projects.selectById(project);
|
||||
if (entity == null || !Objects.equals(entity.getUserId(), user)) throw new IllegalArgumentException("项目不存在或无权限");
|
||||
}
|
||||
static String checkedId(String id) {
|
||||
if (id == null || !id.matches("[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}")) throw new IllegalArgumentException("动作参考ID无效");
|
||||
return id;
|
||||
}
|
||||
public Path file(Long project, String id) { return directory(project).resolve(checkedId(id) + ".mp4"); }
|
||||
public List<Clip> list(Long project, Long user) throws Exception {
|
||||
owner(project, user);
|
||||
if (!Files.isDirectory(directory(project))) return List.of();
|
||||
try (var files = Files.list(directory(project))) {
|
||||
var result = new ArrayList<Clip>();
|
||||
for (var path : files.filter(p -> p.toString().endsWith(".json")).sorted().toList())
|
||||
result.add(AtlasMediaSupport.OBJECT_MAPPER.readValue(path.toFile(), Clip.class));
|
||||
return result;
|
||||
}
|
||||
}
|
||||
public synchronized Clip upload(Long project, Long user, MultipartFile input) throws Exception {
|
||||
owner(project, user);
|
||||
if (input.isEmpty() || input.getSize() > 30L * 1024 * 1024) throw new IllegalArgumentException("动作参考视频应小于30MB");
|
||||
String name = Objects.toString(input.getOriginalFilename(), "动作参考.mp4");
|
||||
if (!name.toLowerCase(Locale.ROOT).matches(".*\\.(mp4|mov)$")) throw new IllegalArgumentException("请选择MP4或MOV视频");
|
||||
Files.createDirectories(directory(project));
|
||||
String id = UUID.randomUUID().toString();
|
||||
Path target = file(project, id), probe = directory(project).resolve(id + ".probe");
|
||||
boolean saved = false;
|
||||
try {
|
||||
input.transferTo(target);
|
||||
var process = new ProcessBuilder("ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
|
||||
"format=duration:stream=codec_name,width,height,avg_frame_rate", "-of", "json", target.toString())
|
||||
.redirectErrorStream(true).redirectOutput(probe.toFile()).start();
|
||||
if (!process.waitFor(25, TimeUnit.SECONDS)) { process.destroyForcibly(); throw new IllegalStateException("视频检查超时"); }
|
||||
if (process.exitValue() != 0) throw new IllegalArgumentException("无法读取动作视频");
|
||||
JsonNode data = AtlasMediaSupport.OBJECT_MAPPER.readTree(Files.readString(probe));
|
||||
var stream = data.path("streams").path(0);
|
||||
String[] ratio = stream.path("avg_frame_rate").asText("0/1").split("/");
|
||||
double fps = Double.parseDouble(ratio[0]) / (ratio.length == 2 ? Double.parseDouble(ratio[1]) : 1);
|
||||
var clip = new Clip(id, name.substring(0, Math.min(120, name.length())), data.path("format").path("duration").asDouble(),
|
||||
stream.path("width").asInt(), stream.path("height").asInt(), fps, Files.size(target),
|
||||
cn.hutool.crypto.digest.DigestUtil.sha256Hex(target.toFile()));
|
||||
validateClip(clip, stream.path("codec_name").asText());
|
||||
AtlasMediaSupport.OBJECT_MAPPER.writeValue(directory(project).resolve(id + ".json").toFile(), clip);
|
||||
saved = true; return clip;
|
||||
} finally {
|
||||
Files.deleteIfExists(probe);
|
||||
if (!saved) Files.deleteIfExists(target);
|
||||
}
|
||||
}
|
||||
static void validateClip(Clip clip, String codec) {
|
||||
double aspect = (double)clip.width() / clip.height();
|
||||
long pixels = (long)clip.width() * clip.height();
|
||||
if (!Set.of("h264", "hevc").contains(codec) || !Double.isFinite(clip.duration()) || clip.duration() < 2 || clip.duration() > 30.05
|
||||
|| !Double.isFinite(clip.fps()) || clip.fps() < 23.9 || clip.fps() > 60.1 || clip.width() < 300 || clip.height() < 300
|
||||
|| clip.width() > 6000 || clip.height() > 6000 || aspect < .4 || aspect > 2.5 || pixels < 409600 || pixels > 8295044)
|
||||
throw new IllegalArgumentException("动作参考须为2–30秒、24–60fps的H.264/H.265视频,画幅0.4–2.5且不低于480p");
|
||||
}
|
||||
public List<Clip> selected(Long project, Long user, String continuity) {
|
||||
owner(project, user);
|
||||
try {
|
||||
var node = AtlasMediaSupport.OBJECT_MAPPER.readTree(continuity == null || continuity.isBlank() ? "{}" : continuity).path("video_reference_ids");
|
||||
if (node.isMissingNode()) return List.of();
|
||||
if (!node.isArray() || node.size() > 10) throw new IllegalArgumentException("动作参考应为最多10个视频ID");
|
||||
var result = new ArrayList<Clip>(); var used = new HashSet<String>();
|
||||
for (var item : node) {
|
||||
if (!item.isTextual() || !used.add(checkedId(item.asText()))) throw new IllegalArgumentException("动作参考ID重复或无效");
|
||||
var path = directory(project).resolve(item.asText() + ".json");
|
||||
if (!Files.isRegularFile(path) || !Files.isRegularFile(file(project, item.asText()))) throw new IllegalArgumentException("动作参考不存在或不属于当前项目");
|
||||
var clip = AtlasMediaSupport.OBJECT_MAPPER.readValue(path.toFile(), Clip.class);
|
||||
if (!clip.id().equals(item.asText())) throw new IllegalArgumentException("动作参考记录不匹配");
|
||||
result.add(clip);
|
||||
}
|
||||
return result;
|
||||
} catch (java.io.IOException error) { throw new IllegalArgumentException("动作参考记录无法读取", error); }
|
||||
}
|
||||
public static void validateModel(String model, List<Clip> clips) {
|
||||
if (clips.isEmpty()) return;
|
||||
boolean mini = model.startsWith("bytedance/seedance-2.0"), full = model.startsWith("bytedance/seedance-2.5/");
|
||||
if ((!mini && !full) || !model.endsWith("/reference-to-video")) throw new IllegalArgumentException("当前模型不支持动作视频参考");
|
||||
double sum = clips.stream().mapToDouble(Clip::duration).sum();
|
||||
if (clips.size() > (mini ? 3 : 10) || sum > (mini ? 15.05 : 30.05)) throw new IllegalArgumentException(mini ? "Seedance 2.0最多3条视频,合计不超过15秒" : "Seedance 2.5最多10条视频,合计不超过30秒");
|
||||
for (var clip : clips) if (clip.bytes() > (mini ? 50L : 200L) * 1024 * 1024 || mini && (clip.duration() > 15.05 || (long)clip.width()*clip.height() > 2086876))
|
||||
throw new IllegalArgumentException("动作视频超过当前模型的时长、文件或分辨率限制");
|
||||
}
|
||||
public List<String> transport(Long project, ChatModelVo model, List<Clip> clips) {
|
||||
ShortDramaAtlasModels.requireProvider(model);
|
||||
validateModel(model.getModelName(), clips);
|
||||
var result = new ArrayList<String>();
|
||||
try {
|
||||
for (var clip : clips) {
|
||||
var path = file(project, clip.id());
|
||||
if (!cn.hutool.crypto.digest.DigestUtil.sha256Hex(path.toFile()).equals(clip.sha256())) throw new IllegalArgumentException("动作参考文件已变更,请重新上传");
|
||||
result.add(images.getOriginalService(model.getProviderCode()).uploadMedia(model, Files.readAllBytes(path), clip.id()+".mp4", "video/mp4"));
|
||||
}
|
||||
return result;
|
||||
} catch (java.io.IOException error) { throw new IllegalStateException("动作参考无法上传", error); }
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ public class ShortDramaVisualAssetService {
|
||||
owner(id,user);
|
||||
var script=scripts.selectById(scriptId);
|
||||
if(script==null || !id.equals(script.getProjectId())) throw new IllegalArgumentException("剧本不属于当前项目");
|
||||
var configured=models.selectModelByName(model);
|
||||
var configured=ShortDramaAtlasModels.require(models,model,"chat");
|
||||
if(configured==null || !"chat".equals(configured.getCategory())) throw new IllegalArgumentException("后台尚未配置可用写作模型");
|
||||
var project=projects.selectById(id);
|
||||
var snapshot=skillCatalog.snapshot(project);
|
||||
@@ -175,8 +175,7 @@ public class ShortDramaVisualAssetService {
|
||||
var project = projects.selectById(id);
|
||||
String selectedDirection = skillCatalog.selected(project, "aesthetic") + skillCatalog.selected(project, "director");
|
||||
String selectedMediaDirection = skillCatalog.selectedVisual(project, "aesthetic") + skillCatalog.selectedVisual(project, "director");
|
||||
var m=models.selectModelByName(model);
|
||||
if(m==null) { var q=new ChatModelBo();q.setCategory("chat");m=models.queryAvailableList(q).stream().findFirst().orElseThrow(); }
|
||||
var m=model==null || model.isBlank() ? ShortDramaAtlasModels.defaultModel(models,"chat") : ShortDramaAtlasModels.require(models,model,"chat");
|
||||
var chat=chats.getOriginalService(ShortDramaServiceImpl.shortDramaProviderCode(m.getProviderCode(),m.getModelName())).buildChatModel(m);
|
||||
// Do not invent extra characters or costumes merely to increase image counts.
|
||||
String prompt=PROP_EXTRACTION_PROMPT+script.getScriptText()+"\n镜头列表:\n"+
|
||||
@@ -492,7 +491,7 @@ public class ShortDramaVisualAssetService {
|
||||
var asset = assets.selectById(assetId);
|
||||
if(asset==null || !projectId.equals(asset.getProjectId()) || !"prop".equals(asset.getKind())) throw new IllegalArgumentException("只能重新生成当前项目的道具");
|
||||
if(asset.getPrompt()==null || asset.getPrompt().isBlank()) throw new IllegalArgumentException("请先填写道具提示词");
|
||||
var configured=models.selectModelByName(model);
|
||||
var configured=ShortDramaAtlasModels.require(models,model,"image");
|
||||
if(configured==null || !"image".equals(configured.getCategory())) throw new IllegalArgumentException("请选择已配置的生图模型");
|
||||
if(!propReferenceImage(asset.getReferenceImages()).isBlank() && "bytedance/seedream-v4.7/text-to-image".equals(model))
|
||||
throw new IllegalArgumentException("当前 Seedream 4.7 文生图不支持参考图;请在创意设定中改用可编辑的图片模型后重试");
|
||||
@@ -558,7 +557,7 @@ public class ShortDramaVisualAssetService {
|
||||
/** Generate missing project props only; never generate storyboard frames from the asset button. */
|
||||
public Map<String,Object> generateMissingProps(Long id, String model, Long user) {
|
||||
owner(id,user);
|
||||
var configured=models.selectModelByName(model);
|
||||
var configured=ShortDramaAtlasModels.require(models,model,"image");
|
||||
if(configured==null || !"image".equals(configured.getCategory())) throw new IllegalArgumentException("后台尚未配置可用图片模型");
|
||||
if(running.putIfAbsent(id,"生成缺图道具")!=null) throw new IllegalStateException("资产任务正在运行,请等待完成");
|
||||
errors.remove(id);
|
||||
@@ -579,7 +578,7 @@ public class ShortDramaVisualAssetService {
|
||||
public Map<String,Object> generateRange(Long id,String model,Long user,int start,int end) {
|
||||
if(start<1 || end<start || end-start>=40)throw new IllegalArgumentException("每批请选择连续1至40镜");
|
||||
requireCurrentScript(id,user);
|
||||
var configured=models.selectModelByName(model);
|
||||
var configured=ShortDramaAtlasModels.require(models,model,"image");
|
||||
if(configured==null || !"image".equals(configured.getCategory()))throw new IllegalArgumentException("请选择生图模型");
|
||||
var selectedShots=boards.selectList(new LambdaQueryWrapper<ShortDramaStoryboard>().eq(ShortDramaStoryboard::getProjectId,id).ge(ShortDramaStoryboard::getSceneNo,start).le(ShortDramaStoryboard::getSceneNo,end));
|
||||
var selected=selectedShots.stream().map(ShortDramaStoryboard::getId).collect(java.util.stream.Collectors.toSet());
|
||||
@@ -628,7 +627,7 @@ public class ShortDramaVisualAssetService {
|
||||
if (boards.selectCount(new LambdaQueryWrapper<ShortDramaStoryboard>().eq(ShortDramaStoryboard::getProjectId,id)) > 0)
|
||||
requireCurrentScript(id,user);
|
||||
synchronizeExistingFrames(id);
|
||||
var configured=models.selectModelByName(model);
|
||||
var configured=ShortDramaAtlasModels.require(models,model,"image");
|
||||
if(configured==null || !"image".equals(configured.getCategory()))throw new IllegalArgumentException("请选择已配置的生图模型");
|
||||
if(rows(id).isEmpty())throw new IllegalStateException("请先分析镜头资产");
|
||||
if(running.putIfAbsent(id,"生成关键道具")!=null)throw new IllegalStateException("资产任务正在运行");
|
||||
@@ -649,7 +648,7 @@ public class ShortDramaVisualAssetService {
|
||||
var asset=assets.selectById(assetId);
|
||||
if(asset==null || !id.equals(asset.getProjectId()) || !"shot_frame".equals(asset.getKind())) throw new IllegalArgumentException("只能修正本项目的镜头关键帧");
|
||||
if(prompt==null || prompt.isBlank() || prompt.length()>12000) throw new IllegalArgumentException("请填写有效的画面描述(最多12000字)");
|
||||
var configured=models.selectModelByName(model);
|
||||
var configured=ShortDramaAtlasModels.require(models,model,"image");
|
||||
if(configured==null || !"image".equals(configured.getCategory()))throw new IllegalArgumentException("请选择生图模型");
|
||||
if(running.putIfAbsent(id,"修正单镜构图")!=null)throw new IllegalStateException("请等待当前资产队列完成后修正单图");
|
||||
try {
|
||||
@@ -702,7 +701,7 @@ public class ShortDramaVisualAssetService {
|
||||
try {
|
||||
boolean resume=a.getPredictionId()!=null && !a.getPredictionId().isBlank() && !"failed".equals(a.getStatus());
|
||||
if (resume) {
|
||||
var original = models.selectModelByName(a.getModel());
|
||||
var original = ShortDramaAtlasModels.require(models,a.getModel(),"image");
|
||||
if (original == null) throw new IllegalStateException("原任务模型配置缺失,无法查询原prediction;没有提交新任务");
|
||||
awaitImage(a, original); return;
|
||||
}
|
||||
@@ -752,7 +751,7 @@ public class ShortDramaVisualAssetService {
|
||||
throw new IllegalArgumentException("所选Seedream 4.7文生图不接参考图;此首帧需另行选择编辑型号,不会自动转换或丢弃已批准身份参考");
|
||||
if(!refs.isEmpty() && model.endsWith("/text-to-image"))selected=model.replace("/text-to-image","/edit");
|
||||
if(refs.isEmpty() && model.endsWith("/edit"))selected=model.replace("/edit","/text-to-image");
|
||||
var m=models.selectModelByName(selected);if(m==null)throw new IllegalStateException("缺少对应多参考图模型配置:"+selected);
|
||||
var m=ShortDramaAtlasModels.require(models,selected,"image");if(m==null)throw new IllegalStateException("缺少对应多参考图模型配置:"+selected);
|
||||
// Resume acknowledged jobs; never submit the same prediction twice.
|
||||
a.setModel(m.getModelName());a.setErrorMessage("");a.setStatus("generating");a.setUpdateTime(new Date());assets.updateById(a);
|
||||
if(!resume) {
|
||||
|
||||
@@ -89,7 +89,7 @@ class ShortDramaAssetAnalysisFailureTest {
|
||||
when(scripts.selectById(20L)).thenReturn(script);
|
||||
when(scripts.selectOne(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(script);
|
||||
var configuration = new ChatModelVo(); configuration.setModelName("writing-model"); configuration.setProviderCode("atlas");
|
||||
when(models.selectModelByName("writing-model")).thenReturn(configuration);
|
||||
ShortDramaAtlasTestModels.register(models, "writing-model", configuration, "chat");
|
||||
when(factory.getOriginalService("atlas")).thenReturn(chat);
|
||||
when(chat.buildChatModel(eq(configuration), any())).thenReturn(model);
|
||||
when(model.chat(anyString())).thenReturn(COMBINED);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.common.chat.entity.chat.ChatModel;
|
||||
import org.ruoyi.mapper.chat.ChatModelMapper;
|
||||
import org.ruoyi.service.chat.IChatProviderService;
|
||||
import org.ruoyi.service.chat.impl.ChatModelServiceImpl;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@Tag("dev")
|
||||
class ShortDramaAtlasIsolationTest {
|
||||
@BeforeAll static void metadata() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test-atlas-isolation"), ChatModel.class);
|
||||
}
|
||||
|
||||
@Test void sharedBackendStillSelectsOtherProvidersWhileShortDramaRejectsThem() {
|
||||
for (String providerCode : List.of("openai", "deepseek", "custom_api")) {
|
||||
var mapper = mock(ChatModelMapper.class);
|
||||
var providers = mock(IChatProviderService.class);
|
||||
var model = new ChatModelVo();
|
||||
model.setProviderCode(providerCode); model.setModelName("shared-name"); model.setCategory("chat");
|
||||
model.setApiHost("https://other-provider.test/v1"); model.setApiKey("test-only-key");
|
||||
when(mapper.selectVoOne(any(LambdaQueryWrapper.class))).thenReturn(model);
|
||||
var sharedService = new ChatModelServiceImpl(mapper, providers);
|
||||
assertSame(model, sharedService.selectModelByName("shared-name"));
|
||||
verify(providers).requireEnabled(providerCode);
|
||||
assertThrows(IllegalArgumentException.class, () -> ShortDramaAtlasModels.requireProvider(model));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.common.chat.service.chat.IChatModelService;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@Tag("dev")
|
||||
class ShortDramaAtlasModelsTest {
|
||||
private static ChatModelVo model(String provider, String name, String category) {
|
||||
var model = new ChatModelVo();
|
||||
model.setProviderCode(provider); model.setModelName(name); model.setCategory(category);
|
||||
model.setApiHost("https://api.atlascloud.ai/v1"); model.setApiKey("test-only-key");
|
||||
return model;
|
||||
}
|
||||
|
||||
@Test void scopesLookupByAtlasCategoryAndExactNameWithoutUsingGlobalNameLookup() {
|
||||
var models = mock(IChatModelService.class);
|
||||
var expected = model("atlas", "shared-name", "chat");
|
||||
when(models.queryAvailableList(any())).thenReturn(List.of(model("openai", "shared-name", "chat"),
|
||||
model("atlas", "shared-name", "image"), model("atlas", "shared-name-longer", "chat"), expected));
|
||||
assertSame(expected, ShortDramaAtlasModels.require(models, "shared-name", "chat"));
|
||||
var query = ArgumentCaptor.forClass(ChatModelBo.class);
|
||||
verify(models).queryAvailableList(query.capture());
|
||||
assertEquals("atlas", query.getValue().getProviderCode());
|
||||
assertEquals("chat", query.getValue().getCategory());
|
||||
assertEquals("shared-name", query.getValue().getModelName());
|
||||
verify(models, never()).selectModelByName(any());
|
||||
}
|
||||
|
||||
@Test void absentOrDisabledAtlasModelNeverFallsBackToAnotherProvider() {
|
||||
var models = mock(IChatModelService.class);
|
||||
when(models.queryAvailableList(any())).thenReturn(List.of(model("deepseek", "writer", "chat")));
|
||||
assertThrows(IllegalArgumentException.class, () -> ShortDramaAtlasModels.require(models, "writer", "chat"));
|
||||
assertThrows(IllegalArgumentException.class, () -> ShortDramaAtlasModels.defaultModel(models, "chat"));
|
||||
when(models.queryAvailableList(any())).thenReturn(List.of());
|
||||
assertThrows(IllegalArgumentException.class, () -> ShortDramaAtlasModels.defaultModel(models, "chat"));
|
||||
}
|
||||
|
||||
@Test void ambiguousAtlasConfigurationMustBeCorrectedRatherThanChoosingAnArbitraryKey() {
|
||||
var models = mock(IChatModelService.class);
|
||||
when(models.queryAvailableList(any())).thenReturn(List.of(model("atlas", "writer", "chat"), model("atlas", "writer", "chat")));
|
||||
assertThrows(IllegalArgumentException.class, () -> ShortDramaAtlasModels.require(models, "writer", "chat"));
|
||||
}
|
||||
|
||||
@Test void providerLabelCannotSendAtlasCredentialsToAnotherHost() {
|
||||
for (String host : List.of("https://api.deepseek.com/v1", "https://api.atlascloud.ai.evil.test/v1",
|
||||
"http://api.atlascloud.ai/v1", "https://key@api.atlascloud.ai/v1", "https://api.atlascloud.ai:8443/v1",
|
||||
"https://api.atlascloud.ai/v1?key=secret", "")) {
|
||||
var model = model("atlas", "writer", "chat"); model.setApiHost(host);
|
||||
assertThrows(IllegalArgumentException.class, () -> ShortDramaAtlasModels.requireProvider(model), host);
|
||||
}
|
||||
}
|
||||
|
||||
@Test void missingKeyAndNonAtlasProviderAreRejectedBeforeAnyMediaSubmission() {
|
||||
var model = model("atlas", "writer", "chat"); model.setApiKey(" ");
|
||||
assertThrows(IllegalArgumentException.class, () -> ShortDramaAtlasModels.requireProvider(model));
|
||||
assertThrows(IllegalArgumentException.class, () -> ShortDramaAtlasModels.requireProvider(model("custom_api", "writer", "chat")));
|
||||
}
|
||||
|
||||
@Test void referenceUploadsAcceptAtlasImageOrVideoConfigurationsWithoutAcceptingChatModels() {
|
||||
var models = mock(IChatModelService.class);
|
||||
var video = model("atlas", "video", "video");
|
||||
when(models.queryAvailableList(any())).thenReturn(List.of(video));
|
||||
assertSame(video, ShortDramaAtlasModels.require(models, "video", "image", "video"));
|
||||
when(models.queryAvailableList(any())).thenReturn(List.of(model("atlas", "writer", "chat")));
|
||||
assertThrows(IllegalArgumentException.class, () -> ShortDramaAtlasModels.require(models, "writer", "image", "video"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.common.chat.service.chat.IChatModelService;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.WeakHashMap;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
|
||||
/** Test catalog with the same provider/category/name scope as production. No network calls. */
|
||||
final class ShortDramaAtlasTestModels {
|
||||
private static final Map<IChatModelService, Map<String, ChatModelVo>> CATALOGS = new WeakHashMap<>();
|
||||
static synchronized void register(IChatModelService service, String name, ChatModelVo model, String category) {
|
||||
model.setModelName(name); model.setProviderCode("atlas"); model.setCategory(category);
|
||||
model.setApiHost("https://api.atlascloud.ai/v1"); model.setApiKey("test-only-key");
|
||||
var catalog = CATALOGS.computeIfAbsent(service, ignored -> new LinkedHashMap<>());
|
||||
catalog.put(category + ":" + name, model);
|
||||
doAnswer(call -> {
|
||||
ChatModelBo query = call.getArgument(0);
|
||||
return catalog.values().stream().filter(entry -> entry.getProviderCode().equals(query.getProviderCode())
|
||||
&& (query.getCategory() == null || entry.getCategory().equals(query.getCategory()))
|
||||
&& (query.getModelName() == null || entry.getModelName().equals(query.getModelName()))).toList();
|
||||
}).when(service).queryAvailableList(any());
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,11 @@ class ShortDramaContinuityTest {
|
||||
|
||||
@Test
|
||||
void compatibleDeepSeekUsesAdapterThatSendsThinkingSetting() {
|
||||
assertEquals("deepseek", ShortDramaServiceImpl.shortDramaProviderCode("openai", "deepseek-ai/deepseek-v4.1-flash"));
|
||||
assertThrows(IllegalArgumentException.class, () -> ShortDramaServiceImpl.shortDramaProviderCode("openai", "deepseek-ai/deepseek-v4.1-flash"));
|
||||
assertEquals("deepseek", ShortDramaServiceImpl.shortDramaProviderCode("atlas", "deepseek-ai/deepseek-v4.1-flash"));
|
||||
assertEquals("atlas", ShortDramaServiceImpl.shortDramaProviderCode("atlas", "gemini-3-flash"));
|
||||
assertEquals("openai", ShortDramaServiceImpl.shortDramaProviderCode("openai", "gemini-3-flash"));
|
||||
assertEquals("custom_api", ShortDramaServiceImpl.shortDramaProviderCode("custom_api", "deepseek-v4"));
|
||||
assertThrows(IllegalArgumentException.class, () -> ShortDramaServiceImpl.shortDramaProviderCode("openai", "gemini-3-flash"));
|
||||
assertThrows(IllegalArgumentException.class, () -> ShortDramaServiceImpl.shortDramaProviderCode("custom_api", "deepseek-v4"));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -95,7 +95,7 @@ class ShortDramaImageRevisionTest {
|
||||
var locations = mock(ShortDramaLocationMapper.class); when(locations.selectById(50L)).thenReturn(location);
|
||||
var models = mock(IChatModelService.class);
|
||||
model = new ChatModelVo(); model.setModelName("edit-model"); model.setProviderCode("atlas");
|
||||
when(models.selectModelByName("edit-model")).thenReturn(model);
|
||||
ShortDramaAtlasTestModels.register(models, "edit-model", model, "image");
|
||||
images = mock(IImageGenerationService.class);
|
||||
when(images.startImageGeneration(any())).thenReturn(MediaGenerationResponse.builder().id("prediction-1").status("processing").build());
|
||||
when(images.generateImage(any())).thenReturn("");
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import dev.langchain4j.exception.AuthenticationException;
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@Tag("dev")
|
||||
class ShortDramaModelErrorsTest {
|
||||
@Test void nestedAuthenticationErrorNamesAtlasWithoutExposingSdkOrVendorResponse() {
|
||||
var error = new CompletionException(new AuthenticationException("{\"code\":401,\"msg\":\"unauthorized\"}"));
|
||||
assertEquals("Atlas 模型鉴权失败,请检查后台该模型的 API Key、接口地址和账号权限", ShortDramaModelErrors.message(error));
|
||||
}
|
||||
@Test void otherGenerationErrorsKeepTheOriginalActionableMessage() {
|
||||
assertEquals("正文格式不完整", ShortDramaModelErrors.message(new IllegalArgumentException("正文格式不完整")));
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ class ShortDramaPartialPlanRecoveryTest {
|
||||
when(appearances.selectList(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(assets.appearances());
|
||||
when(locations.selectList(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(assets.locations());
|
||||
when(boards.selectList(any(com.baomidou.mybatisplus.core.conditions.Wrapper.class))).thenReturn(List.of());
|
||||
var model = new ChatModelVo(); model.setModelName("writer"); when(models.selectModelByName("writer")).thenReturn(model);
|
||||
var model = new ChatModelVo(); model.setModelName("writer"); ShortDramaAtlasTestModels.register(models, "writer", model, "chat");
|
||||
review = new ShortDramaReviewedPlanBo(); review.setScriptId(20L); review.setModel("writer"); review.setExpectedScriptText(script.getScriptText());
|
||||
review.setMinimumShotSeconds(4); review.setSceneNumbers(List.of(1)); review.setExpectedAssetSignature(assets.signature());
|
||||
review.setPanels(List.of(ShortDramaSceneCheckpointTest.panel()));
|
||||
|
||||
@@ -91,7 +91,7 @@ class ShortDramaScriptOnlyCreationTest {
|
||||
ReflectionTestUtils.setField(service, "activeEmitters", emitters);
|
||||
ReflectionTestUtils.setField(service, "planningProgress", new ConcurrentHashMap<>());
|
||||
var configuration = new ChatModelVo(); configuration.setModelName("writing-model"); configuration.setProviderCode("atlas");
|
||||
when(models.selectModelByName("writing-model")).thenReturn(configuration);
|
||||
ShortDramaAtlasTestModels.register(models, "writing-model", configuration, "chat");
|
||||
when(factory.getOriginalService("atlas")).thenReturn(chat);
|
||||
when(chat.buildChatModel(eq(configuration), any())).thenReturn(model);
|
||||
when(chat.buildStreamingChatModel(eq(configuration), any())).thenReturn(streaming);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.ruoyi.controller.shortdrama.ShortDramaSkillController;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import java.nio.file.Path;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@Tag("dev")
|
||||
class ShortDramaSkillCategoriesTest {
|
||||
@TempDir Path root;
|
||||
|
||||
@Test void categoriesEndpointReturnsCatalogRatherThanLookingUpASkillNamedCategories() throws Exception {
|
||||
var catalog = spy(new ShortDramaSkillCatalog(root.toString()));
|
||||
var mvc = MockMvcBuilders.standaloneSetup(new ShortDramaSkillController(catalog, mock(ShortDramaSkillMarket.class))).build();
|
||||
mvc.perform(get("/short-drama/skills/categories"))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data[2].type").value("screenwriting"));
|
||||
verify(catalog, never()).detail("categories");
|
||||
for (var category : catalog.categories()) {
|
||||
if (category.editable()) assertDoesNotThrow(() -> catalog.list(category.type(), false));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package org.ruoyi.service.shortdrama.impl;
|
||||
|
||||
import org.junit.jupiter.api.Tag;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
|
||||
import org.ruoyi.common.chat.factory.ImageServiceFactory;
|
||||
import org.ruoyi.common.satoken.utils.LoginHelper;
|
||||
import org.ruoyi.controller.shortdrama.ShortDramaVideoReferenceController;
|
||||
import org.ruoyi.domain.entity.shortdrama.ShortDramaProject;
|
||||
import org.ruoyi.mapper.shortdrama.ShortDramaProjectMapper;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
|
||||
|
||||
@Tag("dev")
|
||||
class ShortDramaVideoReferenceRoutesTest {
|
||||
@Test void listRouteUsesCurrentUserAndReturnsReferencesWithoutAModelCall() throws Exception {
|
||||
var references = mock(ShortDramaVideoReferenceService.class);
|
||||
when(references.list(10L, 1L)).thenReturn(List.of());
|
||||
var mvc = MockMvcBuilders.standaloneSetup(new ShortDramaVideoReferenceController(references)).build();
|
||||
try (var login = mockStatic(LoginHelper.class)) {
|
||||
login.when(LoginHelper::getUserId).thenReturn(1L);
|
||||
mvc.perform(get("/short-drama/10/video-references"))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value(200))
|
||||
.andExpect(jsonPath("$.data").isEmpty());
|
||||
}
|
||||
verify(references).list(10L, 1L);
|
||||
verifyNoMoreInteractions(references);
|
||||
}
|
||||
|
||||
@Test void ownershipAndAtlasChecksRunBeforeReadingOrUploadingReferences() {
|
||||
var projects = mock(ShortDramaProjectMapper.class);
|
||||
var images = mock(ImageServiceFactory.class);
|
||||
var project = new ShortDramaProject(); project.setUserId(2L);
|
||||
when(projects.selectById(10L)).thenReturn(project);
|
||||
var references = new ShortDramaVideoReferenceService(projects, images);
|
||||
assertThrows(IllegalArgumentException.class, () -> references.list(10L, 1L));
|
||||
var model = new ChatModelVo(); model.setProviderCode("openai");
|
||||
assertThrows(IllegalArgumentException.class, () -> references.transport(10L, model, List.of()));
|
||||
verifyNoInteractions(images);
|
||||
}
|
||||
}
|
||||
@@ -80,9 +80,9 @@ class ShortDramaVideoSubmissionTest {
|
||||
when(mapper.selectById(99L)).thenAnswer(a -> shot);
|
||||
when(projects.selectById(10L)).thenReturn(project);
|
||||
when(assets.readyFrame(99L)).thenReturn("https://example.com/reviewed.png");
|
||||
when(models.selectModelByName(MODEL)).thenReturn(model(MODEL));
|
||||
when(models.selectModelByName(ACTUAL)).thenReturn(model(ACTUAL));
|
||||
when(videos.getOriginalService("fixture")).thenReturn(provider);
|
||||
ShortDramaAtlasTestModels.register(models, MODEL, model(MODEL), "video");
|
||||
ShortDramaAtlasTestModels.register(models, ACTUAL, model(ACTUAL), "video");
|
||||
when(videos.getOriginalService("atlas")).thenReturn(provider);
|
||||
when(sounds.references(eq(10L), eq(1), any())).thenReturn(List.of());
|
||||
when(provider.generateVideo(any())).thenReturn(MediaGenerationResponse.builder().id("prediction-1").status("processing").build());
|
||||
when(mapper.update(isNull(), any())).thenAnswer(a -> {
|
||||
@@ -113,8 +113,8 @@ class ShortDramaVideoSubmissionTest {
|
||||
var sample=new ShortDramaSoundService.Reference("voice:42:sample","阿青",directory.resolve("sample.mp3"),3.0,"immutable-hash");
|
||||
var binding=new ShortDramaCharacterVoiceService.Binding("42","阿青","sample",1,3.0,1);
|
||||
when(voices.plan(any(),nullable(String.class),anyBoolean())).thenReturn(new ShortDramaCharacterVoiceService.Plan(List.of(binding),List.of(sample),"\n阿青使用 @音频1 的音色",List.of(),true,List.of("42")));
|
||||
when(models.selectModelByName(referenceModel)).thenReturn(model(referenceModel));
|
||||
when(models.selectModelByName(requestedModel)).thenReturn(model(requestedModel));
|
||||
ShortDramaAtlasTestModels.register(models, referenceModel, model(referenceModel), "video");
|
||||
ShortDramaAtlasTestModels.register(models, requestedModel, model(requestedModel), "video");
|
||||
when(sounds.uploadReferences(anyList(),any())).thenReturn(List.of("https://example.com/voice-a.mp3"),List.of("https://example.com/voice-b.mp3"));
|
||||
String first=request();service.generateVideo(99L,requestedModel,1L,first,false);
|
||||
var capture=org.mockito.ArgumentCaptor.forClass(VideoContext.class);verify(provider).generateVideo(capture.capture());
|
||||
|
||||
@@ -33,7 +33,7 @@ class ShortDramaVisualAssetVersioningTest {
|
||||
var asset = new ShortDramaVisualAsset(); asset.setId(1L); asset.setProjectId(7L); asset.setStoryboardId(11L);
|
||||
asset.setKind("shot_frame"); asset.setStatus("waiting"); asset.setPredictionId("original-prediction"); asset.setModel("openai/gpt-image-2/edit"); asset.setSourceHash("old-version");
|
||||
var original = new org.ruoyi.common.chat.domain.vo.chat.ChatModelVo(); original.setModelName(asset.getModel());
|
||||
when(models.selectModelByName(asset.getModel())).thenReturn(original);
|
||||
ShortDramaAtlasTestModels.register(models, asset.getModel(), original, "image");
|
||||
when(predictions.retrieve(original, "original-prediction")).thenReturn(org.ruoyi.common.chat.entity.media.MediaGenerationResponse.builder()
|
||||
.id("original-prediction").status("completed").url("https://example.com/old-result.png").build());
|
||||
ReflectionTestUtils.invokeMethod(service, "generateOne", asset, "bytedance/seedream-v4.7/text-to-image");
|
||||
|
||||
Reference in New Issue
Block a user