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

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

parent c8d58c47
......@@ -80,6 +80,16 @@ const emptyPendingFiles = (): PendingFiles => ({
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 => ({
name: "",
stateType: "costume",
......@@ -207,14 +217,17 @@ export function CharacterGeneration() {
};
const openCreate = () => {
revokeImagePreviews(imagePreviews);
setEditingChar(emptyForm());
setImagePreviews(emptyImageState());
setPendingFiles(emptyPendingFiles());
setStateDraft(emptyStateDraft());
fileInputRefs.current.front && (fileInputRefs.current.front.value = "");
setShowModal(true);
};
const openEdit = (character: Character) => {
revokeImagePreviews(imagePreviews);
const prompt = character.imagePrompt?.trim() ?? "";
setEditingChar({
...character,
......@@ -229,18 +242,29 @@ export function CharacterGeneration() {
});
setPendingFiles(emptyPendingFiles());
setStateDraft(emptyStateDraft());
fileInputRefs.current.front && (fileInputRefs.current.front.value = "");
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 file = e.target.files?.[0];
if (!file) return;
revokeObjectPreview(imagePreviews[viewType]);
setPendingFiles((current) => ({ ...current, [viewType]: file }));
setImagePreviews((current) => ({ ...current, [viewType]: URL.createObjectURL(file) }));
e.target.value = "";
};
const clearImage = (viewType: CharacterViewType) => {
revokeObjectPreview(imagePreviews[viewType]);
setPendingFiles((current) => ({ ...current, [viewType]: null }));
setImagePreviews((current) => ({
...current,
......@@ -261,7 +285,7 @@ export function CharacterGeneration() {
if (pendingFile) {
await uploadImage.mutateAsync({ id: String(saved.id), file: pendingFile, viewType: "front" });
}
setShowModal(false);
closeModal();
} catch (error) {
alert(`保存失败: ${(error as Error).message}`);
} finally {
......@@ -598,7 +622,7 @@ export function CharacterGeneration() {
<h2 className="text-lg font-semibold text-foreground">{editingChar.id ? "编辑角色" : "手动添加角色"}</h2>
<p className="text-sm text-muted-foreground mt-1">角色图已合并为单张图片,只需要维护图片和生成提示词。</p>
</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" />
</button>
</div>
......@@ -610,9 +634,9 @@ export function CharacterGeneration() {
<label className="block text-sm font-medium text-foreground">角色图片(可选)</label>
<p className="text-xs text-muted-foreground mt-1">上传大头照与三视图合并后的单张角色参考图。</p>
</div>
{imagePreviews.front && (
{pendingFiles.front && (
<button onClick={() => clearImage("front")} className="text-xs text-destructive hover:underline">
移除
撤销更换
</button>
)}
</div>
......@@ -629,6 +653,16 @@ export function CharacterGeneration() {
</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
ref={(node) => {
fileInputRefs.current.front = node;
......@@ -876,17 +910,17 @@ export function CharacterGeneration() {
<div className="flex gap-3 p-6 border-t border-border flex-shrink-0">
<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"
>
取消
</button>
<button
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"
>
{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>
</div>
......
import { useState, useRef } from "react";
import { useRef, useState, type ChangeEvent } from "react";
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 { useScenes, useExtractScenes, useGenerateSceneImage, useDeleteScene, useSaveScene, useUploadSceneImage } from "../../hooks/useAi";
......@@ -10,6 +10,12 @@ const emptyForm = (): Partial<Scene> => ({
name: "", sceneType: "indoor", description: "", imagePrompt: "",
});
function revokeObjectPreview(url: string | null) {
if (url?.startsWith("blob:")) {
URL.revokeObjectURL(url);
}
}
export function SceneGeneration() {
const navigate = useNavigate();
const { projectId } = useParams();
......@@ -44,24 +50,44 @@ export function SceneGeneration() {
};
const openCreate = () => {
revokeObjectPreview(imagePreview);
setEditingScene(emptyForm());
setImagePreview(null);
setPendingFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
setShowModal(true);
};
const openEdit = (scene: Scene) => {
revokeObjectPreview(imagePreview);
setEditingScene({ ...scene });
setImagePreview(scene.imageUrl ?? null);
setPendingFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
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];
if (!f) return;
revokeObjectPreview(imagePreview);
setPendingFile(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 () => {
......@@ -72,7 +98,7 @@ export function SceneGeneration() {
if (pendingFile) {
await uploadImage.mutateAsync({ id: String(saved.id), file: pendingFile });
}
setShowModal(false);
closeModal();
} catch (e) {
alert("保存失败: " + (e as Error).message);
} finally {
......@@ -308,13 +334,13 @@ export function SceneGeneration() {
{/* Add/Edit Scene Modal */}
{showModal && (
<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 */}
<div className="flex items-center justify-between p-6 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold text-foreground">
{editingScene.id ? "编辑场景" : "手动添加场景"}
</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" />
</button>
</div>
......@@ -322,16 +348,26 @@ export function SceneGeneration() {
{/* Modal Body */}
<div className="overflow-auto p-6 space-y-4 flex-1">
{/* 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>
<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
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()}
>
{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" />
<span className="text-sm text-muted-foreground">点击上传场景参考图</span>
</div>
......@@ -339,10 +375,12 @@ export function SceneGeneration() {
</div>
{imagePreview && (
<button
onClick={() => { setImagePreview(null); setPendingFile(null); }}
className="mt-1 text-xs text-destructive hover:underline"
type="button"
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>
)}
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileSelect} />
......@@ -403,17 +441,17 @@ export function SceneGeneration() {
{/* Modal Footer */}
<div className="flex gap-3 p-6 border-t border-border flex-shrink-0">
<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"
>
取消
</button>
<button
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"
>
{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>
</div>
......
......@@ -146,7 +146,10 @@ export function useUploadCharacterImage(projectId: string) {
return useMutation({
mutationFn: ({ id, file, viewType }: { id: string; file: File; viewType: "front" | "side" | "back" }) =>
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) {
......@@ -218,7 +221,13 @@ export function useGenerateSceneImage(projectId: string) {
}
export function useUploadSceneImage(projectId: string) {
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) {
const qc = useQueryClient();
......
......@@ -317,7 +317,7 @@ export const aiApi = {
const r = await apiClient.post(
`/projects/${projectId}/characters/${characterId}/upload-image?viewType=${viewType}`,
form,
{ headers: { "Content-Type": undefined } }
{ headers: { "Content-Type": "multipart/form-data" } }
);
return r.data.data;
},
......@@ -356,7 +356,7 @@ export const aiApi = {
uploadSceneImage: async (projectId: string, sceneId: string, file: File): Promise<Scene> => {
const form = new FormData();
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;
},
deleteScene: async (projectId: string, sceneId: number | string): Promise<void> => {
......
......@@ -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")
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")
List<ShotAsset> findHistory(@Param("storyboardId") Long storyboardId,
@Param("tenantId") Long tenantId,
......@@ -35,4 +52,7 @@ public interface ShotAssetMapper extends BaseMapper<ShotAsset> {
int deactivateActive(@Param("storyboardId") Long storyboardId,
@Param("tenantId") Long tenantId,
@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 {
void markVideoAssetSucceeded(Long tenantId, Long taskId, String videoUrl);
void markVideoAssetFailed(Long tenantId, Long taskId, String errorMessage);
void deactivateBySourceTask(Long tenantId, Long taskId);
}
......@@ -4,10 +4,10 @@ import cn.dev33.satoken.stp.StpUtil;
import com.yaoai.billing.dto.BillingChargeRequest;
import com.yaoai.billing.service.BillingService;
import com.yaoai.common.context.UserContext;
import com.yaoai.domain.entity.AiTask;
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.ShotAssetMapper;
import com.yaoai.media.service.FfmpegService;
import com.yaoai.pipeline.service.AssemblyPipelineService;
import com.yaoai.storage.service.TosService;
......@@ -28,7 +28,7 @@ import java.util.concurrent.Executor;
public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
private final AssemblyTaskMapper assemblyTaskMapper;
private final AiTaskMapper aiTaskMapper;
private final ShotAssetMapper shotAssetMapper;
private final FfmpegService ffmpegService;
private final TosService tosService;
private final BillingService billingService;
......@@ -36,13 +36,13 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
public AssemblyPipelineServiceImpl(
AssemblyTaskMapper assemblyTaskMapper,
AiTaskMapper aiTaskMapper,
ShotAssetMapper shotAssetMapper,
FfmpegService ffmpegService,
TosService tosService,
BillingService billingService,
@Qualifier("assemblyExecutor") Executor assemblyExecutor) {
this.assemblyTaskMapper = assemblyTaskMapper;
this.aiTaskMapper = aiTaskMapper;
this.shotAssetMapper = shotAssetMapper;
this.ffmpegService = ffmpegService;
this.tosService = tosService;
this.billingService = billingService;
......@@ -90,14 +90,14 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
Path outputFile = null;
try {
List<AiTask> shots = aiTaskMapper.findSucceededByEpisodeOrdered(episodeId, task.getTenantId());
List<ShotAsset> shots = shotAssetMapper.findActiveReadyVideosByEpisode(episodeId, task.getTenantId());
if (shots.isEmpty()) {
fail(task, "没有可用的视频片段(需先生成每个镜头的视频)");
return;
}
List<String> urls = shots.stream()
.map(AiTask::getResultVideoUrl)
.map(ShotAsset::getExternalUrl)
.filter(u -> u != null && !u.isBlank())
.toList();
......
......@@ -98,6 +98,14 @@ public class ShotAssetServiceImpl implements ShotAssetService {
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) {
if (taskId == null) {
return;
......
......@@ -317,6 +317,7 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
if (task == null || !tenantId.equals(task.getTenantId())) {
throw new BizException(ErrorCode.NOT_FOUND, "任务不存在");
}
shotAssetService.deactivateBySourceTask(tenantId, taskId);
aiTaskMapper.deleteById(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