fix(short-drama): enforce Atlas isolation and restore missing catalog routes

This commit is contained in:
ageerle
2026-10-10 10:22:33 +08:00
parent ca6ef96200
commit 432fbb6ea4
24 changed files with 509 additions and 47 deletions

View File

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

View File

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

View File

@@ -0,0 +1,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 未配置,请在后台填写后重试");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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("正文格式不完整")));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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