完成视频部分的前后端
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
Reference in New Issue
Block a user