完成视频部分的前后端
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -53,7 +53,7 @@ public class ImageVideoController {
|
||||
@PostMapping("/douyin-copy")
|
||||
@Operation(summary = "抖音文案识别和仿写")
|
||||
public ApiResponse<DouyinCopyVo> 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<Map<String, Object>> 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<Map<String, Object>> 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")
|
||||
|
||||
@@ -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<String> procImage = new ArrayList<>();
|
||||
|
||||
@Schema(description = "核心卖点或产品属性", example = "优雅、亚麻、白色")
|
||||
private String properties = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,4 +7,6 @@ import lombok.Data;
|
||||
public class ImageVideoVoiceListRequest {
|
||||
@NotNull(message = "userId 不能为空")
|
||||
private Long userId;
|
||||
|
||||
private String name;
|
||||
}
|
||||
|
||||
@@ -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<String> 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<String, Object> 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<String, Object> 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<String> 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<String, Object> getImageVideoWorkflowResult(Long userId, String executeId) {
|
||||
String resolvedExecuteId = firstNonBlank(executeId);
|
||||
if (!hasText(resolvedExecuteId)) {
|
||||
@@ -210,10 +235,12 @@ public class ImageVideoCozeService {
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, Object> runVoiceList(Long userId) {
|
||||
public Map<String, Object> runVoiceList(Long userId, String name) {
|
||||
ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId);
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> runVoiceClone(Long userId, String audioUrl, String videoUrl) {
|
||||
String resolvedAudioUrl = firstNonBlank(audioUrl);
|
||||
public Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> normalizeDouyinCopyProcInfo(DouyinCopyRequest.ProcInfo procInfo) {
|
||||
Map<String, Object> 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<String> images = normalizeStringList(procInfo.getProcImage());
|
||||
normalized.put("proc_image", images);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private Map<String, Object> postWorkflowParameters(String label, String workflowId, Map<String, Object> parameters,
|
||||
String token, boolean async) {
|
||||
String cozeToken = firstNonBlank(token);
|
||||
|
||||
@@ -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<ImageVideoSecretEntity>()
|
||||
.eq(ImageVideoSecretEntity::getUserId, userId)
|
||||
|
||||
@@ -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:}
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
<AiSectionCard v-if="activeTab === 'remake'" step="01" title="参考视频" description="本地视频或抖音链接,作为复刻依据。"
|
||||
class="delivery-card">
|
||||
<AiAssetDropzone title="参考视频" description="上传后会先转为 OSS 地址。" placeholder="上传本地视频"
|
||||
helper="支持 MP4 / MOV,也可在下方粘贴抖音/视频链接" accept="video/*" :file-name="currentWorkspace.referenceAsset.fileName"
|
||||
helper="支持 MP4 / MOV,也可在下方粘贴抖音/视频链接" accept="video/*"
|
||||
:file-name="currentWorkspace.referenceAsset.fileName"
|
||||
:preview-url="currentWorkspace.referenceAsset.previewUrl"
|
||||
:preview-kind="currentWorkspace.referenceAsset.previewKind"
|
||||
:source-url="currentWorkspace.referenceAsset.sourceUrl" :loading="currentWorkspace.referenceAsset.uploading"
|
||||
compact action-label="上传参考视频" selected-label="参考视频"
|
||||
:source-url="currentWorkspace.referenceAsset.sourceUrl"
|
||||
:loading="currentWorkspace.referenceAsset.uploading" compact action-label="上传参考视频" selected-label="参考视频"
|
||||
@select="(file) => setAsset(activeTab, 'referenceAsset', file)"
|
||||
@clear="clearAsset(activeTab, 'referenceAsset')" />
|
||||
<div class="script-learning-panel__toolbar">
|
||||
@@ -43,6 +44,10 @@
|
||||
<span class="delivery-field__label">输出分辨率</span>
|
||||
<AiChoicePills v-model="currentWorkspace.resolution" :options="resolutionOptions" />
|
||||
</div>
|
||||
<div class="delivery-field">
|
||||
<span class="delivery-field__label">模型</span>
|
||||
<AiChoicePills v-model="currentWorkspace.model" :options="modelOptions" />
|
||||
</div>
|
||||
<div class="delivery-field">
|
||||
<span class="delivery-field__label">视频时长</span>
|
||||
<div class="duration-control">
|
||||
@@ -77,15 +82,14 @@
|
||||
placeholder="例如:干净明亮的现代厨房背景,温暖阳光照入。" />
|
||||
</div>
|
||||
<AiAssetDropzone v-if="currentWorkspace.sceneMode === '上传场景图替换'" title="场景图" description="上传后会先转为 OSS 地址。"
|
||||
placeholder="上传场景图" helper="支持 JPG / PNG" accept="image/*" :file-name="currentWorkspace.sceneAsset.fileName"
|
||||
:preview-url="currentWorkspace.sceneAsset.previewUrl" :preview-kind="currentWorkspace.sceneAsset.previewKind"
|
||||
:source-url="currentWorkspace.sceneAsset.sourceUrl" :loading="currentWorkspace.sceneAsset.uploading" allow-url
|
||||
compact action-label="上传场景图" selected-label="场景图" url-label="场景图链接" url-placeholder="也可以粘贴场景图链接"
|
||||
placeholder="上传场景图" helper="支持 JPG / PNG" accept="image/*"
|
||||
:file-name="currentWorkspace.sceneAsset.fileName" :preview-url="currentWorkspace.sceneAsset.previewUrl"
|
||||
:preview-kind="currentWorkspace.sceneAsset.previewKind"
|
||||
:source-url="currentWorkspace.sceneAsset.sourceUrl" :loading="currentWorkspace.sceneAsset.uploading"
|
||||
allow-url compact action-label="上传场景图" selected-label="场景图" url-label="场景图链接" url-placeholder="也可以粘贴场景图链接"
|
||||
@select="(file) => setAsset(activeTab, 'sceneAsset', file)"
|
||||
@url-change="(url) => setAssetUrl(activeTab, 'sceneAsset', url, 'image/*')"
|
||||
preview-clickable
|
||||
@preview="openImagePreview"
|
||||
@clear="clearAsset(activeTab, 'sceneAsset')" />
|
||||
@url-change="(url) => setAssetUrl(activeTab, 'sceneAsset', url, 'image/*')" preview-clickable
|
||||
@preview="openImagePreview" @clear="clearAsset(activeTab, 'sceneAsset')" />
|
||||
</AiSectionCard>
|
||||
</div>
|
||||
|
||||
@@ -101,6 +105,10 @@
|
||||
<span class="delivery-field__label">输出分辨率</span>
|
||||
<AiChoicePills v-model="currentWorkspace.resolution" :options="resolutionOptions" />
|
||||
</div>
|
||||
<div class="delivery-field">
|
||||
<span class="delivery-field__label">模型</span>
|
||||
<AiChoicePills v-model="currentWorkspace.model" :options="modelOptions" />
|
||||
</div>
|
||||
<div class="delivery-field">
|
||||
<span class="delivery-field__label">视频时长</span>
|
||||
<div class="duration-control">
|
||||
@@ -138,17 +146,18 @@
|
||||
<el-input v-model="currentWorkspace.productFocus" type="textarea" :rows="4" resize="none"
|
||||
placeholder="例如:防滑耐磨、复古网面透气设计。" />
|
||||
</div>
|
||||
<AiAssetDropzone title="产品图" description="上传后会先转为 OSS 地址,提交时按接口数组格式传入。" placeholder="上传产品图" helper="支持 JPG / PNG"
|
||||
accept="image/*" :file-name="currentWorkspace.productAsset.fileName"
|
||||
:preview-url="currentWorkspace.productAsset.previewUrl"
|
||||
:preview-kind="currentWorkspace.productAsset.previewKind"
|
||||
:source-url="currentWorkspace.productAsset.sourceUrl" :loading="currentWorkspace.productAsset.uploading"
|
||||
allow-url compact preview-clickable action-label="上传产品图" selected-label="产品图" url-label="产品图链接"
|
||||
url-placeholder="也可以粘贴产品图链接"
|
||||
@select="(file) => setAsset(activeTab, 'productAsset', file)"
|
||||
@url-change="(url) => setAssetUrl(activeTab, 'productAsset', url, 'image/*')"
|
||||
@preview="openImagePreview"
|
||||
@clear="clearAsset(activeTab, 'productAsset')" />
|
||||
<div class="product-assets">
|
||||
<AiAssetDropzone v-for="(asset, index) in currentWorkspace.productAssets" :key="index" :title="`产品图 ${index + 1}`"
|
||||
:description="index === 0 ? '最多上传 5 张,提交时按接口数组格式传入。' : ''" placeholder="上传产品图"
|
||||
helper="支持 JPG / PNG" accept="image/*" :file-name="asset.fileName" :preview-url="asset.previewUrl"
|
||||
:preview-kind="asset.previewKind" :source-url="asset.sourceUrl" :loading="asset.uploading" allow-url compact
|
||||
preview-clickable action-label="上传产品图" selected-label="产品图" url-label="产品图链接"
|
||||
url-placeholder="也可以粘贴产品图链接" @select="(file) => setProductAsset(activeTab, index, file)"
|
||||
@url-change="(url) => setProductAssetUrl(activeTab, index, url)" @preview="openImagePreview"
|
||||
@clear="clearProductAsset(activeTab, index)" />
|
||||
<button v-if="currentWorkspace.productAssets.length < 5" type="button" class="script-action-btn product-assets__add"
|
||||
@click="addProductAsset(activeTab)">添加产品图({{ currentWorkspace.productAssets.length }}/5)</button>
|
||||
</div>
|
||||
</AiSectionCard>
|
||||
|
||||
<AiSectionCard :step="activeTab === 'remake' ? '05' : '04'" title="人脸" description="性别、模特图或人物类型三选一。"
|
||||
@@ -163,16 +172,17 @@
|
||||
</div>
|
||||
<div v-if="currentWorkspace.faceInputType === '人物类型'" class="delivery-field">
|
||||
<span class="delivery-field__label">人物类型</span>
|
||||
<el-input v-model="currentWorkspace.modelFigure" clearable placeholder="输入人物类型,例如:甜妹 / 御姐 / 成熟女性 / 职场女性" />
|
||||
<el-input v-model="currentWorkspace.modelFigure" clearable
|
||||
placeholder="输入人物类型,例如:甜妹 / 御姐 / 成熟女性 / 职场女性" />
|
||||
</div>
|
||||
<AiAssetDropzone v-if="currentWorkspace.faceInputType === '上传模特图'" title="模特图 / 人脸图"
|
||||
description="上传后会先转为 OSS 地址。" placeholder="上传清晰正脸图" helper="建议 1080px 以上,单人正面" accept="image/*"
|
||||
:file-name="currentWorkspace.modelAsset.fileName" :preview-url="currentWorkspace.modelAsset.previewUrl"
|
||||
:preview-kind="currentWorkspace.modelAsset.previewKind" :source-url="currentWorkspace.modelAsset.sourceUrl"
|
||||
:loading="currentWorkspace.modelAsset.uploading" allow-url compact preview-clickable action-label="上传模特图" selected-label="模特图"
|
||||
url-label="模特图链接" url-placeholder="也可以粘贴模特图链接" @select="(file) => setAsset(activeTab, 'modelAsset', file)"
|
||||
@url-change="(url) => setAssetUrl(activeTab, 'modelAsset', url, 'image/*')"
|
||||
@preview="openImagePreview"
|
||||
:preview-kind="currentWorkspace.modelAsset.previewKind"
|
||||
:source-url="currentWorkspace.modelAsset.sourceUrl" :loading="currentWorkspace.modelAsset.uploading"
|
||||
allow-url compact preview-clickable action-label="上传模特图" selected-label="模特图" url-label="模特图链接"
|
||||
url-placeholder="也可以粘贴模特图链接" @select="(file) => setAsset(activeTab, 'modelAsset', file)"
|
||||
@url-change="(url) => setAssetUrl(activeTab, 'modelAsset', url, 'image/*')" @preview="openImagePreview"
|
||||
@clear="clearAsset(activeTab, 'modelAsset')" />
|
||||
</AiSectionCard>
|
||||
|
||||
@@ -194,21 +204,22 @@
|
||||
<el-input v-model="currentWorkspace.productFocus" type="textarea" :rows="4" resize="none"
|
||||
placeholder="例如:防滑耐磨、复古网面透气设计。" />
|
||||
</div>
|
||||
<AiAssetDropzone title="产品图" description="上传后会先转为 OSS 地址,提交时按接口数组格式传入。" placeholder="上传产品图" helper="支持 JPG / PNG"
|
||||
accept="image/*" :file-name="currentWorkspace.productAsset.fileName"
|
||||
:preview-url="currentWorkspace.productAsset.previewUrl"
|
||||
:preview-kind="currentWorkspace.productAsset.previewKind"
|
||||
:source-url="currentWorkspace.productAsset.sourceUrl" :loading="currentWorkspace.productAsset.uploading"
|
||||
allow-url compact preview-clickable action-label="上传产品图" selected-label="产品图" url-label="产品图链接"
|
||||
url-placeholder="也可以粘贴产品图链接"
|
||||
@select="(file) => setAsset(activeTab, 'productAsset', file)"
|
||||
@url-change="(url) => setAssetUrl(activeTab, 'productAsset', url, 'image/*')"
|
||||
@preview="openImagePreview"
|
||||
@clear="clearAsset(activeTab, 'productAsset')" />
|
||||
<div class="product-assets">
|
||||
<AiAssetDropzone v-for="(asset, index) in currentWorkspace.productAssets" :key="index" :title="`产品图 ${index + 1}`"
|
||||
:description="index === 0 ? '最多上传 5 张,提交时按接口数组格式传入。' : ''" placeholder="上传产品图"
|
||||
helper="支持 JPG / PNG" accept="image/*" :file-name="asset.fileName" :preview-url="asset.previewUrl"
|
||||
:preview-kind="asset.previewKind" :source-url="asset.sourceUrl" :loading="asset.uploading" allow-url compact
|
||||
preview-clickable action-label="上传产品图" selected-label="产品图" url-label="产品图链接"
|
||||
url-placeholder="也可以粘贴产品图链接" @select="(file) => setProductAsset(activeTab, index, file)"
|
||||
@url-change="(url) => setProductAssetUrl(activeTab, index, url)" @preview="openImagePreview"
|
||||
@clear="clearProductAsset(activeTab, index)" />
|
||||
<button v-if="currentWorkspace.productAssets.length < 5" type="button" class="script-action-btn product-assets__add"
|
||||
@click="addProductAsset(activeTab)">添加产品图({{ currentWorkspace.productAssets.length }}/5)</button>
|
||||
</div>
|
||||
</AiSectionCard>
|
||||
|
||||
<AiSectionCard :step="activeTab === 'remake' ? '06' : '05'" title="话术 / 语音"
|
||||
description="编辑口播台词,选择最终配音方式。" class="delivery-card">
|
||||
<AiSectionCard :step="activeTab === 'remake' ? '06' : '05'" title="话术 / 语音" description="编辑口播台词,选择最终配音方式。"
|
||||
class="delivery-card">
|
||||
<div class="voice-section">
|
||||
<div class="delivery-field">
|
||||
<span class="delivery-field__label">语言</span>
|
||||
@@ -219,6 +230,11 @@
|
||||
<button type="button" class="script-action-btn voice-manage-btn" @click="openVoiceDialog">音色管理</button>
|
||||
</div>
|
||||
|
||||
<div class="delivery-field">
|
||||
<span class="delivery-field__label">音频类型</span>
|
||||
<AiChoicePills v-model="currentWorkspace.audioType" :options="audioTypeOptions" />
|
||||
</div>
|
||||
|
||||
<div class="delivery-field">
|
||||
<span class="delivery-field__label">口播台词</span>
|
||||
<el-input v-model="currentWorkspace.scriptText" type="textarea" :rows="6" resize="none"
|
||||
@@ -247,9 +263,10 @@
|
||||
<AiAssetDropzone title="最终配音文件" description="对应 text_info.file_url;只放最终用于视频配音的音频。" placeholder="上传音频文件"
|
||||
helper="支持 MP3 / WAV,也可粘贴可访问的音频链接" accept="audio/*" :file-name="currentWorkspace.speechAsset.fileName"
|
||||
:preview-url="currentWorkspace.speechAsset.previewUrl"
|
||||
:preview-kind="currentWorkspace.speechAsset.previewKind" :source-url="currentWorkspace.speechAsset.sourceUrl"
|
||||
:loading="currentWorkspace.speechAsset.uploading" allow-url compact action-label="上传音频" selected-label="最终配音"
|
||||
url-label="最终配音文件链接" @select="(file) => setAsset(activeTab, 'speechAsset', file)"
|
||||
:preview-kind="currentWorkspace.speechAsset.previewKind"
|
||||
:source-url="currentWorkspace.speechAsset.sourceUrl" :loading="currentWorkspace.speechAsset.uploading"
|
||||
allow-url compact action-label="上传音频" selected-label="最终配音" url-label="最终配音文件链接"
|
||||
@select="(file) => setAsset(activeTab, 'speechAsset', file)"
|
||||
@url-change="(url) => setAssetUrl(activeTab, 'speechAsset', url, 'audio/*')"
|
||||
@clear="clearAsset(activeTab, 'speechAsset')" />
|
||||
</template>
|
||||
@@ -290,6 +307,14 @@
|
||||
|
||||
<AiSectionCard :step="activeTab === 'remake' ? '07' : '06'" title="视频结果" description="提交后在这里等待、预览和下载成片。"
|
||||
class="delivery-card delivery-card--preview">
|
||||
<el-tabs v-model="currentWorkspace.generationMode" class="generation-mode-tabs">
|
||||
<el-tab-pane label="样片模式" name="draft">
|
||||
<p>快速生成预览视频,用于验证场景结构、镜头调度、主体动作和提示词意图,消耗更少 token。</p>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="正常生成" name="standard">
|
||||
<p>关闭样片模式,按正常规格生成视频。</p>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
<div class="preview-panel__meta">
|
||||
<div class="preview-row">
|
||||
<span class="preview-row__label">模式</span>
|
||||
@@ -310,8 +335,8 @@
|
||||
:disabled="assemblyBusy || assemblyDownloading" @click="handleAssemblyPrimaryAction">
|
||||
{{ assemblyPrimaryActionText }}
|
||||
</el-button>
|
||||
<el-button v-if="canResetWorkspace" class="delivery-secondary-btn" :disabled="assemblyBusy || assemblyDownloading"
|
||||
@click="resetCurrentWorkspace">
|
||||
<el-button v-if="canResetWorkspace" class="delivery-secondary-btn"
|
||||
:disabled="assemblyBusy || assemblyDownloading" @click="resetCurrentWorkspace">
|
||||
重置内容
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -319,7 +344,7 @@
|
||||
:class="{ 'assembly-waiting--done': !assemblyBusy && assemblyExecuteId }">
|
||||
<div class="assembly-waiting__spinner" aria-hidden="true"></div>
|
||||
<div class="assembly-waiting__content">
|
||||
<strong>{{ assemblyPolling ? 'Coze 正在生成视频,请保持页面打开' : '工作流已提交' }}</strong>
|
||||
<strong>{{ assemblyPolling ? '正在生成视频,请保持页面打开' : '工作流已提交' }}</strong>
|
||||
<span v-if="assemblyExecuteId">执行 ID:{{ assemblyExecuteId }}</span>
|
||||
<span v-if="assemblyPolling">已查询 {{ assemblyPollCount }} 次,系统会每 5 秒自动刷新结果。</span>
|
||||
</div>
|
||||
@@ -372,7 +397,7 @@
|
||||
<el-form-item label="音色分组 ID">
|
||||
<el-input v-model="secretForm.voiceGroupId" type="password" show-password placeholder="不填写则保留当前有效值" />
|
||||
<div v-if="secretStatus?.voiceGroupIdMasked" class="secret-form__hint">当前:{{ secretStatus.voiceGroupIdMasked
|
||||
}}</div>
|
||||
}}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="有效期">
|
||||
<el-select v-model="secretForm.expireDays" class="delivery-select">
|
||||
@@ -398,7 +423,8 @@
|
||||
<strong>{{ filteredVoiceListItems.length }}</strong>
|
||||
<span>/ {{ voiceListItems.length }} 个音色</span>
|
||||
</div>
|
||||
<el-input v-model="voiceSearchKeyword" class="voice-search-input" clearable placeholder="搜索音色名称或 voice_id" />
|
||||
<el-input v-model="voiceSearchKeyword" class="voice-search-input" clearable
|
||||
placeholder="搜索音色名称或 voice_id" @keyup.enter="listVoices" @clear="listVoices" />
|
||||
<button type="button" class="script-primary-action-btn" :disabled="voiceLoading" @click="listVoices">
|
||||
刷新音色
|
||||
</button>
|
||||
@@ -412,18 +438,24 @@
|
||||
</div>
|
||||
<span class="voice-list__source">{{ item.sourceLabel }}</span>
|
||||
<button type="button" class="script-action-btn" @click="useVoice(item)">使用此音色</button>
|
||||
<button v-if="item.sourceKey !== 'system_voice'" type="button" class="script-action-btn script-action-btn--danger"
|
||||
:disabled="voiceLoading" @click="deleteVoice(item)">删除音色</button>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无可展示音色,请确认接口已返回音色数据" />
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="克隆音色" name="clone">
|
||||
<div class="delivery-field">
|
||||
<span class="delivery-field__label">音色名称</span>
|
||||
<el-input v-model="voiceCloneName" clearable placeholder="输入克隆后的音色名称" />
|
||||
</div>
|
||||
<AiAssetDropzone title="克隆素材" description="仅用于音色克隆,不会写入 text_info.file_url。" placeholder="上传音频或视频"
|
||||
helper="支持 MP3 / WAV / MP4,或粘贴素材链接" accept="audio/*,video/*" :file-name="voiceCloneAsset.fileName"
|
||||
:preview-url="voiceCloneAsset.previewUrl" :preview-kind="voiceCloneAsset.previewKind"
|
||||
:source-url="voiceCloneAsset.sourceUrl" :loading="voiceCloneAsset.uploading" allow-url compact
|
||||
action-label="上传克隆素材" selected-label="克隆素材" url-label="克隆素材链接"
|
||||
@select="setVoiceCloneAsset" @url-change="setVoiceCloneUrl" @clear="clearVoiceCloneAsset" />
|
||||
action-label="上传克隆素材" selected-label="克隆素材" url-label="克隆素材链接" @select="setVoiceCloneAsset"
|
||||
@url-change="setVoiceCloneUrl" @clear="clearVoiceCloneAsset" />
|
||||
<div class="voice-dialog__toolbar">
|
||||
<button type="button" class="script-primary-action-btn" :disabled="voiceLoading" @click="cloneVoice">
|
||||
开始克隆
|
||||
@@ -431,16 +463,6 @@
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="删除音色" name="delete">
|
||||
<div class="delivery-field">
|
||||
<span class="delivery-field__label">待删除 voice_id</span>
|
||||
<el-input v-model="deleteVoiceId" clearable placeholder="输入 voice_id,或从音色列表选择后再删除" />
|
||||
</div>
|
||||
<div class="voice-dialog__toolbar">
|
||||
<button type="button" class="script-action-btn script-action-btn--danger" :disabled="voiceLoading"
|
||||
@click="deleteVoice">确认删除音色</button>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-dialog>
|
||||
|
||||
@@ -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<ImageVideoSecretStatusVo | null>(null)
|
||||
const voiceDialogVisible = ref(false)
|
||||
const voiceDialogTab = ref<'list' | 'clone' | 'delete'>('list')
|
||||
const voiceDialogTab = ref<'list' | 'clone'>('list')
|
||||
const voiceStatusMessage = ref('')
|
||||
const voiceStatusType = ref<VoiceStatusType>('info')
|
||||
const voiceListItems = ref<VoiceListItem[]>([])
|
||||
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<string, unknown>
|
||||
;['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;
|
||||
|
||||
@@ -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<JavaApiResponse<ImageVideoDouyinCopyVo>, { userId: number; url: string }>(
|
||||
post<JavaApiResponse<ImageVideoDouyinCopyVo>, 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<JavaApiResponse<ImageVideoWorkflowResponse>, 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<JavaApiResponse<ImageVideoWorkflowResponse>, 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<JavaApiResponse<ImageVideoVoiceWorkflowResponse>, { userId: number }>(
|
||||
post<JavaApiResponse<ImageVideoVoiceWorkflowResponse>, { 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<JavaApiResponse<ImageVideoVoiceWorkflowResponse>, { userId: number; audioUrl?: string; videoUrl?: string }>(
|
||||
post<JavaApiResponse<ImageVideoVoiceWorkflowResponse>, { userId: number; name?: string; audioUrl?: string; videoUrl?: string }>(
|
||||
`${JAVA_API_PREFIX}/image-video/voice/clone`,
|
||||
{ userId: getCurrentUserId(), ...payload },
|
||||
{ timeout: 180000 },
|
||||
|
||||
Reference in New Issue
Block a user