完成视频部分的前后端

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 voiceSynthesisWorkflowId = "7652954297530105894";
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 publicEndpoint;
private String bucket;
private String imageVideoBucket;
private String accessKeyId;
private String accessKeySecret;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,4 +7,6 @@ import lombok.Data;
public class ImageVideoVoiceListRequest {
@NotNull(message = "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.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);

View File

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

View File

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

View File

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

View File

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