Commit 8c8e78a2 authored by yaoke.yk's avatar yaoke.yk

角色和场景可以手动上传、导出视频修复

parent c8d58c47
...@@ -80,6 +80,16 @@ const emptyPendingFiles = (): PendingFiles => ({ ...@@ -80,6 +80,16 @@ const emptyPendingFiles = (): PendingFiles => ({
back: null, back: null,
}); });
function revokeObjectPreview(url: string | null) {
if (url?.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
function revokeImagePreviews(previews: ImageState) {
Object.values(previews).forEach(revokeObjectPreview);
}
const emptyStateDraft = (): CharacterStateDraft => ({ const emptyStateDraft = (): CharacterStateDraft => ({
name: "", name: "",
stateType: "costume", stateType: "costume",
...@@ -207,14 +217,17 @@ export function CharacterGeneration() { ...@@ -207,14 +217,17 @@ export function CharacterGeneration() {
}; };
const openCreate = () => { const openCreate = () => {
revokeImagePreviews(imagePreviews);
setEditingChar(emptyForm()); setEditingChar(emptyForm());
setImagePreviews(emptyImageState()); setImagePreviews(emptyImageState());
setPendingFiles(emptyPendingFiles()); setPendingFiles(emptyPendingFiles());
setStateDraft(emptyStateDraft()); setStateDraft(emptyStateDraft());
fileInputRefs.current.front && (fileInputRefs.current.front.value = "");
setShowModal(true); setShowModal(true);
}; };
const openEdit = (character: Character) => { const openEdit = (character: Character) => {
revokeImagePreviews(imagePreviews);
const prompt = character.imagePrompt?.trim() ?? ""; const prompt = character.imagePrompt?.trim() ?? "";
setEditingChar({ setEditingChar({
...character, ...character,
...@@ -229,18 +242,29 @@ export function CharacterGeneration() { ...@@ -229,18 +242,29 @@ export function CharacterGeneration() {
}); });
setPendingFiles(emptyPendingFiles()); setPendingFiles(emptyPendingFiles());
setStateDraft(emptyStateDraft()); setStateDraft(emptyStateDraft());
fileInputRefs.current.front && (fileInputRefs.current.front.value = "");
setShowModal(true); setShowModal(true);
}; };
const closeModal = () => {
revokeImagePreviews(imagePreviews);
setShowModal(false);
setPendingFiles(emptyPendingFiles());
fileInputRefs.current.front && (fileInputRefs.current.front.value = "");
};
const handleFileSelect = (viewType: CharacterViewType, e: ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (viewType: CharacterViewType, e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
revokeObjectPreview(imagePreviews[viewType]);
setPendingFiles((current) => ({ ...current, [viewType]: file })); setPendingFiles((current) => ({ ...current, [viewType]: file }));
setImagePreviews((current) => ({ ...current, [viewType]: URL.createObjectURL(file) })); setImagePreviews((current) => ({ ...current, [viewType]: URL.createObjectURL(file) }));
e.target.value = "";
}; };
const clearImage = (viewType: CharacterViewType) => { const clearImage = (viewType: CharacterViewType) => {
revokeObjectPreview(imagePreviews[viewType]);
setPendingFiles((current) => ({ ...current, [viewType]: null })); setPendingFiles((current) => ({ ...current, [viewType]: null }));
setImagePreviews((current) => ({ setImagePreviews((current) => ({
...current, ...current,
...@@ -261,7 +285,7 @@ export function CharacterGeneration() { ...@@ -261,7 +285,7 @@ export function CharacterGeneration() {
if (pendingFile) { if (pendingFile) {
await uploadImage.mutateAsync({ id: String(saved.id), file: pendingFile, viewType: "front" }); await uploadImage.mutateAsync({ id: String(saved.id), file: pendingFile, viewType: "front" });
} }
setShowModal(false); closeModal();
} catch (error) { } catch (error) {
alert(`保存失败: ${(error as Error).message}`); alert(`保存失败: ${(error as Error).message}`);
} finally { } finally {
...@@ -598,7 +622,7 @@ export function CharacterGeneration() { ...@@ -598,7 +622,7 @@ export function CharacterGeneration() {
<h2 className="text-lg font-semibold text-foreground">{editingChar.id ? "编辑角色" : "手动添加角色"}</h2> <h2 className="text-lg font-semibold text-foreground">{editingChar.id ? "编辑角色" : "手动添加角色"}</h2>
<p className="text-sm text-muted-foreground mt-1">角色图已合并为单张图片,只需要维护图片和生成提示词。</p> <p className="text-sm text-muted-foreground mt-1">角色图已合并为单张图片,只需要维护图片和生成提示词。</p>
</div> </div>
<button onClick={() => setShowModal(false)} className="p-2 hover:bg-muted rounded-lg transition-colors"> <button onClick={closeModal} className="p-2 hover:bg-muted rounded-lg transition-colors">
<X className="w-5 h-5 text-muted-foreground" /> <X className="w-5 h-5 text-muted-foreground" />
</button> </button>
</div> </div>
...@@ -610,9 +634,9 @@ export function CharacterGeneration() { ...@@ -610,9 +634,9 @@ export function CharacterGeneration() {
<label className="block text-sm font-medium text-foreground">角色图片(可选)</label> <label className="block text-sm font-medium text-foreground">角色图片(可选)</label>
<p className="text-xs text-muted-foreground mt-1">上传大头照与三视图合并后的单张角色参考图。</p> <p className="text-xs text-muted-foreground mt-1">上传大头照与三视图合并后的单张角色参考图。</p>
</div> </div>
{imagePreviews.front && ( {pendingFiles.front && (
<button onClick={() => clearImage("front")} className="text-xs text-destructive hover:underline"> <button onClick={() => clearImage("front")} className="text-xs text-destructive hover:underline">
移除 撤销更换
</button> </button>
)} )}
</div> </div>
...@@ -629,6 +653,16 @@ export function CharacterGeneration() { ...@@ -629,6 +653,16 @@ export function CharacterGeneration() {
</div> </div>
)} )}
</div> </div>
{imagePreviews.front && (
<button
type="button"
onClick={() => fileInputRefs.current.front?.click()}
className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-xs text-foreground hover:bg-muted"
>
<Upload className="h-3.5 w-3.5" />
更换图片
</button>
)}
<input <input
ref={(node) => { ref={(node) => {
fileInputRefs.current.front = node; fileInputRefs.current.front = node;
...@@ -876,17 +910,17 @@ export function CharacterGeneration() { ...@@ -876,17 +910,17 @@ export function CharacterGeneration() {
<div className="flex gap-3 p-6 border-t border-border flex-shrink-0"> <div className="flex gap-3 p-6 border-t border-border flex-shrink-0">
<button <button
onClick={() => setShowModal(false)} onClick={closeModal}
className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors" className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
> >
取消 取消
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={!editingChar.name?.trim() || saving} disabled={!editingChar.name?.trim() || saving || uploadImage.isPending}
className="flex-1 px-4 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 disabled:opacity-50" className="flex-1 px-4 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 disabled:opacity-50"
> >
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />} {saving || uploadImage.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
保存 保存
</button> </button>
</div> </div>
......
import { useState, useRef } from "react"; import { useRef, useState, type ChangeEvent } from "react";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import { ArrowRight, Sparkles, Plus, Edit2, Users, MapPin, Box, Trash2, X, Check, RefreshCw, ImageIcon, Loader2, Upload } from "lucide-react"; import { ArrowRight, Sparkles, Plus, Edit2, Users, MapPin, Box, Trash2, X, Check, RefreshCw, ImageIcon, Loader2, Upload } from "lucide-react";
import { useScenes, useExtractScenes, useGenerateSceneImage, useDeleteScene, useSaveScene, useUploadSceneImage } from "../../hooks/useAi"; import { useScenes, useExtractScenes, useGenerateSceneImage, useDeleteScene, useSaveScene, useUploadSceneImage } from "../../hooks/useAi";
...@@ -10,6 +10,12 @@ const emptyForm = (): Partial<Scene> => ({ ...@@ -10,6 +10,12 @@ const emptyForm = (): Partial<Scene> => ({
name: "", sceneType: "indoor", description: "", imagePrompt: "", name: "", sceneType: "indoor", description: "", imagePrompt: "",
}); });
function revokeObjectPreview(url: string | null) {
if (url?.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
export function SceneGeneration() { export function SceneGeneration() {
const navigate = useNavigate(); const navigate = useNavigate();
const { projectId } = useParams(); const { projectId } = useParams();
...@@ -44,24 +50,44 @@ export function SceneGeneration() { ...@@ -44,24 +50,44 @@ export function SceneGeneration() {
}; };
const openCreate = () => { const openCreate = () => {
revokeObjectPreview(imagePreview);
setEditingScene(emptyForm()); setEditingScene(emptyForm());
setImagePreview(null); setImagePreview(null);
setPendingFile(null); setPendingFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
setShowModal(true); setShowModal(true);
}; };
const openEdit = (scene: Scene) => { const openEdit = (scene: Scene) => {
revokeObjectPreview(imagePreview);
setEditingScene({ ...scene }); setEditingScene({ ...scene });
setImagePreview(scene.imageUrl ?? null); setImagePreview(scene.imageUrl ?? null);
setPendingFile(null); setPendingFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
setShowModal(true); setShowModal(true);
}; };
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const closeModal = () => {
revokeObjectPreview(imagePreview);
setShowModal(false);
setPendingFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const handleFileSelect = (e: ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0]; const f = e.target.files?.[0];
if (!f) return; if (!f) return;
revokeObjectPreview(imagePreview);
setPendingFile(f); setPendingFile(f);
setImagePreview(URL.createObjectURL(f)); setImagePreview(URL.createObjectURL(f));
e.target.value = "";
};
const clearSelectedImage = () => {
revokeObjectPreview(imagePreview);
setPendingFile(null);
setImagePreview(editingScene.imageUrl ?? null);
if (fileInputRef.current) fileInputRef.current.value = "";
}; };
const handleSave = async () => { const handleSave = async () => {
...@@ -72,7 +98,7 @@ export function SceneGeneration() { ...@@ -72,7 +98,7 @@ export function SceneGeneration() {
if (pendingFile) { if (pendingFile) {
await uploadImage.mutateAsync({ id: String(saved.id), file: pendingFile }); await uploadImage.mutateAsync({ id: String(saved.id), file: pendingFile });
} }
setShowModal(false); closeModal();
} catch (e) { } catch (e) {
alert("保存失败: " + (e as Error).message); alert("保存失败: " + (e as Error).message);
} finally { } finally {
...@@ -308,13 +334,13 @@ export function SceneGeneration() { ...@@ -308,13 +334,13 @@ export function SceneGeneration() {
{/* Add/Edit Scene Modal */} {/* Add/Edit Scene Modal */}
{showModal && ( {showModal && (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-6"> <div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-6">
<div className="bg-card rounded-2xl border border-border w-full max-w-md max-h-[90vh] flex flex-col"> <div className="bg-card rounded-2xl border border-border w-full max-w-2xl max-h-[90vh] flex flex-col">
{/* Modal Header */} {/* Modal Header */}
<div className="flex items-center justify-between p-6 border-b border-border flex-shrink-0"> <div className="flex items-center justify-between p-6 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold text-foreground"> <h2 className="text-lg font-semibold text-foreground">
{editingScene.id ? "编辑场景" : "手动添加场景"} {editingScene.id ? "编辑场景" : "手动添加场景"}
</h2> </h2>
<button onClick={() => setShowModal(false)} className="p-2 hover:bg-muted rounded-lg transition-colors"> <button onClick={closeModal} className="p-2 hover:bg-muted rounded-lg transition-colors">
<X className="w-5 h-5 text-muted-foreground" /> <X className="w-5 h-5 text-muted-foreground" />
</button> </button>
</div> </div>
...@@ -322,16 +348,26 @@ export function SceneGeneration() { ...@@ -322,16 +348,26 @@ export function SceneGeneration() {
{/* Modal Body */} {/* Modal Body */}
<div className="overflow-auto p-6 space-y-4 flex-1"> <div className="overflow-auto p-6 space-y-4 flex-1">
{/* Image Upload */} {/* Image Upload */}
<div className="rounded-xl border border-border bg-muted/20 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div> <div>
<label className="block text-sm font-medium text-foreground mb-2">场景图片(可选)</label> <label className="block text-sm font-medium text-foreground">场景图片(可选)</label>
<p className="mt-1 text-xs text-muted-foreground">可以上传自己的场景参考图,保存后会覆盖当前场景图。</p>
</div>
{pendingFile && (
<button onClick={clearSelectedImage} className="text-xs text-destructive hover:underline">
撤销更换
</button>
)}
</div>
<div <div
className="w-full aspect-video rounded-lg border-2 border-dashed border-border bg-muted flex items-center justify-center cursor-pointer hover:border-primary transition-colors overflow-hidden" className="w-full aspect-video rounded-lg border-2 border-dashed border-border bg-background flex items-center justify-center cursor-pointer hover:border-primary transition-colors overflow-hidden"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
> >
{imagePreview ? ( {imagePreview ? (
<img src={imagePreview} alt="预览" className="w-full h-full object-cover" /> <img src={imagePreview} alt="场景图片预览" className="w-full h-full object-cover" />
) : ( ) : (
<div className="text-center"> <div className="text-center px-4">
<Upload className="w-8 h-8 text-muted-foreground mx-auto mb-2" /> <Upload className="w-8 h-8 text-muted-foreground mx-auto mb-2" />
<span className="text-sm text-muted-foreground">点击上传场景参考图</span> <span className="text-sm text-muted-foreground">点击上传场景参考图</span>
</div> </div>
...@@ -339,10 +375,12 @@ export function SceneGeneration() { ...@@ -339,10 +375,12 @@ export function SceneGeneration() {
</div> </div>
{imagePreview && ( {imagePreview && (
<button <button
onClick={() => { setImagePreview(null); setPendingFile(null); }} type="button"
className="mt-1 text-xs text-destructive hover:underline" onClick={() => fileInputRef.current?.click()}
className="mt-2 inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-xs text-foreground hover:bg-muted"
> >
移除图片 <Upload className="h-3.5 w-3.5" />
更换图片
</button> </button>
)} )}
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileSelect} /> <input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileSelect} />
...@@ -403,17 +441,17 @@ export function SceneGeneration() { ...@@ -403,17 +441,17 @@ export function SceneGeneration() {
{/* Modal Footer */} {/* Modal Footer */}
<div className="flex gap-3 p-6 border-t border-border flex-shrink-0"> <div className="flex gap-3 p-6 border-t border-border flex-shrink-0">
<button <button
onClick={() => setShowModal(false)} onClick={closeModal}
className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors" className="flex-1 px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
> >
取消 取消
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={!editingScene.name?.trim() || saving} disabled={!editingScene.name?.trim() || saving || uploadImage.isPending}
className="flex-1 px-4 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 disabled:opacity-50" className="flex-1 px-4 py-2.5 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all flex items-center justify-center gap-2 disabled:opacity-50"
> >
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />} {saving || uploadImage.isPending ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
保存 保存
</button> </button>
</div> </div>
......
...@@ -146,7 +146,10 @@ export function useUploadCharacterImage(projectId: string) { ...@@ -146,7 +146,10 @@ export function useUploadCharacterImage(projectId: string) {
return useMutation({ return useMutation({
mutationFn: ({ id, file, viewType }: { id: string; file: File; viewType: "front" | "side" | "back" }) => mutationFn: ({ id, file, viewType }: { id: string; file: File; viewType: "front" | "side" | "back" }) =>
aiApi.uploadCharacterImage(projectId, id, file, viewType), aiApi.uploadCharacterImage(projectId, id, file, viewType),
onSuccess: () => qc.invalidateQueries({ queryKey: charactersKey(projectId) }), onSuccess: (character) => {
qc.setQueryData<Character[]>(charactersKey(projectId), (current) => upsertById(current, character));
qc.invalidateQueries({ queryKey: charactersKey(projectId) });
},
}); });
} }
export function useDeleteCharacter(projectId: string) { export function useDeleteCharacter(projectId: string) {
...@@ -218,7 +221,13 @@ export function useGenerateSceneImage(projectId: string) { ...@@ -218,7 +221,13 @@ export function useGenerateSceneImage(projectId: string) {
} }
export function useUploadSceneImage(projectId: string) { export function useUploadSceneImage(projectId: string) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ mutationFn: ({ id, file }: { id: string; file: File }) => aiApi.uploadSceneImage(projectId, id, file), onSuccess: () => qc.invalidateQueries({ queryKey: scenesKey(projectId) }) }); return useMutation({
mutationFn: ({ id, file }: { id: string; file: File }) => aiApi.uploadSceneImage(projectId, id, file),
onSuccess: (scene) => {
qc.setQueryData<Scene[]>(scenesKey(projectId), (current) => upsertById(current, scene));
qc.invalidateQueries({ queryKey: scenesKey(projectId) });
},
});
} }
export function useDeleteScene(projectId: string) { export function useDeleteScene(projectId: string) {
const qc = useQueryClient(); const qc = useQueryClient();
......
...@@ -317,7 +317,7 @@ export const aiApi = { ...@@ -317,7 +317,7 @@ export const aiApi = {
const r = await apiClient.post( const r = await apiClient.post(
`/projects/${projectId}/characters/${characterId}/upload-image?viewType=${viewType}`, `/projects/${projectId}/characters/${characterId}/upload-image?viewType=${viewType}`,
form, form,
{ headers: { "Content-Type": undefined } } { headers: { "Content-Type": "multipart/form-data" } }
); );
return r.data.data; return r.data.data;
}, },
...@@ -356,7 +356,7 @@ export const aiApi = { ...@@ -356,7 +356,7 @@ export const aiApi = {
uploadSceneImage: async (projectId: string, sceneId: string, file: File): Promise<Scene> => { uploadSceneImage: async (projectId: string, sceneId: string, file: File): Promise<Scene> => {
const form = new FormData(); const form = new FormData();
form.append("file", file); form.append("file", file);
const r = await apiClient.post(`/projects/${projectId}/scenes/${sceneId}/upload-image`, form, { headers: { "Content-Type": undefined } }); const r = await apiClient.post(`/projects/${projectId}/scenes/${sceneId}/upload-image`, form, { headers: { "Content-Type": "multipart/form-data" } });
return r.data.data; return r.data.data;
}, },
deleteScene: async (projectId: string, sceneId: number | string): Promise<void> => { deleteScene: async (projectId: string, sceneId: number | string): Promise<void> => {
......
...@@ -18,6 +18,23 @@ public interface ShotAssetMapper extends BaseMapper<ShotAsset> { ...@@ -18,6 +18,23 @@ public interface ShotAssetMapper extends BaseMapper<ShotAsset> {
@Select("SELECT * FROM shot_assets WHERE storyboard_id=#{storyboardId} AND tenant_id=#{tenantId} ORDER BY asset_type, asset_version DESC, created_at DESC") @Select("SELECT * FROM shot_assets WHERE storyboard_id=#{storyboardId} AND tenant_id=#{tenantId} ORDER BY asset_type, asset_version DESC, created_at DESC")
List<ShotAsset> findByStoryboard(@Param("storyboardId") Long storyboardId, @Param("tenantId") Long tenantId); List<ShotAsset> findByStoryboard(@Param("storyboardId") Long storyboardId, @Param("tenantId") Long tenantId);
@Select("""
SELECT a.* FROM shot_assets a
LEFT JOIN storyboards s ON a.storyboard_id = s.id
LEFT JOIN ai_tasks t ON a.source_task_id = t.id AND a.tenant_id = t.tenant_id
WHERE a.episode_id = #{episodeId}
AND a.tenant_id = #{tenantId}
AND a.asset_type = 'video'
AND a.is_active = 1
AND a.status = 'ready'
AND a.external_url IS NOT NULL
AND a.external_url <> ''
AND (a.source_task_id IS NULL OR t.id IS NOT NULL)
ORDER BY COALESCE(s.sequence_num, 9999), a.created_at
""")
List<ShotAsset> findActiveReadyVideosByEpisode(@Param("episodeId") Long episodeId,
@Param("tenantId") Long tenantId);
@Select("SELECT * FROM shot_assets WHERE storyboard_id=#{storyboardId} AND tenant_id=#{tenantId} AND asset_type=#{assetType} ORDER BY asset_version DESC, created_at DESC") @Select("SELECT * FROM shot_assets WHERE storyboard_id=#{storyboardId} AND tenant_id=#{tenantId} AND asset_type=#{assetType} ORDER BY asset_version DESC, created_at DESC")
List<ShotAsset> findHistory(@Param("storyboardId") Long storyboardId, List<ShotAsset> findHistory(@Param("storyboardId") Long storyboardId,
@Param("tenantId") Long tenantId, @Param("tenantId") Long tenantId,
...@@ -35,4 +52,7 @@ public interface ShotAssetMapper extends BaseMapper<ShotAsset> { ...@@ -35,4 +52,7 @@ public interface ShotAssetMapper extends BaseMapper<ShotAsset> {
int deactivateActive(@Param("storyboardId") Long storyboardId, int deactivateActive(@Param("storyboardId") Long storyboardId,
@Param("tenantId") Long tenantId, @Param("tenantId") Long tenantId,
@Param("assetType") String assetType); @Param("assetType") String assetType);
@Update("UPDATE shot_assets SET is_active=0 WHERE source_task_id=#{sourceTaskId} AND tenant_id=#{tenantId} AND is_active=1")
int deactivateBySourceTask(@Param("sourceTaskId") Long sourceTaskId, @Param("tenantId") Long tenantId);
} }
...@@ -20,4 +20,6 @@ public interface ShotAssetService { ...@@ -20,4 +20,6 @@ public interface ShotAssetService {
void markVideoAssetSucceeded(Long tenantId, Long taskId, String videoUrl); void markVideoAssetSucceeded(Long tenantId, Long taskId, String videoUrl);
void markVideoAssetFailed(Long tenantId, Long taskId, String errorMessage); void markVideoAssetFailed(Long tenantId, Long taskId, String errorMessage);
void deactivateBySourceTask(Long tenantId, Long taskId);
} }
...@@ -4,10 +4,10 @@ import cn.dev33.satoken.stp.StpUtil; ...@@ -4,10 +4,10 @@ import cn.dev33.satoken.stp.StpUtil;
import com.yaoai.billing.dto.BillingChargeRequest; import com.yaoai.billing.dto.BillingChargeRequest;
import com.yaoai.billing.service.BillingService; import com.yaoai.billing.service.BillingService;
import com.yaoai.common.context.UserContext; import com.yaoai.common.context.UserContext;
import com.yaoai.domain.entity.AiTask;
import com.yaoai.domain.entity.AssemblyTask; import com.yaoai.domain.entity.AssemblyTask;
import com.yaoai.domain.mapper.AiTaskMapper; import com.yaoai.domain.entity.ShotAsset;
import com.yaoai.domain.mapper.AssemblyTaskMapper; import com.yaoai.domain.mapper.AssemblyTaskMapper;
import com.yaoai.domain.mapper.ShotAssetMapper;
import com.yaoai.media.service.FfmpegService; import com.yaoai.media.service.FfmpegService;
import com.yaoai.pipeline.service.AssemblyPipelineService; import com.yaoai.pipeline.service.AssemblyPipelineService;
import com.yaoai.storage.service.TosService; import com.yaoai.storage.service.TosService;
...@@ -28,7 +28,7 @@ import java.util.concurrent.Executor; ...@@ -28,7 +28,7 @@ import java.util.concurrent.Executor;
public class AssemblyPipelineServiceImpl implements AssemblyPipelineService { public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
private final AssemblyTaskMapper assemblyTaskMapper; private final AssemblyTaskMapper assemblyTaskMapper;
private final AiTaskMapper aiTaskMapper; private final ShotAssetMapper shotAssetMapper;
private final FfmpegService ffmpegService; private final FfmpegService ffmpegService;
private final TosService tosService; private final TosService tosService;
private final BillingService billingService; private final BillingService billingService;
...@@ -36,13 +36,13 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService { ...@@ -36,13 +36,13 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
public AssemblyPipelineServiceImpl( public AssemblyPipelineServiceImpl(
AssemblyTaskMapper assemblyTaskMapper, AssemblyTaskMapper assemblyTaskMapper,
AiTaskMapper aiTaskMapper, ShotAssetMapper shotAssetMapper,
FfmpegService ffmpegService, FfmpegService ffmpegService,
TosService tosService, TosService tosService,
BillingService billingService, BillingService billingService,
@Qualifier("assemblyExecutor") Executor assemblyExecutor) { @Qualifier("assemblyExecutor") Executor assemblyExecutor) {
this.assemblyTaskMapper = assemblyTaskMapper; this.assemblyTaskMapper = assemblyTaskMapper;
this.aiTaskMapper = aiTaskMapper; this.shotAssetMapper = shotAssetMapper;
this.ffmpegService = ffmpegService; this.ffmpegService = ffmpegService;
this.tosService = tosService; this.tosService = tosService;
this.billingService = billingService; this.billingService = billingService;
...@@ -90,14 +90,14 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService { ...@@ -90,14 +90,14 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
Path outputFile = null; Path outputFile = null;
try { try {
List<AiTask> shots = aiTaskMapper.findSucceededByEpisodeOrdered(episodeId, task.getTenantId()); List<ShotAsset> shots = shotAssetMapper.findActiveReadyVideosByEpisode(episodeId, task.getTenantId());
if (shots.isEmpty()) { if (shots.isEmpty()) {
fail(task, "没有可用的视频片段(需先生成每个镜头的视频)"); fail(task, "没有可用的视频片段(需先生成每个镜头的视频)");
return; return;
} }
List<String> urls = shots.stream() List<String> urls = shots.stream()
.map(AiTask::getResultVideoUrl) .map(ShotAsset::getExternalUrl)
.filter(u -> u != null && !u.isBlank()) .filter(u -> u != null && !u.isBlank())
.toList(); .toList();
......
...@@ -98,6 +98,14 @@ public class ShotAssetServiceImpl implements ShotAssetService { ...@@ -98,6 +98,14 @@ public class ShotAssetServiceImpl implements ShotAssetService {
updateVideoAssetStatus(tenantId, taskId, "failed", null, errorMessage); updateVideoAssetStatus(tenantId, taskId, "failed", null, errorMessage);
} }
@Override
public void deactivateBySourceTask(Long tenantId, Long taskId) {
if (taskId == null) {
return;
}
shotAssetMapper.deactivateBySourceTask(taskId, tenantId);
}
private void updateVideoAssetStatus(Long tenantId, Long taskId, String status, String videoUrl, String errorMessage) { private void updateVideoAssetStatus(Long tenantId, Long taskId, String status, String videoUrl, String errorMessage) {
if (taskId == null) { if (taskId == null) {
return; return;
......
...@@ -317,6 +317,7 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService { ...@@ -317,6 +317,7 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
if (task == null || !tenantId.equals(task.getTenantId())) { if (task == null || !tenantId.equals(task.getTenantId())) {
throw new BizException(ErrorCode.NOT_FOUND, "任务不存在"); throw new BizException(ErrorCode.NOT_FOUND, "任务不存在");
} }
shotAssetService.deactivateBySourceTask(tenantId, taskId);
aiTaskMapper.deleteById(taskId); aiTaskMapper.deleteById(taskId);
log.info("AiTask deleted: id={}", taskId); log.info("AiTask deleted: id={}", taskId);
} }
......
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