完成视频部分的前后端

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