diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/ImageVideoProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/ImageVideoProperties.java index 7e6f471..8ef13c1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/ImageVideoProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/ImageVideoProperties.java @@ -17,5 +17,5 @@ public class ImageVideoProperties { private String voiceCloneWorkflowId = "7652954356887961641"; private String voiceSynthesisWorkflowId = "7652954297530105894"; private int cozeConnectTimeoutMillis = 10000; - private int cozeReadTimeoutMillis = 600000; + private int cozeReadTimeoutMillis = 3600000; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/OssProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/OssProperties.java index 4768e5a..c47026f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/OssProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/OssProperties.java @@ -10,6 +10,7 @@ public class OssProperties { private String endpoint; private String publicEndpoint; private String bucket; + private String imageVideoBucket; private String accessKeyId; private String accessKeySecret; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java index 91b6da7..a85d2cf 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java @@ -38,10 +38,11 @@ public class OssStorageService { public UploadedResult uploadResultFileWithFreshDownloadUrl(File file, String moduleType) { String objectKey = String.format("result/%s/%s/%s", moduleType.toLowerCase(), UUID.randomUUID(), file.getName()); + String bucket = resolveBucket(moduleType); OSS ossClient = buildClient(); try { - ossClient.putObject(ossProperties.getBucket(), objectKey, file); - return new UploadedResult(objectKey, getPublicUrl(objectKey)); + ossClient.putObject(bucket, objectKey, file); + return new UploadedResult(objectKey, getPublicUrl(objectKey, bucket)); } finally { ossClient.shutdown(); } @@ -56,10 +57,11 @@ public class OssStorageService { objectName = file.getName(); } String objectKey = String.format("upload/%s/%s/%s", normalizedModuleType, UUID.randomUUID(), objectName); + String bucket = resolveBucket(moduleType); OSS ossClient = buildClient(); try { - ossClient.putObject(ossProperties.getBucket(), objectKey, file); - return new UploadedResult(objectKey, getPublicUrl(objectKey)); + ossClient.putObject(bucket, objectKey, file); + return new UploadedResult(objectKey, getPublicUrl(objectKey, bucket)); } finally { ossClient.shutdown(); } @@ -151,12 +153,16 @@ public class OssStorageService { * 获取公开(无签名)URL,格式:https://{bucket}.{endpoint}/{objectKey} */ public String getPublicUrl(String value) { + return getPublicUrl(value, ossProperties.getBucket()); + } + + private String getPublicUrl(String value, String bucket) { if (value == null || value.isBlank()) { return null; } String objectKey = resolveObjectKey(value); String normalizedKey = objectKey.startsWith("/") ? objectKey.substring(1) : objectKey; - String host = String.format("%s.%s", ossProperties.getBucket(), publicEndpoint()); + String host = String.format("%s.%s", bucket, publicEndpoint()); try { return new URI("https", host, "/" + normalizedKey, null).toASCIIString(); } catch (Exception ignored) { @@ -210,6 +216,16 @@ public class OssStorageService { ); } + private String resolveBucket(String moduleType) { + if (moduleType != null + && "IMAGE_VIDEO".equalsIgnoreCase(moduleType.trim()) + && ossProperties.getImageVideoBucket() != null + && !ossProperties.getImageVideoBucket().isBlank()) { + return ossProperties.getImageVideoBucket().trim(); + } + return ossProperties.getBucket(); + } + private String publicEndpoint() { String endpoint = ossProperties.getPublicEndpoint(); if (endpoint == null || endpoint.isBlank()) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/controller/ImageVideoController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/controller/ImageVideoController.java index a018f55..494b902 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/controller/ImageVideoController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/controller/ImageVideoController.java @@ -53,7 +53,7 @@ public class ImageVideoController { @PostMapping("/douyin-copy") @Operation(summary = "抖音文案识别和仿写") public ApiResponse douyinCopy(@Valid @RequestBody DouyinCopyRequest request) { - return ApiResponse.success(imageVideoCozeService.runDouyinCopy(request.getUserId(), request.getUrl())); + return ApiResponse.success(imageVideoCozeService.runDouyinCopy(request)); } @PostMapping("/workflow/run") @@ -77,7 +77,7 @@ public class ImageVideoController { @PostMapping("/voice/list") @Operation(summary = "查看音色") public ApiResponse> listVoices(@Valid @RequestBody ImageVideoVoiceListRequest request) { - return ApiResponse.success(imageVideoCozeService.runVoiceList(request.getUserId())); + return ApiResponse.success(imageVideoCozeService.runVoiceList(request.getUserId(), request.getName())); } @PostMapping("/voice/delete") @@ -89,7 +89,7 @@ public class ImageVideoController { @PostMapping("/voice/clone") @Operation(summary = "音色克隆") public ApiResponse> cloneVoice(@Valid @RequestBody ImageVideoVoiceCloneRequest request) { - return ApiResponse.success(imageVideoCozeService.runVoiceClone(request.getUserId(), request.getAudioUrl(), request.getVideoUrl())); + return ApiResponse.success(imageVideoCozeService.runVoiceClone(request.getUserId(), request.getName(), request.getAudioUrl(), request.getVideoUrl())); } @PostMapping("/voice/synthesis") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/DouyinCopyRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/DouyinCopyRequest.java index be2d9c7..54ccaa7 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/DouyinCopyRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/DouyinCopyRequest.java @@ -1,19 +1,58 @@ package com.nanri.aiimage.modules.imagevideo.model.dto; +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import lombok.Data; +import java.util.ArrayList; +import java.util.List; + @Data @Schema(description = "抖音文案解析和仿写请求") public class DouyinCopyRequest { - @NotNull(message = "userId 不能为空") @Schema(description = "用户ID", example = "1") private Long userId; + @Schema(description = "文案工作流业务 api_key") + @JsonProperty("api_key") + @JsonAlias("apiKey") + private String apiKey = ""; + + @Schema(description = "T8 平台密钥") + @JsonProperty("t8_key") + @JsonAlias("t8Key") + private String t8Key = ""; + @NotBlank(message = "url 不能为空") @Schema(description = "抖音分享口令、短链或视频链接", example = "2.38 复制打开抖音,看看") private String url; + + @Schema(description = "目标视频时长,单位秒", example = "15") + private Integer duration; + + @Schema(description = "产品信息") + @JsonProperty("proc_info") + @JsonAlias("procInfo") + private ProcInfo procInfo = new ProcInfo(); + + @Data + @Schema(description = "抖音文案仿写产品信息") + public static class ProcInfo { + @Schema(description = "产品类目", example = "连衣裙") + private String type = ""; + + @Schema(description = "产品名称", example = "亚麻连衣裙") + private String name = ""; + + @Schema(description = "产品图片 URL 列表") + @JsonProperty("proc_image") + @JsonAlias("procImage") + private List procImage = new ArrayList<>(); + + @Schema(description = "核心卖点或产品属性", example = "优雅、亚麻、白色") + private String properties = ""; + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceCloneRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceCloneRequest.java index 0ac136c..48a8dd7 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceCloneRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceCloneRequest.java @@ -7,6 +7,7 @@ import lombok.Data; public class ImageVideoVoiceCloneRequest { @NotNull(message = "userId 不能为空") private Long userId; + private String name; private String audioUrl; private String videoUrl; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceListRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceListRequest.java index 4b7f1d8..b5ebb8d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceListRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/model/dto/ImageVideoVoiceListRequest.java @@ -7,4 +7,6 @@ import lombok.Data; public class ImageVideoVoiceListRequest { @NotNull(message = "userId 不能为空") private Long userId; + + private String name; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoCozeService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoCozeService.java index 2faff1e..ea33c35 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoCozeService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoCozeService.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.ImageVideoProperties; import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.imagevideo.model.dto.DouyinCopyRequest; import com.nanri.aiimage.modules.imagevideo.model.vo.ImageVideoMediaUploadVo; import com.nanri.aiimage.modules.imagevideo.model.vo.DouyinCopyVo; import lombok.RequiredArgsConstructor; @@ -25,6 +26,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; @Slf4j @Service @@ -39,6 +41,9 @@ public class ImageVideoCozeService { "scriptDraft", "script_draft", "rewrittenContent", "rewritten_content", "fullContent", "full_content", "copywriting", "script", "output", "text", "result" ); + private static final Set IMAGE_VIDEO_MODELS = Set.of( + "sdols-2.0", "sdols-2.0-fast", "doubao-seedance-2.0-mini" + ); private final ImageVideoProperties properties; private final ImageVideoSecretService secretService; @@ -47,20 +52,23 @@ public class ImageVideoCozeService { private final ObjectMapper objectMapper; private volatile HttpClient sharedHttpClient; - public DouyinCopyVo runDouyinCopy(Long userId, String url) { - String normalizedUrl = url == null ? "" : url.trim(); + public DouyinCopyVo runDouyinCopy(DouyinCopyRequest request) { + if (request == null) { + throw new BusinessException("请求参数不能为空"); + } + String normalizedUrl = request.getUrl() == null ? "" : request.getUrl().trim(); if (normalizedUrl.isBlank()) { throw new BusinessException("抖音口令或链接不能为空"); } if (looksLikeJsonObject(normalizedUrl)) { - throw new BusinessException("请粘贴抖音分享口令或链接,不要粘贴接口返回内容"); - } - if (!isLikelyDouyinSource(normalizedUrl)) { - throw new BusinessException("请粘贴包含抖音链接的分享口令"); + throw new BusinessException("请粘贴视频链接或文案来源链接,不要粘贴接口返回内容"); } - ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId); - String responseText = postWorkflow(normalizedUrl, secret.copyApiKey(), secret.t8Key(), secret.cozeToken(), + ImageVideoSecretService.ResolvedSecret secret = secretService.resolveForDouyinCopy(request.getUserId()); + String responseText = postWorkflow(request, normalizedUrl, + firstNonBlank(request.getApiKey(), secret.copyApiKey()), + firstNonBlank(request.getT8Key(), secret.t8Key()), + secret.cozeToken(), workflowConfigService.douyinCopyWorkflowId()); return parseDouyinCopyResponse(responseText); } @@ -119,6 +127,32 @@ public class ImageVideoCozeService { normalizedProcInfo.put("proc_image", normalizeStringList(normalizedProcInfo.get("proc_image"))); parameters.put("proc_info", normalizedProcInfo); } + + Object videoInfo = parameters.get("video_info"); + if (videoInfo instanceof Map) { + Map normalizedVideoInfo = new LinkedHashMap<>(); + ((Map) videoInfo).forEach((key, value) -> normalizedVideoInfo.put(String.valueOf(key), value)); + String model = firstNonBlank(asText(normalizedVideoInfo.get("model")), "sdols-2.0-fast"); + if (!IMAGE_VIDEO_MODELS.contains(model)) { + throw new BusinessException("不支持的视频模型: " + model); + } + normalizedVideoInfo.put("model", model); + normalizedVideoInfo.put("draft", !"false".equalsIgnoreCase(asText(normalizedVideoInfo.get("draft")))); + parameters.put("video_info", normalizedVideoInfo); + } + + Object audioInfo = parameters.get("audio_info"); + Map normalizedAudioInfo = new LinkedHashMap<>(); + if (audioInfo instanceof Map) { + ((Map) audioInfo).forEach((key, value) -> normalizedAudioInfo.put(String.valueOf(key), value)); + } + String audioType = firstNonBlank(asText(normalizedAudioInfo.get("type")), "1"); + if (!"1".equals(audioType) && !"2".equals(audioType)) { + throw new BusinessException("不支持的音频类型: " + audioType); + } + normalizedAudioInfo.put("audio_url", firstNonBlank(asText(normalizedAudioInfo.get("audio_url")))); + normalizedAudioInfo.put("type", Integer.valueOf(audioType)); + parameters.put("audio_info", normalizedAudioInfo); } private List normalizeStringList(Object value) { @@ -144,15 +178,6 @@ public class ImageVideoCozeService { return text.startsWith("{") && text.endsWith("}"); } - private boolean isLikelyDouyinSource(String value) { - String text = firstNonBlank(value).toLowerCase(); - return text.contains("douyin.com") - || text.contains("v.douyin") - || text.contains("iesdouyin") - || text.contains("dou音") - || text.contains("抖音"); - } - public Map getImageVideoWorkflowResult(Long userId, String executeId) { String resolvedExecuteId = firstNonBlank(executeId); if (!hasText(resolvedExecuteId)) { @@ -210,10 +235,12 @@ public class ImageVideoCozeService { } } - public Map runVoiceList(Long userId) { + public Map runVoiceList(Long userId, String name) { ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId); Map parameters = new LinkedHashMap<>(); parameters.put("api_key", secret.voiceApiKey()); + parameters.put("name", firstNonBlank(name)); + parameters.put("user_id", userId); return postWorkflowParameters("voice list", workflowConfigService.voiceListWorkflowId(), parameters, secret.cozeToken(), false); } @@ -222,19 +249,22 @@ public class ImageVideoCozeService { Map parameters = new LinkedHashMap<>(); parameters.put("api_key", secret.voiceApiKey()); parameters.put("group_id", secret.voiceGroupId()); + parameters.put("user_id", userId); parameters.put("voice_id", firstNonBlank(voiceId)); return postWorkflowParameters("voice delete", workflowConfigService.voiceDeleteWorkflowId(), parameters, secret.cozeToken(), false); } - public Map runVoiceClone(Long userId, String audioUrl, String videoUrl) { - String resolvedAudioUrl = firstNonBlank(audioUrl); + public Map runVoiceClone(Long userId, String name, String audioUrl, String videoUrl) { String resolvedVideoUrl = firstNonBlank(videoUrl); + String resolvedAudioUrl = firstNonBlank(audioUrl, resolvedVideoUrl); if (!hasText(resolvedAudioUrl) && !hasText(resolvedVideoUrl)) { throw new BusinessException("请上传音频或填写视频链接"); } ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId); Map parameters = new LinkedHashMap<>(); parameters.put("api_key", secret.voiceApiKey()); + parameters.put("name", firstNonBlank(name)); + parameters.put("user_id", userId); parameters.put("audio_url", resolvedAudioUrl); parameters.put("video_url", resolvedVideoUrl); return postWorkflowParameters("voice clone", workflowConfigService.voiceCloneWorkflowId(), parameters, secret.cozeToken(), false); @@ -250,7 +280,7 @@ public class ImageVideoCozeService { return postWorkflowParameters("voice synthesis", workflowConfigService.voiceSynthesisWorkflowId(), parameters, secret.cozeToken(), false); } - private String postWorkflow(String url, String apiKey, String t8Key, String token, String workflowId) { + private String postWorkflow(DouyinCopyRequest request, String url, String apiKey, String t8Key, String token, String workflowId) { String resolvedApiKey = firstNonBlank(apiKey); String resolvedT8Key = firstNonBlank(t8Key); String cozeToken = firstNonBlank(token); @@ -268,6 +298,8 @@ public class ImageVideoCozeService { parameters.put("api_key", resolvedApiKey); parameters.put("t8_key", resolvedT8Key); parameters.put("url", url); + parameters.put("duration", normalizeDouyinCopyDuration(request.getDuration())); + parameters.put("proc_info", normalizeDouyinCopyProcInfo(request.getProcInfo())); Map body = new LinkedHashMap<>(); String resolvedWorkflowId = firstNonBlank(workflowId, properties.getDouyinCopyWorkflowId()); @@ -282,6 +314,30 @@ public class ImageVideoCozeService { return postCozeJson("douyin copy", endpoint, body, cozeToken); } + private int normalizeDouyinCopyDuration(Integer duration) { + if (duration == null || duration < 0) { + return 0; + } + return duration; + } + + private Map normalizeDouyinCopyProcInfo(DouyinCopyRequest.ProcInfo procInfo) { + Map normalized = new LinkedHashMap<>(); + if (procInfo == null) { + normalized.put("properties", ""); + normalized.put("type", ""); + normalized.put("name", ""); + normalized.put("proc_image", List.of()); + return normalized; + } + normalized.put("properties", firstNonBlank(procInfo.getProperties())); + normalized.put("type", firstNonBlank(procInfo.getType())); + normalized.put("name", firstNonBlank(procInfo.getName())); + List images = normalizeStringList(procInfo.getProcImage()); + normalized.put("proc_image", images); + return normalized; + } + private Map postWorkflowParameters(String label, String workflowId, Map parameters, String token, boolean async) { String cozeToken = firstNonBlank(token); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoSecretService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoSecretService.java index d86100a..ef30005 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoSecretService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoSecretService.java @@ -103,6 +103,28 @@ public class ImageVideoSecretService { return new ResolvedSecret(cozeToken, copyApiKey, t8Key, voiceApiKey, voiceGroupId); } + public ResolvedSecret resolveForDouyinCopy(Long userId) { + String cozeToken = normalize(properties.getCozeToken()); + if (!hasText(cozeToken)) { + throw new BusinessException("图生视频 Coze 访问令牌未配置"); + } + if (userId == null || userId <= 0) { + return new ResolvedSecret(cozeToken, "", "", "", ""); + } + + ImageVideoSecretEntity row = selectByUserId(userId); + if (row == null || row.getExpiresAt() == null || !row.getExpiresAt().isAfter(LocalDateTime.now())) { + return new ResolvedSecret(cozeToken, "", "", "", ""); + } + return new ResolvedSecret( + cozeToken, + decrypt(row.getCopyApiKey()), + decrypt(row.getT8Key()), + decrypt(row.getVoiceApiKey()), + decrypt(row.getVoiceGroupId()) + ); + } + private ImageVideoSecretEntity selectByUserId(Long userId) { return secretMapper.selectOne(new LambdaQueryWrapper() .eq(ImageVideoSecretEntity::getUserId, userId) diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 8cd2684..c2bd0b3 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -88,6 +88,7 @@ aiimage: endpoint: ${AIIMAGE_OSS_ENDPOINT:oss-cn-hangzhou.aliyuncs.com} public-endpoint: ${AIIMAGE_OSS_PUBLIC_ENDPOINT:oss-cn-hangzhou.aliyuncs.com} bucket: ${AIIMAGE_OSS_BUCKET:nanri-ai-images} + image-video-bucket: ${AIIMAGE_IMAGE_VIDEO_OSS_BUCKET:shufu-video} access-key-id: ${AIIMAGE_OSS_ACCESS_KEY_ID:LTAI5tNpyvzMNz9f2dHarsm8} access-key-secret: ${AIIMAGE_OSS_ACCESS_KEY_SECRET:bQSZnFH455i8tzyOgeahJmUzwmhynz} transient-storage: @@ -232,10 +233,10 @@ aiimage: coze-result-buffer-enabled: ${AIIMAGE_SIMILAR_ASIN_COZE_RESULT_BUFFER_ENABLED:true} image-video: coze-base-url: ${AIIMAGE_IMAGE_VIDEO_COZE_BASE_URL:https://api.coze.cn} - coze-token: ${AIIMAGE_IMAGE_VIDEO_COZE_TOKEN:} + coze-token: ${AIIMAGE_IMAGE_VIDEO_COZE_TOKEN:sat_Ws4VB1caOPasDivpKIvtOySYx3lhKgQ95H3crIh0tBwiNYtPTyi6bqe0pBaRzpVu} coze-workflow-path: ${AIIMAGE_IMAGE_VIDEO_COZE_WORKFLOW_PATH:/v1/workflow/run} coze-connect-timeout-millis: ${AIIMAGE_IMAGE_VIDEO_COZE_CONNECT_TIMEOUT_MILLIS:10000} - coze-read-timeout-millis: ${AIIMAGE_IMAGE_VIDEO_COZE_READ_TIMEOUT_MILLIS:600000} + coze-read-timeout-millis: ${AIIMAGE_IMAGE_VIDEO_COZE_READ_TIMEOUT_MILLIS:3600000} security: shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key} internal-token: ${AIIMAGE_INTERNAL_TOKEN:} diff --git a/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue b/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue index 6ff6f87..5ffdb6c 100644 --- a/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue +++ b/frontend-vue/src/pages/image-video/components/DeliveryVideoWorkspace.vue @@ -14,11 +14,12 @@
@@ -43,6 +44,10 @@ 输出分辨率
+
+ 模型 + +
视频时长
@@ -77,15 +82,14 @@ placeholder="例如:干净明亮的现代厨房背景,温暖阳光照入。" />
+ @url-change="(url) => setAssetUrl(activeTab, 'sceneAsset', url, 'image/*')" preview-clickable + @preview="openImagePreview" @clear="clearAsset(activeTab, 'sceneAsset')" />
@@ -101,6 +105,10 @@ 输出分辨率 +
+ 模型 + +
视频时长
@@ -138,17 +146,18 @@
- +
+ + +
人物类型 - +
@@ -194,21 +204,22 @@
- +
+ + +
- +
语言 @@ -219,6 +230,11 @@
+
+ 音频类型 + +
+
口播台词 @@ -290,6 +307,14 @@ + + +

快速生成预览视频,用于验证场景结构、镜头调度、主体动作和提示词意图,消耗更少 token。

+
+ +

关闭样片模式,按正常规格生成视频。

+
+
模式 @@ -310,8 +335,8 @@ :disabled="assemblyBusy || assemblyDownloading" @click="handleAssemblyPrimaryAction"> {{ assemblyPrimaryActionText }} - + 重置内容
@@ -319,7 +344,7 @@ :class="{ 'assembly-waiting--done': !assemblyBusy && assemblyExecuteId }">
- {{ assemblyPolling ? 'Coze 正在生成视频,请保持页面打开' : '工作流已提交' }} + {{ assemblyPolling ? '正在生成视频,请保持页面打开' : '工作流已提交' }} 执行 ID:{{ assemblyExecuteId }} 已查询 {{ assemblyPollCount }} 次,系统会每 5 秒自动刷新结果。
@@ -372,7 +397,7 @@
当前:{{ secretStatus.voiceGroupIdMasked - }}
+ }}
@@ -398,7 +423,8 @@ {{ filteredVoiceListItems.length }} / {{ voiceListItems.length }} 个音色
- + @@ -412,18 +438,24 @@
{{ item.sourceLabel }} + +
+ 音色名称 + +
+ action-label="上传克隆素材" selected-label="克隆素材" url-label="克隆素材链接" @select="setVoiceCloneAsset" + @url-change="setVoiceCloneUrl" @clear="clearVoiceCloneAsset" />
- -
- 待删除 voice_id - -
-
- -
-
@@ -476,7 +498,7 @@ import { type WorkspaceTab = 'remake' | 'imageToVideo' type PreviewKind = 'image' | 'video' | 'file' type MediaType = 'image' | 'video' | 'audio' | 'file' -type AssetKey = 'referenceAsset' | 'productAsset' | 'sceneAsset' | 'modelAsset' | 'speechAsset' +type AssetKey = 'referenceAsset' | 'sceneAsset' | 'modelAsset' | 'speechAsset' type VoiceMode = 'textOnly' | 'uploadAudio' | 'synthesize' type VoiceStatusType = 'info' | 'success' | 'error' @@ -503,12 +525,14 @@ type AssemblyState = { type WorkspaceState = { referenceAsset: AssetState - productAsset: AssetState + productAssets: AssetState[] sceneAsset: AssetState modelAsset: AssetState speechAsset: AssetState ratio: string resolution: string + model: string + generationMode: 'draft' | 'standard' duration: string durationSeconds: number videoPrompt: string @@ -518,6 +542,7 @@ type WorkspaceState = { gender: string modelFigure: string language: string + audioType: string voiceMode: VoiceMode voiceId: string voiceName: string @@ -546,12 +571,12 @@ const secretDialogVisible = ref(false) const secretSaving = ref(false) const secretStatus = ref(null) const voiceDialogVisible = ref(false) -const voiceDialogTab = ref<'list' | 'clone' | 'delete'>('list') +const voiceDialogTab = ref<'list' | 'clone'>('list') const voiceStatusMessage = ref('') const voiceStatusType = ref('info') const voiceListItems = ref([]) const voiceSearchKeyword = ref('') -const deleteVoiceId = ref('') +const voiceCloneName = ref('') const imagePreviewVisible = ref(false) const imagePreviewUrl = ref('') const currentAssembly = computed(() => currentWorkspace.value.assembly) @@ -572,9 +597,9 @@ const assemblyBusy = computed(() => currentAssembly.value.loading || currentAsse const canResetWorkspace = computed(() => Boolean( currentAssembly.value.executeId - || currentAssembly.value.resultText - || currentAssembly.value.videoUrl - || currentAssembly.value.debugUrl, + || currentAssembly.value.resultText + || currentAssembly.value.videoUrl + || currentAssembly.value.debugUrl, ), ) const assemblyPrimaryActionText = computed(() => { @@ -594,11 +619,20 @@ const secretForm = reactive({ const ratioOptions = ['9:16', '16:9', '3:4', '4:3', '1:1'] const resolutionOptions = ['480p', '720p', '1080p'] +const modelOptions = [ + { label: 'seedance-2.0-mini', value: 'doubao-seedance-2.0-mini' }, + { label: 'seedance-2.0-fast', value: 'sdols-2.0-fast' }, + { label: 'seedance-2.0', value: 'sdols-2.0' }, +] const durationOptions = ['15', '30', '45', '60'] const sceneModeOptions = ['提示词微调', '上传场景图替换'] const faceInputTypeOptions = ['性别选项', '上传模特图', '人物类型'] const genderOptions = ['女', '男'] const languageOptions = ['中文', '英语', '韩语', '粤语'] +const audioTypeOptions = [ + { label: '人物口播', value: '1' }, + { label: '背景旁白', value: '2' }, +] const voiceModeOptions = [ { label: '只提交文案', value: 'textOnly' }, { label: '上传/粘贴已有音频', value: 'uploadAudio' }, @@ -634,12 +668,14 @@ function createAssemblyState(): AssemblyState { function createWorkspaceState(): WorkspaceState { return { referenceAsset: createAssetState(), - productAsset: createAssetState(), + productAssets: [createAssetState()], sceneAsset: createAssetState(), modelAsset: createAssetState(), speechAsset: createAssetState(), ratio: '9:16', resolution: '720p', + model: 'sdols-2.0-fast', + generationMode: 'draft', duration: '15', durationSeconds: 15, videoPrompt: '', @@ -649,6 +685,7 @@ function createWorkspaceState(): WorkspaceState { gender: '女', modelFigure: '御姐', language: '中文', + audioType: '1', voiceMode: 'textOnly', voiceId: '', voiceName: '', @@ -682,8 +719,8 @@ const secretStatusText = computed(() => { function normalizeDuration(value: number | string) { const nextValue = Number(value) - if (Number.isFinite(nextValue) && nextValue > 0) return String(Math.round(nextValue)) - return '15' + if (Number.isFinite(nextValue) && nextValue > 0) return Math.round(nextValue) + return 15 } function setDurationPreset(value: string | string[]) { @@ -774,6 +811,54 @@ function clearAsset(tab: WorkspaceTab, assetKey: AssetKey) { scheduleDeliveryGridLayout() } +function addProductAsset(tab: WorkspaceTab) { + const productAssets = workspaces[tab].productAssets + if (productAssets.length < 5) productAssets.push(createAssetState()) + scheduleDeliveryGridLayout() +} + +function setProductAssetUrl(tab: WorkspaceTab, index: number, url: string) { + const target = workspaces[tab].productAssets[index] + if (!target) return + const normalizedUrl = (url || '').trim() + target.sourceUrl = normalizedUrl + target.previewUrl = normalizedUrl + target.fileName = normalizedUrl ? '在线素材' : '' + target.mediaType = resolveMediaTypeFromUrl(normalizedUrl, 'image/*') + target.previewKind = resolvePreviewKindFromMediaType(target.mediaType) + scheduleDeliveryGridLayout() +} + +async function setProductAsset(tab: WorkspaceTab, index: number, file: File) { + const target = workspaces[tab].productAssets[index] + if (!target) return + target.uploading = true + try { + const result = await uploadImageVideoMedia(file) + target.fileName = result.originalFilename || file.name + target.sourceUrl = result.url + target.previewUrl = result.url + target.mediaType = (result.mediaType || 'file') as MediaType + target.previewKind = resolvePreviewKindFromMediaType(target.mediaType) + ElMessage.success('产品图上传成功') + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : '产品图上传失败') + } finally { + target.uploading = false + scheduleDeliveryGridLayout() + } +} + +function clearProductAsset(tab: WorkspaceTab, index: number) { + const productAssets = workspaces[tab].productAssets + if (productAssets.length === 1) { + Object.assign(productAssets[0], createAssetState()) + } else { + productAssets.splice(index, 1) + } + scheduleDeliveryGridLayout() +} + function openImagePreview(url: string) { const normalizedUrl = (url || '').trim() if (!normalizedUrl) return @@ -867,17 +952,22 @@ async function rewriteScriptFromSource() { return } if (looksLikeJsonObject(url)) { - ElMessage.warning('请粘贴抖音分享口令或链接,不要粘贴接口返回内容') - return - } - if (!isLikelyDouyinSource(url)) { - ElMessage.warning('请粘贴包含抖音链接的分享口令') + ElMessage.warning('请粘贴视频链接或文案来源链接,不要粘贴接口返回内容') return } if (activeTab.value === 'remake') currentWorkspace.value.scriptSourceUrl = url copyLearningLoading.value = true try { - const result = await runImageVideoDouyinCopy(url) + const result = await runImageVideoDouyinCopy({ + url, + duration: Math.max(0, Math.round(Number(currentWorkspace.value.durationSeconds) || 0)), + proc_info: { + type: currentWorkspace.value.categoryName.trim(), + name: currentWorkspace.value.productName.trim(), + proc_image: resolveProductImageUrls(currentWorkspace.value), + properties: currentWorkspace.value.productFocus.trim(), + }, + }) const text = cleanCopyText(result.scriptDraft || result.recognizedContent || '') currentWorkspace.value.scriptText = text ElMessage.success('文案已提取') @@ -897,15 +987,6 @@ function looksLikeJsonObject(value: string) { return text.startsWith('{') && text.endsWith('}') } -function isLikelyDouyinSource(value: string) { - const text = value.trim().toLowerCase() - return text.includes('douyin.com') - || text.includes('v.douyin') - || text.includes('iesdouyin') - || text.includes('dou音') - || text.includes('抖音') -} - function cleanCopyText(value: string) { return value .replace(/^\s*https?:\/\/\S+\s*$/gm, '') @@ -916,8 +997,7 @@ function cleanCopyText(value: string) { } function resolveProductImageUrls(workspace: WorkspaceState) { - const productImageUrl = resolveAssetUrl(workspace.productAsset) - return productImageUrl ? [productImageUrl] : [] + return workspace.productAssets.map(resolveAssetUrl).filter(Boolean).slice(0, 5) } function resolveSpeechFileUrl(workspace: WorkspaceState) { @@ -954,27 +1034,8 @@ function resolveFaceInfo(workspace: WorkspaceState): ImageVideoWorkflowParameter return { type: 1, model_figure: workspace.gender, model_image: [] } } -function validateWorkspace(workspace: WorkspaceState) { - const productImages = resolveProductImageUrls(workspace) - const referenceVideo = resolveAssetUrl(workspace.referenceAsset) - if (activeTab.value === 'remake' && !referenceVideo) return '请先上传或填写参考视频' - if (!productImages.length) return '请先上传或填写产品图' - if (!workspace.categoryName.trim()) return '请先输入产品类目' - if (!workspace.productName.trim()) return '请先输入产品名称' - if (!workspace.productFocus.trim()) return '请先输入核心卖点/产品属性' - if (workspace.sceneMode === '上传场景图替换' && !resolveAssetUrl(workspace.sceneAsset)) return '请先上传或填写场景图' - if (workspace.faceInputType === '上传模特图' && !resolveAssetUrl(workspace.modelAsset)) return '请先上传或填写模特图' - return '' -} - function buildImageVideoWorkflowParameters(): ImageVideoWorkflowParameters | null { const workspace = currentWorkspace.value - const error = validateWorkspace(workspace) - if (error) { - ElMessage.warning(error) - return null - } - const referenceVideoInfo = resolveReferenceVideoInfo(workspace) const productImageUrls = resolveProductImageUrls(workspace) return { @@ -1000,12 +1061,17 @@ function buildImageVideoWorkflowParameters(): ImageVideoWorkflowParameters | nul text: workspace.scriptText.trim(), file_url: resolveSpeechFileUrl(workspace), }, + audio_info: { + audio_url: resolveSpeechFileUrl(workspace), + type: Number(workspace.audioType) || 1, + }, video_info: { video_url: referenceVideoInfo.videoUrl, share_url: referenceVideoInfo.shareUrl, ref_video_mode: referenceVideoInfo.refVideoMode, mode: activeTab.value === 'imageToVideo' ? '1' : '2', - model: 'sdols-2.0-fast', + draft: workspace.generationMode === 'draft', + model: workspace.model, prompt: workspace.videoPrompt.trim(), ratio: workspace.ratio, resolution: workspace.resolution, @@ -1140,7 +1206,7 @@ async function pollAssemblyResult(tab: WorkspaceTab, executeId: string) { return } - if (assembly.pollCount >= 120) { + if (assembly.pollCount >= 720) { assembly.polling = false ElMessage.warning('Coze 执行结果查询超时,请稍后通过 execute_id 查看') return @@ -1213,10 +1279,10 @@ function collectVoiceArrays(value: unknown): unknown[] { return } const record = node as Record - ;['system_voice', 'voice_cloning', 'voice_generation', 'music_generation'].forEach((key) => { - const item = record[key] - if (Array.isArray(item)) arrays.push(...item) - }) + ;['system_voice', 'voice_cloning', 'voice_generation', 'music_generation'].forEach((key) => { + const item = record[key] + if (Array.isArray(item)) arrays.push(...item) + }) Object.values(record).forEach(collect) } @@ -1456,9 +1522,9 @@ function extractVoiceItems(value: unknown): VoiceListItem[] { sourceConfigs.forEach((config) => { const direct = record[config.key] const nested = getRecordValue(record, ['data', config.key]) - ;[direct, nested].forEach((source) => { - if (Array.isArray(source)) source.forEach((item) => addVoice(item, config.key, config.label)) - }) + ;[direct, nested].forEach((source) => { + if (Array.isArray(source)) source.forEach((item) => addVoice(item, config.key, config.label)) + }) }) Object.values(record).forEach(collectFromKnownArrays) } @@ -1482,7 +1548,7 @@ function firstString(...values: unknown[]) { async function listVoices() { voiceLoading.value = true try { - const result = await listImageVideoVoices() + const result = await listImageVideoVoices(voiceSearchKeyword.value.trim()) voiceListItems.value = extractVoiceItems(result) setVoiceStatus( voiceListItems.value.length ? `已获取 ${voiceListItems.value.length} 个音色` : '音色接口已返回,未解析到列表项', @@ -1497,12 +1563,12 @@ async function listVoices() { } } -async function deleteVoice() { - const voiceId = deleteVoiceId.value.trim() || currentWorkspace.value.voiceId.trim() - if (!voiceId) { - ElMessage.warning('请先输入 voice_id') +async function deleteVoice(item: VoiceListItem) { + if (item.sourceKey === 'system_voice') { + ElMessage.warning('系统音色不支持删除') return } + const voiceId = item.voiceId try { await ElMessageBox.confirm(`确定删除音色 ${voiceId} 吗?`, '删除音色', { confirmButtonText: '删除', @@ -1528,6 +1594,11 @@ async function deleteVoice() { } async function cloneVoice() { + const name = voiceCloneName.value.trim() + if (!name) { + ElMessage.warning('请输入音色名称') + return + } const sourceUrl = resolveAssetUrl(voiceCloneAsset) if (!sourceUrl) { ElMessage.warning('请先上传或填写音频/视频链接') @@ -1536,10 +1607,11 @@ async function cloneVoice() { voiceLoading.value = true try { const clonePayload = { - audioUrl: voiceCloneAsset.mediaType === 'video' ? '' : sourceUrl, + audioUrl: sourceUrl, videoUrl: voiceCloneAsset.mediaType === 'video' ? sourceUrl : '', } const result = await cloneImageVideoVoice({ + name, audioUrl: clonePayload.audioUrl, videoUrl: clonePayload.videoUrl, }) @@ -1598,7 +1670,6 @@ async function synthesizeVoice() { function useVoice(item: VoiceListItem) { currentWorkspace.value.voiceId = item.voiceId currentWorkspace.value.voiceName = item.name || item.voiceId - deleteVoiceId.value = item.voiceId currentWorkspace.value.voiceMode = 'synthesize' setVoiceStatus(`已选择音色:${currentWorkspace.value.voiceName}(${item.voiceId})`, 'success') voiceDialogVisible.value = false @@ -1856,6 +1927,15 @@ onBeforeUnmount(() => { gap: 8px; } +.product-assets { + display: grid; + gap: 12px; +} + +.product-assets__add { + justify-self: start; +} + .scene-face-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -1956,6 +2036,10 @@ onBeforeUnmount(() => { padding-right: 4px; } +.voice-dialog__tabs :deep(.el-tabs__item:not(.is-active)) { + color: #fff; +} + .voice-dialog__toolbar { display: flex; justify-content: flex-end; @@ -1998,7 +2082,7 @@ onBeforeUnmount(() => { .voice-list__item { display: grid; - grid-template-columns: minmax(0, 1fr) auto auto; + grid-template-columns: minmax(0, 1fr) auto auto auto; gap: 10px; align-items: center; padding: 10px 12px; @@ -2049,6 +2133,29 @@ onBeforeUnmount(() => { background: #141414; } +.generation-mode-tabs :deep(.el-tabs__header) { + margin-bottom: 10px; +} + +.generation-mode-tabs :deep(.el-tabs__item) { + color: #f2f2f2; + font-weight: 700; +} + +.generation-mode-tabs :deep(.el-tabs__active-bar) { + background-color: #b99455; +} + +.generation-mode-tabs :deep(.el-tabs__content) { + color: #a8a8a8; + font-size: 12px; + line-height: 1.5; +} + +.generation-mode-tabs p { + margin: 0; +} + .preview-row { display: flex; justify-content: space-between; diff --git a/frontend-vue/src/shared/api/java-modules.ts b/frontend-vue/src/shared/api/java-modules.ts index 06db1d8..6582f00 100644 --- a/frontend-vue/src/shared/api/java-modules.ts +++ b/frontend-vue/src/shared/api/java-modules.ts @@ -2483,6 +2483,19 @@ export interface ImageVideoDouyinCopyVo { debugUrl?: string; } +export interface ImageVideoDouyinCopyPayload { + url: string; + api_key?: string; + t8_key?: string; + duration?: number; + proc_info?: { + type: string; + name: string; + proc_image: string[]; + properties: string; + }; +} + export interface ImageVideoSecretStatusVo { userId?: number; configured?: boolean; @@ -2535,16 +2548,21 @@ export interface ImageVideoWorkflowParameters { text: string; file_url: string; }; + audio_info: { + audio_url: string; + type: number; + }; video_info: { video_url: string; share_url: string; ref_video_mode: string; mode: string; + draft: boolean; model: string; prompt: string; ratio: string; resolution: string; - duration: string; + duration: number; }; } @@ -2601,11 +2619,11 @@ export function saveImageVideoSecrets(payload: ImageVideoSecretSavePayload) { ); } -export function runImageVideoDouyinCopy(url: string) { +export function runImageVideoDouyinCopy(payload: ImageVideoDouyinCopyPayload) { return unwrapJavaResponse( - post, { userId: number; url: string }>( + post, ImageVideoDouyinCopyPayload & { userId: number }>( `${JAVA_API_PREFIX}/image-video/douyin-copy`, - { userId: getCurrentUserId(), url }, + { userId: getCurrentUserId(), ...payload }, { timeout: 180000 }, ), ); @@ -2616,7 +2634,7 @@ export function runImageVideoWorkflow(parameters: ImageVideoWorkflowParameters) post, ImageVideoWorkflowRunRequest>( `${JAVA_API_PREFIX}/image-video/workflow/run`, { userId: getCurrentUserId(), parameters }, - { timeout: 120000 }, + { timeout: 3600000 }, ), ); } @@ -2626,7 +2644,7 @@ export function getImageVideoWorkflowResult(executeId: string) { post, ImageVideoWorkflowResultRequest>( `${JAVA_API_PREFIX}/image-video/workflow/result`, { userId: getCurrentUserId(), executeId }, - { timeout: 600000 }, + { timeout: 3600000 }, ), ); } @@ -2660,11 +2678,11 @@ function resolveImageVideoMediaType(file: File) { return "file"; } -export function listImageVideoVoices() { +export function listImageVideoVoices(name = "") { return unwrapJavaResponse( - post, { userId: number }>( + post, { userId: number; name: string }>( `${JAVA_API_PREFIX}/image-video/voice/list`, - { userId: getCurrentUserId() }, + { userId: getCurrentUserId(), name }, { timeout: 180000 }, ), ); @@ -2680,9 +2698,9 @@ export function deleteImageVideoVoice(voiceId: string) { ); } -export function cloneImageVideoVoice(payload: { audioUrl?: string; videoUrl?: string }) { +export function cloneImageVideoVoice(payload: { name?: string; audioUrl?: string; videoUrl?: string }) { return unwrapJavaResponse( - post, { userId: number; audioUrl?: string; videoUrl?: string }>( + post, { userId: number; name?: string; audioUrl?: string; videoUrl?: string }>( `${JAVA_API_PREFIX}/image-video/voice/clone`, { userId: getCurrentUserId(), ...payload }, { timeout: 180000 },