Commit ee496fd0 authored by yaoke.yk's avatar yaoke.yk

导出视频合成问题fix8

parent 52c75644
...@@ -62,6 +62,7 @@ export function VideoGeneration() { ...@@ -62,6 +62,7 @@ export function VideoGeneration() {
const orderedShots = storyboards const orderedShots = storyboards
.map((sb) => ({ sb, task: getTaskForSb(sb) })) .map((sb) => ({ sb, task: getTaskForSb(sb) }))
.filter(({ task }) => task?.status === "succeeded" && task.resultVideoUrl); .filter(({ task }) => task?.status === "succeeded" && task.resultVideoUrl);
const orderedShotTaskIds = orderedShots.map(({ task }) => task!.id);
// Stats // Stats
const totalShots = storyboards.length; const totalShots = storyboards.length;
...@@ -116,6 +117,10 @@ export function VideoGeneration() { ...@@ -116,6 +117,10 @@ export function VideoGeneration() {
await deleteTask.mutateAsync(taskId); await deleteTask.mutateAsync(taskId);
}; };
const handleStartAssembly = () => {
startAssembly.mutate(orderedShotTaskIds);
};
// Episode stats helper // Episode stats helper
const epShotCount = (ep: Episode) => ep.storyboardCount ?? 0; const epShotCount = (ep: Episode) => ep.storyboardCount ?? 0;
const epDoneCount = (ep: Episode) => const epDoneCount = (ep: Episode) =>
...@@ -194,32 +199,32 @@ export function VideoGeneration() { ...@@ -194,32 +199,32 @@ export function VideoGeneration() {
整集 整集
</button> </button>
)} )}
{(!assemblyTask || assemblyTask.status === "failed") && (
<button
onClick={() => startAssembly.mutate()}
disabled={startAssembly.isPending || doneShots === 0}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-xs text-foreground hover:bg-muted transition disabled:opacity-50"
>
{startAssembly.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Film className="w-3.5 h-3.5" />}
合成整集
</button>
)}
{(assemblyTask?.status === "running" || assemblyTask?.status === "pending") && ( {(assemblyTask?.status === "running" || assemblyTask?.status === "pending") && (
<span className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground"> <span className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground">
<Loader2 className="w-3.5 h-3.5 animate-spin" />合成中... <Loader2 className="w-3.5 h-3.5 animate-spin" />合成中...
</span> </span>
)} )}
{hasAssembly && ( {hasAssembly && assemblyTask?.status !== "running" && assemblyTask?.status !== "pending" && (
<a <a
href={assemblyTask!.downloadUrl!} href={assemblyTask!.downloadUrl!}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-green-600 text-white text-xs hover:bg-green-700 transition" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-border text-xs text-foreground hover:bg-muted transition"
> >
<Download className="w-3.5 h-3.5" /> <Download className="w-3.5 h-3.5" />
导出整集 下载已合成
</a> </a>
)} )}
{assemblyTask?.status !== "running" && assemblyTask?.status !== "pending" && (
<button
onClick={handleStartAssembly}
disabled={startAssembly.isPending || doneShots === 0}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-green-600 text-white text-xs hover:bg-green-700 transition disabled:opacity-50"
>
{startAssembly.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Download className="w-3.5 h-3.5" />}
导出整集
</button>
)}
</div> </div>
</div> </div>
......
...@@ -446,7 +446,7 @@ export function useAssemblyTask(projectId: string, episodeId: string) { ...@@ -446,7 +446,7 @@ export function useAssemblyTask(projectId: string, episodeId: string) {
export function useStartAssembly(projectId: string, episodeId: string) { export function useStartAssembly(projectId: string, episodeId: string) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: () => aiApi.startAssembly(projectId, episodeId), mutationFn: (videoTaskIds?: string[]) => aiApi.startAssembly(projectId, episodeId, videoTaskIds),
onSuccess: () => qc.invalidateQueries({ queryKey: assemblyKey(projectId, episodeId) }), onSuccess: () => qc.invalidateQueries({ queryKey: assemblyKey(projectId, episodeId) }),
}); });
} }
......
...@@ -406,8 +406,10 @@ export const aiApi = { ...@@ -406,8 +406,10 @@ export const aiApi = {
}, },
// ---- Assembly ---- // ---- Assembly ----
startAssembly: async (projectId: string, episodeId: string): Promise<AssemblyTask> => { startAssembly: async (projectId: string, episodeId: string, videoTaskIds?: string[]): Promise<AssemblyTask> => {
const r = await apiClient.post(`/projects/${projectId}/episodes/${episodeId}/assembly`); const r = await apiClient.post(`/projects/${projectId}/episodes/${episodeId}/assembly`, {
videoTaskIds,
});
return r.data.data; return r.data.data;
}, },
getAssemblyTask: async (projectId: string, episodeId: string): Promise<AssemblyTask | null> => { getAssemblyTask: async (projectId: string, episodeId: string): Promise<AssemblyTask | null> => {
......
...@@ -10,6 +10,8 @@ import io.swagger.v3.oas.annotations.tags.Tag; ...@@ -10,6 +10,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "视频合成") @Tag(name = "视频合成")
@RestController @RestController
@RequestMapping("/projects/{projectId}/episodes/{episodeId}/assembly") @RequestMapping("/projects/{projectId}/episodes/{episodeId}/assembly")
...@@ -21,9 +23,15 @@ public class AssemblyController { ...@@ -21,9 +23,15 @@ public class AssemblyController {
@Operation(summary = "启动合成任务") @Operation(summary = "启动合成任务")
@PostMapping @PostMapping
public ApiResponse<AssemblyTaskDTO> start(@PathVariable Long projectId, public ApiResponse<AssemblyTaskDTO> start(@PathVariable Long projectId,
@PathVariable Long episodeId) { @PathVariable Long episodeId,
@RequestBody(required = false) AssemblyStartRequest req) {
Long tenantId = TenantContext.get(); Long tenantId = TenantContext.get();
AssemblyTask task = assemblyPipelineService.startAssembly(projectId, episodeId, tenantId); AssemblyTask task = assemblyPipelineService.startAssembly(
projectId,
episodeId,
tenantId,
req == null ? null : req.videoTaskIds()
);
return ApiResponse.success(AssemblyTaskDTO.from(task, null)); return ApiResponse.success(AssemblyTaskDTO.from(task, null));
} }
...@@ -39,4 +47,7 @@ public class AssemblyController { ...@@ -39,4 +47,7 @@ public class AssemblyController {
String downloadUrl = assemblyPipelineService.signedDownloadUrl(task); String downloadUrl = assemblyPipelineService.signedDownloadUrl(task);
return ApiResponse.success(AssemblyTaskDTO.from(task, downloadUrl)); return ApiResponse.success(AssemblyTaskDTO.from(task, downloadUrl));
} }
public record AssemblyStartRequest(List<Long> videoTaskIds) {
}
} }
...@@ -2,14 +2,21 @@ package com.yaoai.pipeline.service; ...@@ -2,14 +2,21 @@ package com.yaoai.pipeline.service;
import com.yaoai.domain.entity.AssemblyTask; import com.yaoai.domain.entity.AssemblyTask;
import java.util.List;
public interface AssemblyPipelineService { public interface AssemblyPipelineService {
/** 创建合成任务并异步执行 */ default AssemblyTask startAssembly(Long projectId, Long episodeId, Long tenantId) {
AssemblyTask startAssembly(Long projectId, Long episodeId, Long tenantId); return startAssembly(projectId, episodeId, tenantId, null);
}
/**
* Creates an async assembly task. When videoTaskIds is provided, the task
* uses exactly those succeeded video tasks in the given order.
*/
AssemblyTask startAssembly(Long projectId, Long episodeId, Long tenantId, List<Long> videoTaskIds);
/** 查询最新合成任务 */
AssemblyTask getLatest(Long episodeId, Long tenantId); AssemblyTask getLatest(Long episodeId, Long tenantId);
/** 生成签名下载 URL(1小时有效) */
String signedDownloadUrl(AssemblyTask task); String signedDownloadUrl(AssemblyTask task);
} }
...@@ -24,9 +24,12 @@ import java.io.IOException; ...@@ -24,9 +24,12 @@ import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.stream.Collectors;
@Slf4j @Slf4j
@Service @Service
...@@ -61,7 +64,7 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService { ...@@ -61,7 +64,7 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
} }
@Override @Override
public AssemblyTask startAssembly(Long projectId, Long episodeId, Long tenantId) { public AssemblyTask startAssembly(Long projectId, Long episodeId, Long tenantId, List<Long> videoTaskIds) {
Long userId = resolveUserId(); Long userId = resolveUserId();
AssemblyTask task = new AssemblyTask(); AssemblyTask task = new AssemblyTask();
task.setTenantId(tenantId); task.setTenantId(tenantId);
...@@ -75,7 +78,7 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService { ...@@ -75,7 +78,7 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
CompletableFuture.runAsync(() -> { CompletableFuture.runAsync(() -> {
UserContext.set(userId); UserContext.set(userId);
try { try {
doAssemble(taskId, episodeId, userId); doAssemble(taskId, episodeId, userId, videoTaskIds);
} finally { } finally {
UserContext.clear(); UserContext.clear();
} }
...@@ -94,14 +97,16 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService { ...@@ -94,14 +97,16 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
return tosService.publicUrl(task.getResultTosKey()); return tosService.publicUrl(task.getResultTosKey());
} }
private void doAssemble(Long assemblyTaskId, Long episodeId, Long userId) { private void doAssemble(Long assemblyTaskId, Long episodeId, Long userId, List<Long> videoTaskIds) {
AssemblyTask task = assemblyTaskMapper.selectById(assemblyTaskId); AssemblyTask task = assemblyTaskMapper.selectById(assemblyTaskId);
task.setStatus("running"); task.setStatus("running");
assemblyTaskMapper.updateById(task); assemblyTaskMapper.updateById(task);
Path outputFile = null; Path outputFile = null;
try { try {
List<String> urls = buildAssemblyUrls(episodeId, task.getTenantId()); List<String> urls = videoTaskIds != null && !videoTaskIds.isEmpty()
? buildAssemblyUrlsFromTaskIds(videoTaskIds, episodeId, task.getTenantId())
: buildAssemblyUrls(episodeId, task.getTenantId());
if (urls.isEmpty()) { if (urls.isEmpty()) {
fail(task, "没有可用的视频片段(需先生成每个镜头的视频)"); fail(task, "没有可用的视频片段(需先生成每个镜头的视频)");
return; return;
...@@ -143,6 +148,29 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService { ...@@ -143,6 +148,29 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
} }
} }
private List<String> buildAssemblyUrlsFromTaskIds(List<Long> videoTaskIds, Long episodeId, Long tenantId) {
List<AiTask> tasks = aiTaskMapper.selectBatchIds(videoTaskIds);
Map<Long, AiTask> taskById = tasks.stream()
.collect(Collectors.toMap(AiTask::getId, task -> task, (left, right) -> left, LinkedHashMap::new));
List<String> urls = new ArrayList<>();
for (Long taskId : videoTaskIds) {
AiTask task = taskById.get(taskId);
if (task == null
|| !tenantId.equals(task.getTenantId())
|| !episodeId.equals(task.getEpisodeId())
|| !"succeeded".equals(task.getStatus())
|| task.getResultVideoUrl() == null
|| task.getResultVideoUrl().isBlank()) {
continue;
}
urls.add(task.getResultVideoUrl());
}
if (urls.size() != videoTaskIds.size()) {
throw new IllegalStateException("合成视频任务数量不一致:请求 " + videoTaskIds.size() + " 个,实际可用 " + urls.size() + " 个");
}
return urls;
}
private List<String> buildAssemblyUrls(Long episodeId, Long tenantId) { private List<String> buildAssemblyUrls(Long episodeId, Long tenantId) {
List<Storyboard> storyboards = storyboardMapper.findByEpisode(episodeId, tenantId); List<Storyboard> storyboards = storyboardMapper.findByEpisode(episodeId, tenantId);
List<String> urls = new ArrayList<>(); List<String> urls = new ArrayList<>();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment