完成视频部分的前后端

This commit is contained in:
super
2026-07-11 19:42:42 +08:00
parent aab4ce942c
commit d8ca8de537
12 changed files with 432 additions and 169 deletions

View File

@@ -17,5 +17,5 @@ public class ImageVideoProperties {
private String voiceCloneWorkflowId = "7652954356887961641"; private String voiceCloneWorkflowId = "7652954356887961641";
private String voiceSynthesisWorkflowId = "7652954297530105894"; private String voiceSynthesisWorkflowId = "7652954297530105894";
private int cozeConnectTimeoutMillis = 10000; private int cozeConnectTimeoutMillis = 10000;
private int cozeReadTimeoutMillis = 600000; private int cozeReadTimeoutMillis = 3600000;
} }

View File

@@ -10,6 +10,7 @@ public class OssProperties {
private String endpoint; private String endpoint;
private String publicEndpoint; private String publicEndpoint;
private String bucket; private String bucket;
private String imageVideoBucket;
private String accessKeyId; private String accessKeyId;
private String accessKeySecret; private String accessKeySecret;
} }

View File

@@ -38,10 +38,11 @@ public class OssStorageService {
public UploadedResult uploadResultFileWithFreshDownloadUrl(File file, String moduleType) { public UploadedResult uploadResultFileWithFreshDownloadUrl(File file, String moduleType) {
String objectKey = String.format("result/%s/%s/%s", moduleType.toLowerCase(), UUID.randomUUID(), file.getName()); String objectKey = String.format("result/%s/%s/%s", moduleType.toLowerCase(), UUID.randomUUID(), file.getName());
String bucket = resolveBucket(moduleType);
OSS ossClient = buildClient(); OSS ossClient = buildClient();
try { try {
ossClient.putObject(ossProperties.getBucket(), objectKey, file); ossClient.putObject(bucket, objectKey, file);
return new UploadedResult(objectKey, getPublicUrl(objectKey)); return new UploadedResult(objectKey, getPublicUrl(objectKey, bucket));
} finally { } finally {
ossClient.shutdown(); ossClient.shutdown();
} }
@@ -56,10 +57,11 @@ public class OssStorageService {
objectName = file.getName(); objectName = file.getName();
} }
String objectKey = String.format("upload/%s/%s/%s", normalizedModuleType, UUID.randomUUID(), objectName); String objectKey = String.format("upload/%s/%s/%s", normalizedModuleType, UUID.randomUUID(), objectName);
String bucket = resolveBucket(moduleType);
OSS ossClient = buildClient(); OSS ossClient = buildClient();
try { try {
ossClient.putObject(ossProperties.getBucket(), objectKey, file); ossClient.putObject(bucket, objectKey, file);
return new UploadedResult(objectKey, getPublicUrl(objectKey)); return new UploadedResult(objectKey, getPublicUrl(objectKey, bucket));
} finally { } finally {
ossClient.shutdown(); ossClient.shutdown();
} }
@@ -151,12 +153,16 @@ public class OssStorageService {
* 获取公开无签名URL格式https://{bucket}.{endpoint}/{objectKey} * 获取公开无签名URL格式https://{bucket}.{endpoint}/{objectKey}
*/ */
public String getPublicUrl(String value) { public String getPublicUrl(String value) {
return getPublicUrl(value, ossProperties.getBucket());
}
private String getPublicUrl(String value, String bucket) {
if (value == null || value.isBlank()) { if (value == null || value.isBlank()) {
return null; return null;
} }
String objectKey = resolveObjectKey(value); String objectKey = resolveObjectKey(value);
String normalizedKey = objectKey.startsWith("/") ? objectKey.substring(1) : objectKey; 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 { try {
return new URI("https", host, "/" + normalizedKey, null).toASCIIString(); return new URI("https", host, "/" + normalizedKey, null).toASCIIString();
} catch (Exception ignored) { } 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() { private String publicEndpoint() {
String endpoint = ossProperties.getPublicEndpoint(); String endpoint = ossProperties.getPublicEndpoint();
if (endpoint == null || endpoint.isBlank()) { if (endpoint == null || endpoint.isBlank()) {

View File

@@ -53,7 +53,7 @@ public class ImageVideoController {
@PostMapping("/douyin-copy") @PostMapping("/douyin-copy")
@Operation(summary = "抖音文案识别和仿写") @Operation(summary = "抖音文案识别和仿写")
public ApiResponse<DouyinCopyVo> douyinCopy(@Valid @RequestBody DouyinCopyRequest request) { 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") @PostMapping("/workflow/run")
@@ -77,7 +77,7 @@ public class ImageVideoController {
@PostMapping("/voice/list") @PostMapping("/voice/list")
@Operation(summary = "查看音色") @Operation(summary = "查看音色")
public ApiResponse<Map<String, Object>> listVoices(@Valid @RequestBody ImageVideoVoiceListRequest request) { 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") @PostMapping("/voice/delete")
@@ -89,7 +89,7 @@ public class ImageVideoController {
@PostMapping("/voice/clone") @PostMapping("/voice/clone")
@Operation(summary = "音色克隆") @Operation(summary = "音色克隆")
public ApiResponse<Map<String, Object>> cloneVoice(@Valid @RequestBody ImageVideoVoiceCloneRequest request) { 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") @PostMapping("/voice/synthesis")

View File

@@ -1,19 +1,58 @@
package com.nanri.aiimage.modules.imagevideo.model.dto; 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 io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data; import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Data @Data
@Schema(description = "抖音文案解析和仿写请求") @Schema(description = "抖音文案解析和仿写请求")
public class DouyinCopyRequest { public class DouyinCopyRequest {
@NotNull(message = "userId 不能为空")
@Schema(description = "用户ID", example = "1") @Schema(description = "用户ID", example = "1")
private Long userId; 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 不能为空") @NotBlank(message = "url 不能为空")
@Schema(description = "抖音分享口令、短链或视频链接", example = "2.38 复制打开抖音,看看") @Schema(description = "抖音分享口令、短链或视频链接", example = "2.38 复制打开抖音,看看")
private String url; 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 = "";
}
} }

View File

@@ -7,6 +7,7 @@ import lombok.Data;
public class ImageVideoVoiceCloneRequest { public class ImageVideoVoiceCloneRequest {
@NotNull(message = "userId 不能为空") @NotNull(message = "userId 不能为空")
private Long userId; private Long userId;
private String name;
private String audioUrl; private String audioUrl;
private String videoUrl; private String videoUrl;
} }

View File

@@ -7,4 +7,6 @@ import lombok.Data;
public class ImageVideoVoiceListRequest { public class ImageVideoVoiceListRequest {
@NotNull(message = "userId 不能为空") @NotNull(message = "userId 不能为空")
private Long userId; private Long userId;
private String name;
} }

View File

@@ -6,6 +6,7 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.ImageVideoProperties; import com.nanri.aiimage.config.ImageVideoProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService; 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.ImageVideoMediaUploadVo;
import com.nanri.aiimage.modules.imagevideo.model.vo.DouyinCopyVo; import com.nanri.aiimage.modules.imagevideo.model.vo.DouyinCopyVo;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -25,6 +26,7 @@ import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
@Slf4j @Slf4j
@Service @Service
@@ -39,6 +41,9 @@ public class ImageVideoCozeService {
"scriptDraft", "script_draft", "rewrittenContent", "rewritten_content", "scriptDraft", "script_draft", "rewrittenContent", "rewritten_content",
"fullContent", "full_content", "copywriting", "script", "output", "text", "result" "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 ImageVideoProperties properties;
private final ImageVideoSecretService secretService; private final ImageVideoSecretService secretService;
@@ -47,20 +52,23 @@ public class ImageVideoCozeService {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private volatile HttpClient sharedHttpClient; private volatile HttpClient sharedHttpClient;
public DouyinCopyVo runDouyinCopy(Long userId, String url) { public DouyinCopyVo runDouyinCopy(DouyinCopyRequest request) {
String normalizedUrl = url == null ? "" : url.trim(); if (request == null) {
throw new BusinessException("请求参数不能为空");
}
String normalizedUrl = request.getUrl() == null ? "" : request.getUrl().trim();
if (normalizedUrl.isBlank()) { if (normalizedUrl.isBlank()) {
throw new BusinessException("抖音口令或链接不能为空"); throw new BusinessException("抖音口令或链接不能为空");
} }
if (looksLikeJsonObject(normalizedUrl)) { if (looksLikeJsonObject(normalizedUrl)) {
throw new BusinessException("请粘贴抖音分享口令或链接,不要粘贴接口返回内容"); throw new BusinessException("请粘贴视频链接或文案来源链接,不要粘贴接口返回内容");
}
if (!isLikelyDouyinSource(normalizedUrl)) {
throw new BusinessException("请粘贴包含抖音链接的分享口令");
} }
ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId); ImageVideoSecretService.ResolvedSecret secret = secretService.resolveForDouyinCopy(request.getUserId());
String responseText = postWorkflow(normalizedUrl, secret.copyApiKey(), secret.t8Key(), secret.cozeToken(), String responseText = postWorkflow(request, normalizedUrl,
firstNonBlank(request.getApiKey(), secret.copyApiKey()),
firstNonBlank(request.getT8Key(), secret.t8Key()),
secret.cozeToken(),
workflowConfigService.douyinCopyWorkflowId()); workflowConfigService.douyinCopyWorkflowId());
return parseDouyinCopyResponse(responseText); return parseDouyinCopyResponse(responseText);
} }
@@ -119,6 +127,32 @@ public class ImageVideoCozeService {
normalizedProcInfo.put("proc_image", normalizeStringList(normalizedProcInfo.get("proc_image"))); normalizedProcInfo.put("proc_image", normalizeStringList(normalizedProcInfo.get("proc_image")));
parameters.put("proc_info", normalizedProcInfo); 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) { private List<String> normalizeStringList(Object value) {
@@ -144,15 +178,6 @@ public class ImageVideoCozeService {
return text.startsWith("{") && text.endsWith("}"); 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) { public Map<String, Object> getImageVideoWorkflowResult(Long userId, String executeId) {
String resolvedExecuteId = firstNonBlank(executeId); String resolvedExecuteId = firstNonBlank(executeId);
if (!hasText(resolvedExecuteId)) { 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); ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId);
Map<String, Object> parameters = new LinkedHashMap<>(); Map<String, Object> parameters = new LinkedHashMap<>();
parameters.put("api_key", secret.voiceApiKey()); 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); return postWorkflowParameters("voice list", workflowConfigService.voiceListWorkflowId(), parameters, secret.cozeToken(), false);
} }
@@ -222,19 +249,22 @@ public class ImageVideoCozeService {
Map<String, Object> parameters = new LinkedHashMap<>(); Map<String, Object> parameters = new LinkedHashMap<>();
parameters.put("api_key", secret.voiceApiKey()); parameters.put("api_key", secret.voiceApiKey());
parameters.put("group_id", secret.voiceGroupId()); parameters.put("group_id", secret.voiceGroupId());
parameters.put("user_id", userId);
parameters.put("voice_id", firstNonBlank(voiceId)); parameters.put("voice_id", firstNonBlank(voiceId));
return postWorkflowParameters("voice delete", workflowConfigService.voiceDeleteWorkflowId(), parameters, secret.cozeToken(), false); return postWorkflowParameters("voice delete", workflowConfigService.voiceDeleteWorkflowId(), parameters, secret.cozeToken(), false);
} }
public Map<String, Object> runVoiceClone(Long userId, String audioUrl, String videoUrl) { public Map<String, Object> runVoiceClone(Long userId, String name, String audioUrl, String videoUrl) {
String resolvedAudioUrl = firstNonBlank(audioUrl);
String resolvedVideoUrl = firstNonBlank(videoUrl); String resolvedVideoUrl = firstNonBlank(videoUrl);
String resolvedAudioUrl = firstNonBlank(audioUrl, resolvedVideoUrl);
if (!hasText(resolvedAudioUrl) && !hasText(resolvedVideoUrl)) { if (!hasText(resolvedAudioUrl) && !hasText(resolvedVideoUrl)) {
throw new BusinessException("请上传音频或填写视频链接"); throw new BusinessException("请上传音频或填写视频链接");
} }
ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId); ImageVideoSecretService.ResolvedSecret secret = secretService.requireValid(userId);
Map<String, Object> parameters = new LinkedHashMap<>(); Map<String, Object> parameters = new LinkedHashMap<>();
parameters.put("api_key", secret.voiceApiKey()); parameters.put("api_key", secret.voiceApiKey());
parameters.put("name", firstNonBlank(name));
parameters.put("user_id", userId);
parameters.put("audio_url", resolvedAudioUrl); parameters.put("audio_url", resolvedAudioUrl);
parameters.put("video_url", resolvedVideoUrl); parameters.put("video_url", resolvedVideoUrl);
return postWorkflowParameters("voice clone", workflowConfigService.voiceCloneWorkflowId(), parameters, secret.cozeToken(), false); 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); 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 resolvedApiKey = firstNonBlank(apiKey);
String resolvedT8Key = firstNonBlank(t8Key); String resolvedT8Key = firstNonBlank(t8Key);
String cozeToken = firstNonBlank(token); String cozeToken = firstNonBlank(token);
@@ -268,6 +298,8 @@ public class ImageVideoCozeService {
parameters.put("api_key", resolvedApiKey); parameters.put("api_key", resolvedApiKey);
parameters.put("t8_key", resolvedT8Key); parameters.put("t8_key", resolvedT8Key);
parameters.put("url", url); parameters.put("url", url);
parameters.put("duration", normalizeDouyinCopyDuration(request.getDuration()));
parameters.put("proc_info", normalizeDouyinCopyProcInfo(request.getProcInfo()));
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
String resolvedWorkflowId = firstNonBlank(workflowId, properties.getDouyinCopyWorkflowId()); String resolvedWorkflowId = firstNonBlank(workflowId, properties.getDouyinCopyWorkflowId());
@@ -282,6 +314,30 @@ public class ImageVideoCozeService {
return postCozeJson("douyin copy", endpoint, body, cozeToken); 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, private Map<String, Object> postWorkflowParameters(String label, String workflowId, Map<String, Object> parameters,
String token, boolean async) { String token, boolean async) {
String cozeToken = firstNonBlank(token); String cozeToken = firstNonBlank(token);

View File

@@ -103,6 +103,28 @@ public class ImageVideoSecretService {
return new ResolvedSecret(cozeToken, copyApiKey, t8Key, voiceApiKey, voiceGroupId); 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) { private ImageVideoSecretEntity selectByUserId(Long userId) {
return secretMapper.selectOne(new LambdaQueryWrapper<ImageVideoSecretEntity>() return secretMapper.selectOne(new LambdaQueryWrapper<ImageVideoSecretEntity>()
.eq(ImageVideoSecretEntity::getUserId, userId) .eq(ImageVideoSecretEntity::getUserId, userId)

View File

@@ -88,6 +88,7 @@ aiimage:
endpoint: ${AIIMAGE_OSS_ENDPOINT:oss-cn-hangzhou.aliyuncs.com} endpoint: ${AIIMAGE_OSS_ENDPOINT:oss-cn-hangzhou.aliyuncs.com}
public-endpoint: ${AIIMAGE_OSS_PUBLIC_ENDPOINT:oss-cn-hangzhou.aliyuncs.com} public-endpoint: ${AIIMAGE_OSS_PUBLIC_ENDPOINT:oss-cn-hangzhou.aliyuncs.com}
bucket: ${AIIMAGE_OSS_BUCKET:nanri-ai-images} 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-id: ${AIIMAGE_OSS_ACCESS_KEY_ID:LTAI5tNpyvzMNz9f2dHarsm8}
access-key-secret: ${AIIMAGE_OSS_ACCESS_KEY_SECRET:bQSZnFH455i8tzyOgeahJmUzwmhynz} access-key-secret: ${AIIMAGE_OSS_ACCESS_KEY_SECRET:bQSZnFH455i8tzyOgeahJmUzwmhynz}
transient-storage: transient-storage:
@@ -232,10 +233,10 @@ aiimage:
coze-result-buffer-enabled: ${AIIMAGE_SIMILAR_ASIN_COZE_RESULT_BUFFER_ENABLED:true} coze-result-buffer-enabled: ${AIIMAGE_SIMILAR_ASIN_COZE_RESULT_BUFFER_ENABLED:true}
image-video: image-video:
coze-base-url: ${AIIMAGE_IMAGE_VIDEO_COZE_BASE_URL:https://api.coze.cn} 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-workflow-path: ${AIIMAGE_IMAGE_VIDEO_COZE_WORKFLOW_PATH:/v1/workflow/run}
coze-connect-timeout-millis: ${AIIMAGE_IMAGE_VIDEO_COZE_CONNECT_TIMEOUT_MILLIS:10000} 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: security:
shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key} shop-credential-key: ${AIIMAGE_SHOP_CREDENTIAL_KEY:change-me-shop-credential-key}
internal-token: ${AIIMAGE_INTERNAL_TOKEN:} internal-token: ${AIIMAGE_INTERNAL_TOKEN:}

View File

@@ -14,11 +14,12 @@
<AiSectionCard v-if="activeTab === 'remake'" step="01" title="参考视频" description="本地视频或抖音链接,作为复刻依据。" <AiSectionCard v-if="activeTab === 'remake'" step="01" title="参考视频" description="本地视频或抖音链接,作为复刻依据。"
class="delivery-card"> class="delivery-card">
<AiAssetDropzone title="参考视频" description="上传后会先转为 OSS 地址。" placeholder="上传本地视频" <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-url="currentWorkspace.referenceAsset.previewUrl"
:preview-kind="currentWorkspace.referenceAsset.previewKind" :preview-kind="currentWorkspace.referenceAsset.previewKind"
:source-url="currentWorkspace.referenceAsset.sourceUrl" :loading="currentWorkspace.referenceAsset.uploading" :source-url="currentWorkspace.referenceAsset.sourceUrl"
compact action-label="上传参考视频" selected-label="参考视频" :loading="currentWorkspace.referenceAsset.uploading" compact action-label="上传参考视频" selected-label="参考视频"
@select="(file) => setAsset(activeTab, 'referenceAsset', file)" @select="(file) => setAsset(activeTab, 'referenceAsset', file)"
@clear="clearAsset(activeTab, 'referenceAsset')" /> @clear="clearAsset(activeTab, 'referenceAsset')" />
<div class="script-learning-panel__toolbar"> <div class="script-learning-panel__toolbar">
@@ -43,6 +44,10 @@
<span class="delivery-field__label">输出分辨率</span> <span class="delivery-field__label">输出分辨率</span>
<AiChoicePills v-model="currentWorkspace.resolution" :options="resolutionOptions" /> <AiChoicePills v-model="currentWorkspace.resolution" :options="resolutionOptions" />
</div> </div>
<div class="delivery-field">
<span class="delivery-field__label">模型</span>
<AiChoicePills v-model="currentWorkspace.model" :options="modelOptions" />
</div>
<div class="delivery-field"> <div class="delivery-field">
<span class="delivery-field__label">视频时长</span> <span class="delivery-field__label">视频时长</span>
<div class="duration-control"> <div class="duration-control">
@@ -77,15 +82,14 @@
placeholder="例如:干净明亮的现代厨房背景,温暖阳光照入。" /> placeholder="例如:干净明亮的现代厨房背景,温暖阳光照入。" />
</div> </div>
<AiAssetDropzone v-if="currentWorkspace.sceneMode === '上传场景图替换'" title="场景图" description="上传后会先转为 OSS 地址。" <AiAssetDropzone v-if="currentWorkspace.sceneMode === '上传场景图替换'" title="场景图" description="上传后会先转为 OSS 地址。"
placeholder="上传场景图" helper="支持 JPG / PNG" accept="image/*" :file-name="currentWorkspace.sceneAsset.fileName" placeholder="上传场景图" helper="支持 JPG / PNG" accept="image/*"
:preview-url="currentWorkspace.sceneAsset.previewUrl" :preview-kind="currentWorkspace.sceneAsset.previewKind" :file-name="currentWorkspace.sceneAsset.fileName" :preview-url="currentWorkspace.sceneAsset.previewUrl"
:source-url="currentWorkspace.sceneAsset.sourceUrl" :loading="currentWorkspace.sceneAsset.uploading" allow-url :preview-kind="currentWorkspace.sceneAsset.previewKind"
compact action-label="上传场景图" selected-label="场景图" url-label="场景图链接" url-placeholder="也可以粘贴场景图链接" :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)" @select="(file) => setAsset(activeTab, 'sceneAsset', file)"
@url-change="(url) => setAssetUrl(activeTab, 'sceneAsset', url, 'image/*')" @url-change="(url) => setAssetUrl(activeTab, 'sceneAsset', url, 'image/*')" preview-clickable
preview-clickable @preview="openImagePreview" @clear="clearAsset(activeTab, 'sceneAsset')" />
@preview="openImagePreview"
@clear="clearAsset(activeTab, 'sceneAsset')" />
</AiSectionCard> </AiSectionCard>
</div> </div>
@@ -101,6 +105,10 @@
<span class="delivery-field__label">输出分辨率</span> <span class="delivery-field__label">输出分辨率</span>
<AiChoicePills v-model="currentWorkspace.resolution" :options="resolutionOptions" /> <AiChoicePills v-model="currentWorkspace.resolution" :options="resolutionOptions" />
</div> </div>
<div class="delivery-field">
<span class="delivery-field__label">模型</span>
<AiChoicePills v-model="currentWorkspace.model" :options="modelOptions" />
</div>
<div class="delivery-field"> <div class="delivery-field">
<span class="delivery-field__label">视频时长</span> <span class="delivery-field__label">视频时长</span>
<div class="duration-control"> <div class="duration-control">
@@ -138,17 +146,18 @@
<el-input v-model="currentWorkspace.productFocus" type="textarea" :rows="4" resize="none" <el-input v-model="currentWorkspace.productFocus" type="textarea" :rows="4" resize="none"
placeholder="例如防滑耐磨复古网面透气设计" /> placeholder="例如防滑耐磨复古网面透气设计" />
</div> </div>
<AiAssetDropzone title="产品图" description="上传后会先转为 OSS 地址提交时按接口数组格式传入" placeholder="上传产品图" helper="支持 JPG / PNG" <div class="product-assets">
accept="image/*" :file-name="currentWorkspace.productAsset.fileName" <AiAssetDropzone v-for="(asset, index) in currentWorkspace.productAssets" :key="index" :title="`产品图 ${index + 1}`"
:preview-url="currentWorkspace.productAsset.previewUrl" :description="index === 0 ? '最多上传 5 张,提交时按接口数组格式传入。' : ''" placeholder="上传产品图"
:preview-kind="currentWorkspace.productAsset.previewKind" helper="支持 JPG / PNG" accept="image/*" :file-name="asset.fileName" :preview-url="asset.previewUrl"
:source-url="currentWorkspace.productAsset.sourceUrl" :loading="currentWorkspace.productAsset.uploading" :preview-kind="asset.previewKind" :source-url="asset.sourceUrl" :loading="asset.uploading" allow-url compact
allow-url compact preview-clickable action-label="上传产品图" selected-label="产品图" url-label="产品图链接" preview-clickable action-label="上传产品图" selected-label="产品图" url-label="产品图链接"
url-placeholder="也可以粘贴产品图链接" url-placeholder="也可以粘贴产品图链接" @select="(file) => setProductAsset(activeTab, index, file)"
@select="(file) => setAsset(activeTab, 'productAsset', file)" @url-change="(url) => setProductAssetUrl(activeTab, index, url)" @preview="openImagePreview"
@url-change="(url) => setAssetUrl(activeTab, 'productAsset', url, 'image/*')" @clear="clearProductAsset(activeTab, index)" />
@preview="openImagePreview" <button v-if="currentWorkspace.productAssets.length < 5" type="button" class="script-action-btn product-assets__add"
@clear="clearAsset(activeTab, 'productAsset')" /> @click="addProductAsset(activeTab)">添加产品图({{ currentWorkspace.productAssets.length }}/5</button>
</div>
</AiSectionCard> </AiSectionCard>
<AiSectionCard :step="activeTab === 'remake' ? '05' : '04'" title="人脸" description="性别模特图或人物类型三选一" <AiSectionCard :step="activeTab === 'remake' ? '05' : '04'" title="人脸" description="性别模特图或人物类型三选一"
@@ -163,16 +172,17 @@
</div> </div>
<div v-if="currentWorkspace.faceInputType === '人物类型'" class="delivery-field"> <div v-if="currentWorkspace.faceInputType === '人物类型'" class="delivery-field">
<span class="delivery-field__label">人物类型</span> <span class="delivery-field__label">人物类型</span>
<el-input v-model="currentWorkspace.modelFigure" clearable placeholder="输入人物类型例如甜妹 / 御姐 / 成熟女性 / 职场女性" /> <el-input v-model="currentWorkspace.modelFigure" clearable
placeholder="输入人物类型例如甜妹 / 御姐 / 成熟女性 / 职场女性" />
</div> </div>
<AiAssetDropzone v-if="currentWorkspace.faceInputType === '上传模特图'" title="模特图 / 人脸图" <AiAssetDropzone v-if="currentWorkspace.faceInputType === '上传模特图'" title="模特图 / 人脸图"
description="上传后会先转为 OSS 地址" placeholder="上传清晰正脸图" helper="建议 1080px 以上单人正面" accept="image/*" description="上传后会先转为 OSS 地址" placeholder="上传清晰正脸图" helper="建议 1080px 以上单人正面" accept="image/*"
:file-name="currentWorkspace.modelAsset.fileName" :preview-url="currentWorkspace.modelAsset.previewUrl" :file-name="currentWorkspace.modelAsset.fileName" :preview-url="currentWorkspace.modelAsset.previewUrl"
:preview-kind="currentWorkspace.modelAsset.previewKind" :source-url="currentWorkspace.modelAsset.sourceUrl" :preview-kind="currentWorkspace.modelAsset.previewKind"
:loading="currentWorkspace.modelAsset.uploading" allow-url compact preview-clickable action-label="上传模特图" selected-label="模特图" :source-url="currentWorkspace.modelAsset.sourceUrl" :loading="currentWorkspace.modelAsset.uploading"
url-label="模特图链接" url-placeholder="也可以粘贴模特图链接" @select="(file) => setAsset(activeTab, 'modelAsset', file)" allow-url compact preview-clickable action-label="上传模特图" selected-label="模特图" url-label="模特图链接"
@url-change="(url) => setAssetUrl(activeTab, 'modelAsset', url, 'image/*')" url-placeholder="也可以粘贴模特图链接" @select="(file) => setAsset(activeTab, 'modelAsset', file)"
@preview="openImagePreview" @url-change="(url) => setAssetUrl(activeTab, 'modelAsset', url, 'image/*')" @preview="openImagePreview"
@clear="clearAsset(activeTab, 'modelAsset')" /> @clear="clearAsset(activeTab, 'modelAsset')" />
</AiSectionCard> </AiSectionCard>
@@ -194,21 +204,22 @@
<el-input v-model="currentWorkspace.productFocus" type="textarea" :rows="4" resize="none" <el-input v-model="currentWorkspace.productFocus" type="textarea" :rows="4" resize="none"
placeholder="例如防滑耐磨复古网面透气设计" /> placeholder="例如防滑耐磨复古网面透气设计" />
</div> </div>
<AiAssetDropzone title="产品图" description="上传后会先转为 OSS 地址提交时按接口数组格式传入" placeholder="上传产品图" helper="支持 JPG / PNG" <div class="product-assets">
accept="image/*" :file-name="currentWorkspace.productAsset.fileName" <AiAssetDropzone v-for="(asset, index) in currentWorkspace.productAssets" :key="index" :title="`产品图 ${index + 1}`"
:preview-url="currentWorkspace.productAsset.previewUrl" :description="index === 0 ? '最多上传 5 张,提交时按接口数组格式传入。' : ''" placeholder="上传产品图"
:preview-kind="currentWorkspace.productAsset.previewKind" helper="支持 JPG / PNG" accept="image/*" :file-name="asset.fileName" :preview-url="asset.previewUrl"
:source-url="currentWorkspace.productAsset.sourceUrl" :loading="currentWorkspace.productAsset.uploading" :preview-kind="asset.previewKind" :source-url="asset.sourceUrl" :loading="asset.uploading" allow-url compact
allow-url compact preview-clickable action-label="上传产品图" selected-label="产品图" url-label="产品图链接" preview-clickable action-label="上传产品图" selected-label="产品图" url-label="产品图链接"
url-placeholder="也可以粘贴产品图链接" url-placeholder="也可以粘贴产品图链接" @select="(file) => setProductAsset(activeTab, index, file)"
@select="(file) => setAsset(activeTab, 'productAsset', file)" @url-change="(url) => setProductAssetUrl(activeTab, index, url)" @preview="openImagePreview"
@url-change="(url) => setAssetUrl(activeTab, 'productAsset', url, 'image/*')" @clear="clearProductAsset(activeTab, index)" />
@preview="openImagePreview" <button v-if="currentWorkspace.productAssets.length < 5" type="button" class="script-action-btn product-assets__add"
@clear="clearAsset(activeTab, 'productAsset')" /> @click="addProductAsset(activeTab)">添加产品图({{ currentWorkspace.productAssets.length }}/5</button>
</div>
</AiSectionCard> </AiSectionCard>
<AiSectionCard :step="activeTab === 'remake' ? '06' : '05'" title="话术 / 语音" <AiSectionCard :step="activeTab === 'remake' ? '06' : '05'" title="话术 / 语音" description="编辑口播台词选择最终配音方式"
description="编辑口播台词选择最终配音方式" class="delivery-card"> class="delivery-card">
<div class="voice-section"> <div class="voice-section">
<div class="delivery-field"> <div class="delivery-field">
<span class="delivery-field__label">语言</span> <span class="delivery-field__label">语言</span>
@@ -219,6 +230,11 @@
<button type="button" class="script-action-btn voice-manage-btn" @click="openVoiceDialog">音色管理</button> <button type="button" class="script-action-btn voice-manage-btn" @click="openVoiceDialog">音色管理</button>
</div> </div>
<div class="delivery-field">
<span class="delivery-field__label">音频类型</span>
<AiChoicePills v-model="currentWorkspace.audioType" :options="audioTypeOptions" />
</div>
<div class="delivery-field"> <div class="delivery-field">
<span class="delivery-field__label">口播台词</span> <span class="delivery-field__label">口播台词</span>
<el-input v-model="currentWorkspace.scriptText" type="textarea" :rows="6" resize="none" <el-input v-model="currentWorkspace.scriptText" type="textarea" :rows="6" resize="none"
@@ -247,9 +263,10 @@
<AiAssetDropzone title="最终配音文件" description="对应 text_info.file_url只放最终用于视频配音的音频" placeholder="上传音频文件" <AiAssetDropzone title="最终配音文件" description="对应 text_info.file_url只放最终用于视频配音的音频" placeholder="上传音频文件"
helper="支持 MP3 / WAV也可粘贴可访问的音频链接" accept="audio/*" :file-name="currentWorkspace.speechAsset.fileName" helper="支持 MP3 / WAV也可粘贴可访问的音频链接" accept="audio/*" :file-name="currentWorkspace.speechAsset.fileName"
:preview-url="currentWorkspace.speechAsset.previewUrl" :preview-url="currentWorkspace.speechAsset.previewUrl"
:preview-kind="currentWorkspace.speechAsset.previewKind" :source-url="currentWorkspace.speechAsset.sourceUrl" :preview-kind="currentWorkspace.speechAsset.previewKind"
:loading="currentWorkspace.speechAsset.uploading" allow-url compact action-label="上传音频" selected-label="最终配音" :source-url="currentWorkspace.speechAsset.sourceUrl" :loading="currentWorkspace.speechAsset.uploading"
url-label="最终配音文件链接" @select="(file) => setAsset(activeTab, 'speechAsset', file)" allow-url compact action-label="上传音频" selected-label="最终配音" url-label="最终配音文件链接"
@select="(file) => setAsset(activeTab, 'speechAsset', file)"
@url-change="(url) => setAssetUrl(activeTab, 'speechAsset', url, 'audio/*')" @url-change="(url) => setAssetUrl(activeTab, 'speechAsset', url, 'audio/*')"
@clear="clearAsset(activeTab, 'speechAsset')" /> @clear="clearAsset(activeTab, 'speechAsset')" />
</template> </template>
@@ -290,6 +307,14 @@
<AiSectionCard :step="activeTab === 'remake' ? '07' : '06'" title="视频结果" description="提交后在这里等待预览和下载成片" <AiSectionCard :step="activeTab === 'remake' ? '07' : '06'" title="视频结果" description="提交后在这里等待预览和下载成片"
class="delivery-card delivery-card--preview"> 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-panel__meta">
<div class="preview-row"> <div class="preview-row">
<span class="preview-row__label">模式</span> <span class="preview-row__label">模式</span>
@@ -310,8 +335,8 @@
:disabled="assemblyBusy || assemblyDownloading" @click="handleAssemblyPrimaryAction"> :disabled="assemblyBusy || assemblyDownloading" @click="handleAssemblyPrimaryAction">
{{ assemblyPrimaryActionText }} {{ assemblyPrimaryActionText }}
</el-button> </el-button>
<el-button v-if="canResetWorkspace" class="delivery-secondary-btn" :disabled="assemblyBusy || assemblyDownloading" <el-button v-if="canResetWorkspace" class="delivery-secondary-btn"
@click="resetCurrentWorkspace"> :disabled="assemblyBusy || assemblyDownloading" @click="resetCurrentWorkspace">
重置内容 重置内容
</el-button> </el-button>
</div> </div>
@@ -319,7 +344,7 @@
:class="{ 'assembly-waiting--done': !assemblyBusy && assemblyExecuteId }"> :class="{ 'assembly-waiting--done': !assemblyBusy && assemblyExecuteId }">
<div class="assembly-waiting__spinner" aria-hidden="true"></div> <div class="assembly-waiting__spinner" aria-hidden="true"></div>
<div class="assembly-waiting__content"> <div class="assembly-waiting__content">
<strong>{{ assemblyPolling ? 'Coze 正在生成视频,请保持页面打开' : '工作流已提交' }}</strong> <strong>{{ assemblyPolling ? '正在生成视频,请保持页面打开' : '工作流已提交' }}</strong>
<span v-if="assemblyExecuteId">执行 ID{{ assemblyExecuteId }}</span> <span v-if="assemblyExecuteId">执行 ID{{ assemblyExecuteId }}</span>
<span v-if="assemblyPolling">已查询 {{ assemblyPollCount }} 次,系统会每 5 秒自动刷新结果。</span> <span v-if="assemblyPolling">已查询 {{ assemblyPollCount }} 次,系统会每 5 秒自动刷新结果。</span>
</div> </div>
@@ -372,7 +397,7 @@
<el-form-item label="音色分组 ID"> <el-form-item label="音色分组 ID">
<el-input v-model="secretForm.voiceGroupId" type="password" show-password placeholder="不填写则保留当前有效值" /> <el-input v-model="secretForm.voiceGroupId" type="password" show-password placeholder="不填写则保留当前有效值" />
<div v-if="secretStatus?.voiceGroupIdMasked" class="secret-form__hint">当前:{{ secretStatus.voiceGroupIdMasked <div v-if="secretStatus?.voiceGroupIdMasked" class="secret-form__hint">当前:{{ secretStatus.voiceGroupIdMasked
}}</div> }}</div>
</el-form-item> </el-form-item>
<el-form-item label="有效期"> <el-form-item label="有效期">
<el-select v-model="secretForm.expireDays" class="delivery-select"> <el-select v-model="secretForm.expireDays" class="delivery-select">
@@ -398,7 +423,8 @@
<strong>{{ filteredVoiceListItems.length }}</strong> <strong>{{ filteredVoiceListItems.length }}</strong>
<span>/ {{ voiceListItems.length }} 个音色</span> <span>/ {{ voiceListItems.length }} 个音色</span>
</div> </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 type="button" class="script-primary-action-btn" :disabled="voiceLoading" @click="listVoices">
刷新音色 刷新音色
</button> </button>
@@ -412,18 +438,24 @@
</div> </div>
<span class="voice-list__source">{{ item.sourceLabel }}</span> <span class="voice-list__source">{{ item.sourceLabel }}</span>
<button type="button" class="script-action-btn" @click="useVoice(item)">使用此音色</button> <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>
</div> </div>
<el-empty v-else description="暂无可展示音色请确认接口已返回音色数据" /> <el-empty v-else description="暂无可展示音色请确认接口已返回音色数据" />
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="克隆音色" name="clone"> <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="上传音频或视频" <AiAssetDropzone title="克隆素材" description="仅用于音色克隆不会写入 text_info.file_url" placeholder="上传音频或视频"
helper="支持 MP3 / WAV / MP4或粘贴素材链接" accept="audio/*,video/*" :file-name="voiceCloneAsset.fileName" helper="支持 MP3 / WAV / MP4或粘贴素材链接" accept="audio/*,video/*" :file-name="voiceCloneAsset.fileName"
:preview-url="voiceCloneAsset.previewUrl" :preview-kind="voiceCloneAsset.previewKind" :preview-url="voiceCloneAsset.previewUrl" :preview-kind="voiceCloneAsset.previewKind"
:source-url="voiceCloneAsset.sourceUrl" :loading="voiceCloneAsset.uploading" allow-url compact :source-url="voiceCloneAsset.sourceUrl" :loading="voiceCloneAsset.uploading" allow-url compact
action-label="上传克隆素材" selected-label="克隆素材" url-label="克隆素材链接" action-label="上传克隆素材" selected-label="克隆素材" url-label="克隆素材链接" @select="setVoiceCloneAsset"
@select="setVoiceCloneAsset" @url-change="setVoiceCloneUrl" @clear="clearVoiceCloneAsset" /> @url-change="setVoiceCloneUrl" @clear="clearVoiceCloneAsset" />
<div class="voice-dialog__toolbar"> <div class="voice-dialog__toolbar">
<button type="button" class="script-primary-action-btn" :disabled="voiceLoading" @click="cloneVoice"> <button type="button" class="script-primary-action-btn" :disabled="voiceLoading" @click="cloneVoice">
开始克隆 开始克隆
@@ -431,16 +463,6 @@
</div> </div>
</el-tab-pane> </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-tabs>
</el-dialog> </el-dialog>
@@ -476,7 +498,7 @@ import {
type WorkspaceTab = 'remake' | 'imageToVideo' type WorkspaceTab = 'remake' | 'imageToVideo'
type PreviewKind = 'image' | 'video' | 'file' type PreviewKind = 'image' | 'video' | 'file'
type MediaType = 'image' | 'video' | 'audio' | '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 VoiceMode = 'textOnly' | 'uploadAudio' | 'synthesize'
type VoiceStatusType = 'info' | 'success' | 'error' type VoiceStatusType = 'info' | 'success' | 'error'
@@ -503,12 +525,14 @@ type AssemblyState = {
type WorkspaceState = { type WorkspaceState = {
referenceAsset: AssetState referenceAsset: AssetState
productAsset: AssetState productAssets: AssetState[]
sceneAsset: AssetState sceneAsset: AssetState
modelAsset: AssetState modelAsset: AssetState
speechAsset: AssetState speechAsset: AssetState
ratio: string ratio: string
resolution: string resolution: string
model: string
generationMode: 'draft' | 'standard'
duration: string duration: string
durationSeconds: number durationSeconds: number
videoPrompt: string videoPrompt: string
@@ -518,6 +542,7 @@ type WorkspaceState = {
gender: string gender: string
modelFigure: string modelFigure: string
language: string language: string
audioType: string
voiceMode: VoiceMode voiceMode: VoiceMode
voiceId: string voiceId: string
voiceName: string voiceName: string
@@ -546,12 +571,12 @@ const secretDialogVisible = ref(false)
const secretSaving = ref(false) const secretSaving = ref(false)
const secretStatus = ref<ImageVideoSecretStatusVo | null>(null) const secretStatus = ref<ImageVideoSecretStatusVo | null>(null)
const voiceDialogVisible = ref(false) const voiceDialogVisible = ref(false)
const voiceDialogTab = ref<'list' | 'clone' | 'delete'>('list') const voiceDialogTab = ref<'list' | 'clone'>('list')
const voiceStatusMessage = ref('') const voiceStatusMessage = ref('')
const voiceStatusType = ref<VoiceStatusType>('info') const voiceStatusType = ref<VoiceStatusType>('info')
const voiceListItems = ref<VoiceListItem[]>([]) const voiceListItems = ref<VoiceListItem[]>([])
const voiceSearchKeyword = ref('') const voiceSearchKeyword = ref('')
const deleteVoiceId = ref('') const voiceCloneName = ref('')
const imagePreviewVisible = ref(false) const imagePreviewVisible = ref(false)
const imagePreviewUrl = ref('') const imagePreviewUrl = ref('')
const currentAssembly = computed(() => currentWorkspace.value.assembly) const currentAssembly = computed(() => currentWorkspace.value.assembly)
@@ -572,9 +597,9 @@ const assemblyBusy = computed(() => currentAssembly.value.loading || currentAsse
const canResetWorkspace = computed(() => const canResetWorkspace = computed(() =>
Boolean( Boolean(
currentAssembly.value.executeId currentAssembly.value.executeId
|| currentAssembly.value.resultText || currentAssembly.value.resultText
|| currentAssembly.value.videoUrl || currentAssembly.value.videoUrl
|| currentAssembly.value.debugUrl, || currentAssembly.value.debugUrl,
), ),
) )
const assemblyPrimaryActionText = computed(() => { const assemblyPrimaryActionText = computed(() => {
@@ -594,11 +619,20 @@ const secretForm = reactive({
const ratioOptions = ['9:16', '16:9', '3:4', '4:3', '1:1'] const ratioOptions = ['9:16', '16:9', '3:4', '4:3', '1:1']
const resolutionOptions = ['480p', '720p', '1080p'] 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 durationOptions = ['15', '30', '45', '60']
const sceneModeOptions = ['提示词微调', '上传场景图替换'] const sceneModeOptions = ['提示词微调', '上传场景图替换']
const faceInputTypeOptions = ['性别选项', '上传模特图', '人物类型'] const faceInputTypeOptions = ['性别选项', '上传模特图', '人物类型']
const genderOptions = ['女', '男'] const genderOptions = ['女', '男']
const languageOptions = ['中文', '英语', '韩语', '粤语'] const languageOptions = ['中文', '英语', '韩语', '粤语']
const audioTypeOptions = [
{ label: '人物口播', value: '1' },
{ label: '背景旁白', value: '2' },
]
const voiceModeOptions = [ const voiceModeOptions = [
{ label: '只提交文案', value: 'textOnly' }, { label: '只提交文案', value: 'textOnly' },
{ label: '上传/粘贴已有音频', value: 'uploadAudio' }, { label: '上传/粘贴已有音频', value: 'uploadAudio' },
@@ -634,12 +668,14 @@ function createAssemblyState(): AssemblyState {
function createWorkspaceState(): WorkspaceState { function createWorkspaceState(): WorkspaceState {
return { return {
referenceAsset: createAssetState(), referenceAsset: createAssetState(),
productAsset: createAssetState(), productAssets: [createAssetState()],
sceneAsset: createAssetState(), sceneAsset: createAssetState(),
modelAsset: createAssetState(), modelAsset: createAssetState(),
speechAsset: createAssetState(), speechAsset: createAssetState(),
ratio: '9:16', ratio: '9:16',
resolution: '720p', resolution: '720p',
model: 'sdols-2.0-fast',
generationMode: 'draft',
duration: '15', duration: '15',
durationSeconds: 15, durationSeconds: 15,
videoPrompt: '', videoPrompt: '',
@@ -649,6 +685,7 @@ function createWorkspaceState(): WorkspaceState {
gender: '女', gender: '女',
modelFigure: '御姐', modelFigure: '御姐',
language: '中文', language: '中文',
audioType: '1',
voiceMode: 'textOnly', voiceMode: 'textOnly',
voiceId: '', voiceId: '',
voiceName: '', voiceName: '',
@@ -682,8 +719,8 @@ const secretStatusText = computed(() => {
function normalizeDuration(value: number | string) { function normalizeDuration(value: number | string) {
const nextValue = Number(value) const nextValue = Number(value)
if (Number.isFinite(nextValue) && nextValue > 0) return String(Math.round(nextValue)) if (Number.isFinite(nextValue) && nextValue > 0) return Math.round(nextValue)
return '15' return 15
} }
function setDurationPreset(value: string | string[]) { function setDurationPreset(value: string | string[]) {
@@ -774,6 +811,54 @@ function clearAsset(tab: WorkspaceTab, assetKey: AssetKey) {
scheduleDeliveryGridLayout() 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) { function openImagePreview(url: string) {
const normalizedUrl = (url || '').trim() const normalizedUrl = (url || '').trim()
if (!normalizedUrl) return if (!normalizedUrl) return
@@ -867,17 +952,22 @@ async function rewriteScriptFromSource() {
return return
} }
if (looksLikeJsonObject(url)) { if (looksLikeJsonObject(url)) {
ElMessage.warning('请粘贴抖音分享口令或链接,不要粘贴接口返回内容') ElMessage.warning('请粘贴视频链接或文案来源链接,不要粘贴接口返回内容')
return
}
if (!isLikelyDouyinSource(url)) {
ElMessage.warning('请粘贴包含抖音链接的分享口令')
return return
} }
if (activeTab.value === 'remake') currentWorkspace.value.scriptSourceUrl = url if (activeTab.value === 'remake') currentWorkspace.value.scriptSourceUrl = url
copyLearningLoading.value = true copyLearningLoading.value = true
try { 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 || '') const text = cleanCopyText(result.scriptDraft || result.recognizedContent || '')
currentWorkspace.value.scriptText = text currentWorkspace.value.scriptText = text
ElMessage.success('文案已提取') ElMessage.success('文案已提取')
@@ -897,15 +987,6 @@ function looksLikeJsonObject(value: string) {
return text.startsWith('{') && text.endsWith('}') 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) { function cleanCopyText(value: string) {
return value return value
.replace(/^\s*https?:\/\/\S+\s*$/gm, '') .replace(/^\s*https?:\/\/\S+\s*$/gm, '')
@@ -916,8 +997,7 @@ function cleanCopyText(value: string) {
} }
function resolveProductImageUrls(workspace: WorkspaceState) { function resolveProductImageUrls(workspace: WorkspaceState) {
const productImageUrl = resolveAssetUrl(workspace.productAsset) return workspace.productAssets.map(resolveAssetUrl).filter(Boolean).slice(0, 5)
return productImageUrl ? [productImageUrl] : []
} }
function resolveSpeechFileUrl(workspace: WorkspaceState) { function resolveSpeechFileUrl(workspace: WorkspaceState) {
@@ -954,27 +1034,8 @@ function resolveFaceInfo(workspace: WorkspaceState): ImageVideoWorkflowParameter
return { type: 1, model_figure: workspace.gender, model_image: [] } 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 { function buildImageVideoWorkflowParameters(): ImageVideoWorkflowParameters | null {
const workspace = currentWorkspace.value const workspace = currentWorkspace.value
const error = validateWorkspace(workspace)
if (error) {
ElMessage.warning(error)
return null
}
const referenceVideoInfo = resolveReferenceVideoInfo(workspace) const referenceVideoInfo = resolveReferenceVideoInfo(workspace)
const productImageUrls = resolveProductImageUrls(workspace) const productImageUrls = resolveProductImageUrls(workspace)
return { return {
@@ -1000,12 +1061,17 @@ function buildImageVideoWorkflowParameters(): ImageVideoWorkflowParameters | nul
text: workspace.scriptText.trim(), text: workspace.scriptText.trim(),
file_url: resolveSpeechFileUrl(workspace), file_url: resolveSpeechFileUrl(workspace),
}, },
audio_info: {
audio_url: resolveSpeechFileUrl(workspace),
type: Number(workspace.audioType) || 1,
},
video_info: { video_info: {
video_url: referenceVideoInfo.videoUrl, video_url: referenceVideoInfo.videoUrl,
share_url: referenceVideoInfo.shareUrl, share_url: referenceVideoInfo.shareUrl,
ref_video_mode: referenceVideoInfo.refVideoMode, ref_video_mode: referenceVideoInfo.refVideoMode,
mode: activeTab.value === 'imageToVideo' ? '1' : '2', mode: activeTab.value === 'imageToVideo' ? '1' : '2',
model: 'sdols-2.0-fast', draft: workspace.generationMode === 'draft',
model: workspace.model,
prompt: workspace.videoPrompt.trim(), prompt: workspace.videoPrompt.trim(),
ratio: workspace.ratio, ratio: workspace.ratio,
resolution: workspace.resolution, resolution: workspace.resolution,
@@ -1140,7 +1206,7 @@ async function pollAssemblyResult(tab: WorkspaceTab, executeId: string) {
return return
} }
if (assembly.pollCount >= 120) { if (assembly.pollCount >= 720) {
assembly.polling = false assembly.polling = false
ElMessage.warning('Coze 执行结果查询超时,请稍后通过 execute_id 查看') ElMessage.warning('Coze 执行结果查询超时,请稍后通过 execute_id 查看')
return return
@@ -1213,10 +1279,10 @@ function collectVoiceArrays(value: unknown): unknown[] {
return return
} }
const record = node as Record<string, unknown> const record = node as Record<string, unknown>
;['system_voice', 'voice_cloning', 'voice_generation', 'music_generation'].forEach((key) => { ;['system_voice', 'voice_cloning', 'voice_generation', 'music_generation'].forEach((key) => {
const item = record[key] const item = record[key]
if (Array.isArray(item)) arrays.push(...item) if (Array.isArray(item)) arrays.push(...item)
}) })
Object.values(record).forEach(collect) Object.values(record).forEach(collect)
} }
@@ -1456,9 +1522,9 @@ function extractVoiceItems(value: unknown): VoiceListItem[] {
sourceConfigs.forEach((config) => { sourceConfigs.forEach((config) => {
const direct = record[config.key] const direct = record[config.key]
const nested = getRecordValue(record, ['data', config.key]) const nested = getRecordValue(record, ['data', config.key])
;[direct, nested].forEach((source) => { ;[direct, nested].forEach((source) => {
if (Array.isArray(source)) source.forEach((item) => addVoice(item, config.key, config.label)) if (Array.isArray(source)) source.forEach((item) => addVoice(item, config.key, config.label))
}) })
}) })
Object.values(record).forEach(collectFromKnownArrays) Object.values(record).forEach(collectFromKnownArrays)
} }
@@ -1482,7 +1548,7 @@ function firstString(...values: unknown[]) {
async function listVoices() { async function listVoices() {
voiceLoading.value = true voiceLoading.value = true
try { try {
const result = await listImageVideoVoices() const result = await listImageVideoVoices(voiceSearchKeyword.value.trim())
voiceListItems.value = extractVoiceItems(result) voiceListItems.value = extractVoiceItems(result)
setVoiceStatus( setVoiceStatus(
voiceListItems.value.length ? `已获取 ${voiceListItems.value.length} 个音色` : '音色接口已返回,未解析到列表项', voiceListItems.value.length ? `已获取 ${voiceListItems.value.length} 个音色` : '音色接口已返回,未解析到列表项',
@@ -1497,12 +1563,12 @@ async function listVoices() {
} }
} }
async function deleteVoice() { async function deleteVoice(item: VoiceListItem) {
const voiceId = deleteVoiceId.value.trim() || currentWorkspace.value.voiceId.trim() if (item.sourceKey === 'system_voice') {
if (!voiceId) { ElMessage.warning('系统音色不支持删除')
ElMessage.warning('请先输入 voice_id')
return return
} }
const voiceId = item.voiceId
try { try {
await ElMessageBox.confirm(`确定删除音色 ${voiceId} 吗?`, '删除音色', { await ElMessageBox.confirm(`确定删除音色 ${voiceId} 吗?`, '删除音色', {
confirmButtonText: '删除', confirmButtonText: '删除',
@@ -1528,6 +1594,11 @@ async function deleteVoice() {
} }
async function cloneVoice() { async function cloneVoice() {
const name = voiceCloneName.value.trim()
if (!name) {
ElMessage.warning('请输入音色名称')
return
}
const sourceUrl = resolveAssetUrl(voiceCloneAsset) const sourceUrl = resolveAssetUrl(voiceCloneAsset)
if (!sourceUrl) { if (!sourceUrl) {
ElMessage.warning('请先上传或填写音频/视频链接') ElMessage.warning('请先上传或填写音频/视频链接')
@@ -1536,10 +1607,11 @@ async function cloneVoice() {
voiceLoading.value = true voiceLoading.value = true
try { try {
const clonePayload = { const clonePayload = {
audioUrl: voiceCloneAsset.mediaType === 'video' ? '' : sourceUrl, audioUrl: sourceUrl,
videoUrl: voiceCloneAsset.mediaType === 'video' ? sourceUrl : '', videoUrl: voiceCloneAsset.mediaType === 'video' ? sourceUrl : '',
} }
const result = await cloneImageVideoVoice({ const result = await cloneImageVideoVoice({
name,
audioUrl: clonePayload.audioUrl, audioUrl: clonePayload.audioUrl,
videoUrl: clonePayload.videoUrl, videoUrl: clonePayload.videoUrl,
}) })
@@ -1598,7 +1670,6 @@ async function synthesizeVoice() {
function useVoice(item: VoiceListItem) { function useVoice(item: VoiceListItem) {
currentWorkspace.value.voiceId = item.voiceId currentWorkspace.value.voiceId = item.voiceId
currentWorkspace.value.voiceName = item.name || item.voiceId currentWorkspace.value.voiceName = item.name || item.voiceId
deleteVoiceId.value = item.voiceId
currentWorkspace.value.voiceMode = 'synthesize' currentWorkspace.value.voiceMode = 'synthesize'
setVoiceStatus(`已选择音色:${currentWorkspace.value.voiceName}${item.voiceId}`, 'success') setVoiceStatus(`已选择音色:${currentWorkspace.value.voiceName}${item.voiceId}`, 'success')
voiceDialogVisible.value = false voiceDialogVisible.value = false
@@ -1856,6 +1927,15 @@ onBeforeUnmount(() => {
gap: 8px; gap: 8px;
} }
.product-assets {
display: grid;
gap: 12px;
}
.product-assets__add {
justify-self: start;
}
.scene-face-grid { .scene-face-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1956,6 +2036,10 @@ onBeforeUnmount(() => {
padding-right: 4px; padding-right: 4px;
} }
.voice-dialog__tabs :deep(.el-tabs__item:not(.is-active)) {
color: #fff;
}
.voice-dialog__toolbar { .voice-dialog__toolbar {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
@@ -1998,7 +2082,7 @@ onBeforeUnmount(() => {
.voice-list__item { .voice-list__item {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto auto; grid-template-columns: minmax(0, 1fr) auto auto auto;
gap: 10px; gap: 10px;
align-items: center; align-items: center;
padding: 10px 12px; padding: 10px 12px;
@@ -2049,6 +2133,29 @@ onBeforeUnmount(() => {
background: #141414; 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 { .preview-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@@ -2483,6 +2483,19 @@ export interface ImageVideoDouyinCopyVo {
debugUrl?: string; 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 { export interface ImageVideoSecretStatusVo {
userId?: number; userId?: number;
configured?: boolean; configured?: boolean;
@@ -2535,16 +2548,21 @@ export interface ImageVideoWorkflowParameters {
text: string; text: string;
file_url: string; file_url: string;
}; };
audio_info: {
audio_url: string;
type: number;
};
video_info: { video_info: {
video_url: string; video_url: string;
share_url: string; share_url: string;
ref_video_mode: string; ref_video_mode: string;
mode: string; mode: string;
draft: boolean;
model: string; model: string;
prompt: string; prompt: string;
ratio: string; ratio: string;
resolution: 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( return unwrapJavaResponse(
post<JavaApiResponse<ImageVideoDouyinCopyVo>, { userId: number; url: string }>( post<JavaApiResponse<ImageVideoDouyinCopyVo>, ImageVideoDouyinCopyPayload & { userId: number }>(
`${JAVA_API_PREFIX}/image-video/douyin-copy`, `${JAVA_API_PREFIX}/image-video/douyin-copy`,
{ userId: getCurrentUserId(), url }, { userId: getCurrentUserId(), ...payload },
{ timeout: 180000 }, { timeout: 180000 },
), ),
); );
@@ -2616,7 +2634,7 @@ export function runImageVideoWorkflow(parameters: ImageVideoWorkflowParameters)
post<JavaApiResponse<ImageVideoWorkflowResponse>, ImageVideoWorkflowRunRequest>( post<JavaApiResponse<ImageVideoWorkflowResponse>, ImageVideoWorkflowRunRequest>(
`${JAVA_API_PREFIX}/image-video/workflow/run`, `${JAVA_API_PREFIX}/image-video/workflow/run`,
{ userId: getCurrentUserId(), parameters }, { userId: getCurrentUserId(), parameters },
{ timeout: 120000 }, { timeout: 3600000 },
), ),
); );
} }
@@ -2626,7 +2644,7 @@ export function getImageVideoWorkflowResult(executeId: string) {
post<JavaApiResponse<ImageVideoWorkflowResponse>, ImageVideoWorkflowResultRequest>( post<JavaApiResponse<ImageVideoWorkflowResponse>, ImageVideoWorkflowResultRequest>(
`${JAVA_API_PREFIX}/image-video/workflow/result`, `${JAVA_API_PREFIX}/image-video/workflow/result`,
{ userId: getCurrentUserId(), executeId }, { userId: getCurrentUserId(), executeId },
{ timeout: 600000 }, { timeout: 3600000 },
), ),
); );
} }
@@ -2660,11 +2678,11 @@ function resolveImageVideoMediaType(file: File) {
return "file"; return "file";
} }
export function listImageVideoVoices() { export function listImageVideoVoices(name = "") {
return unwrapJavaResponse( return unwrapJavaResponse(
post<JavaApiResponse<ImageVideoVoiceWorkflowResponse>, { userId: number }>( post<JavaApiResponse<ImageVideoVoiceWorkflowResponse>, { userId: number; name: string }>(
`${JAVA_API_PREFIX}/image-video/voice/list`, `${JAVA_API_PREFIX}/image-video/voice/list`,
{ userId: getCurrentUserId() }, { userId: getCurrentUserId(), name },
{ timeout: 180000 }, { 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( 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`, `${JAVA_API_PREFIX}/image-video/voice/clone`,
{ userId: getCurrentUserId(), ...payload }, { userId: getCurrentUserId(), ...payload },
{ timeout: 180000 }, { timeout: 180000 },