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

导出视频合成问题fix8

parent 52c75644
......@@ -62,6 +62,7 @@ export function VideoGeneration() {
const orderedShots = storyboards
.map((sb) => ({ sb, task: getTaskForSb(sb) }))
.filter(({ task }) => task?.status === "succeeded" && task.resultVideoUrl);
const orderedShotTaskIds = orderedShots.map(({ task }) => task!.id);
// Stats
const totalShots = storyboards.length;
......@@ -116,6 +117,10 @@ export function VideoGeneration() {
await deleteTask.mutateAsync(taskId);
};
const handleStartAssembly = () => {
startAssembly.mutate(orderedShotTaskIds);
};
// Episode stats helper
const epShotCount = (ep: Episode) => ep.storyboardCount ?? 0;
const epDoneCount = (ep: Episode) =>
......@@ -194,32 +199,32 @@ export function VideoGeneration() {
整集
</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") && (
<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" />合成中...
</span>
)}
{hasAssembly && (
{hasAssembly && assemblyTask?.status !== "running" && assemblyTask?.status !== "pending" && (
<a
href={assemblyTask!.downloadUrl!}
target="_blank"
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" />
导出整集
下载已合成
</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>
......
......@@ -446,7 +446,7 @@ export function useAssemblyTask(projectId: string, episodeId: string) {
export function useStartAssembly(projectId: string, episodeId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: () => aiApi.startAssembly(projectId, episodeId),
mutationFn: (videoTaskIds?: string[]) => aiApi.startAssembly(projectId, episodeId, videoTaskIds),
onSuccess: () => qc.invalidateQueries({ queryKey: assemblyKey(projectId, episodeId) }),
});
}
......
......@@ -406,8 +406,10 @@ export const aiApi = {
},
// ---- Assembly ----
startAssembly: async (projectId: string, episodeId: string): Promise<AssemblyTask> => {
const r = await apiClient.post(`/projects/${projectId}/episodes/${episodeId}/assembly`);
startAssembly: async (projectId: string, episodeId: string, videoTaskIds?: string[]): Promise<AssemblyTask> => {
const r = await apiClient.post(`/projects/${projectId}/episodes/${episodeId}/assembly`, {
videoTaskIds,
});
return r.data.data;
},
getAssemblyTask: async (projectId: string, episodeId: string): Promise<AssemblyTask | null> => {
......
......@@ -10,6 +10,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@Tag(name = "视频合成")
@RestController
@RequestMapping("/projects/{projectId}/episodes/{episodeId}/assembly")
......@@ -21,9 +23,15 @@ public class AssemblyController {
@Operation(summary = "启动合成任务")
@PostMapping
public ApiResponse<AssemblyTaskDTO> start(@PathVariable Long projectId,
@PathVariable Long episodeId) {
@PathVariable Long episodeId,
@RequestBody(required = false) AssemblyStartRequest req) {
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));
}
......@@ -39,4 +47,7 @@ public class AssemblyController {
String downloadUrl = assemblyPipelineService.signedDownloadUrl(task);
return ApiResponse.success(AssemblyTaskDTO.from(task, downloadUrl));
}
public record AssemblyStartRequest(List<Long> videoTaskIds) {
}
}
......@@ -2,14 +2,21 @@ package com.yaoai.pipeline.service;
import com.yaoai.domain.entity.AssemblyTask;
import java.util.List;
public interface AssemblyPipelineService {
/** 创建合成任务并异步执行 */
AssemblyTask startAssembly(Long projectId, Long episodeId, Long tenantId);
default 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);
/** 生成签名下载 URL(1小时有效) */
String signedDownloadUrl(AssemblyTask task);
}
......@@ -24,9 +24,12 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.stream.Collectors;
@Slf4j
@Service
......@@ -61,7 +64,7 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
}
@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();
AssemblyTask task = new AssemblyTask();
task.setTenantId(tenantId);
......@@ -75,7 +78,7 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
CompletableFuture.runAsync(() -> {
UserContext.set(userId);
try {
doAssemble(taskId, episodeId, userId);
doAssemble(taskId, episodeId, userId, videoTaskIds);
} finally {
UserContext.clear();
}
......@@ -94,14 +97,16 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
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);
task.setStatus("running");
assemblyTaskMapper.updateById(task);
Path outputFile = null;
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()) {
fail(task, "没有可用的视频片段(需先生成每个镜头的视频)");
return;
......@@ -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) {
List<Storyboard> storyboards = storyboardMapper.findByEpisode(episodeId, tenantId);
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