Commit 787ae772 authored by yaoke.yk's avatar yaoke.yk

客户端加入首未帧和道具修改

parent 232de8ef
import { useState, useEffect, useMemo, useRef } from "react";
import { useState, useEffect, useMemo, useRef, type ChangeEvent } from "react";
import { useParams, useNavigate } from "react-router";
import {
Plus, Wand2, Play, Trash2,
......@@ -16,6 +16,7 @@ import {
useStartAssembly,
useGenerateStructuredVideo,
useUpdateStoryboard,
useUploadStoryboardFrameImage,
useVideoTasks,
useCharacters,
useAllCharacterStates,
......@@ -61,6 +62,10 @@ interface SbDraft {
characterKeys: string[];
sceneKey: string;
propKeys: string[];
firstFrameImageKey: string;
firstFrameImageUrl: string;
lastFrameImageKey: string;
lastFrameImageUrl: string;
freeformPrompt: string;
selectedDuration: number;
selectedRatio: string;
......@@ -301,6 +306,7 @@ export function StoryboardWorkspace() {
const createSb = useCreateStoryboard(pid, eid);
const deleteSb = useDeleteStoryboard(pid, eid);
const updateSb = useUpdateStoryboard(pid, eid);
const uploadFrameImage = useUploadStoryboardFrameImage(pid, eid);
const generateStructured = useGenerateStructuredVideo(pid);
const startAssembly = useStartAssembly(pid, eid);
const videoCredits = useOperationCost("video_generate");
......@@ -310,6 +316,10 @@ export function StoryboardWorkspace() {
const [characterKeys, setCharacterKeys] = useState<string[]>([]);
const [sceneKey, setSceneKey] = useState<string>("");
const [propKeys, setPropKeys] = useState<string[]>([]);
const [firstFrameImageKey, setFirstFrameImageKey] = useState<string>("");
const [firstFrameImageUrl, setFirstFrameImageUrl] = useState<string>("");
const [lastFrameImageKey, setLastFrameImageKey] = useState<string>("");
const [lastFrameImageUrl, setLastFrameImageUrl] = useState<string>("");
const [styleKey] = useState<string>(""); // reserved; not exposed in this UI
const [shortDescription, setShortDescription] = useState<string>("");
const [freeformPrompt, setFreeformPrompt] = useState<string>("");
......@@ -378,6 +388,10 @@ export function StoryboardWorkspace() {
setCharacterKeys([]);
setSceneKey("");
setPropKeys([]);
setFirstFrameImageKey("");
setFirstFrameImageUrl("");
setLastFrameImageKey("");
setLastFrameImageUrl("");
setShortDescription("");
setActiveVideoSbId(null);
lastSyncedExpandedIdRef.current = null;
......@@ -411,6 +425,10 @@ export function StoryboardWorkspace() {
setCharacterKeys(draft.characterKeys);
setSceneKey(nextSceneKey);
setPropKeys(draft.propKeys);
setFirstFrameImageKey(draft.firstFrameImageKey || expanded.firstFrameImageKey || "");
setFirstFrameImageUrl(draft.firstFrameImageUrl || expanded.firstFrameImageUrl || "");
setLastFrameImageKey(draft.lastFrameImageKey || expanded.lastFrameImageKey || "");
setLastFrameImageUrl(draft.lastFrameImageUrl || expanded.lastFrameImageUrl || "");
setFreeformPrompt(draft.freeformPrompt);
setSelectedDuration(normalizeVideoDuration(draft.selectedDuration));
setSelectedRatio(draft.selectedRatio);
......@@ -443,6 +461,10 @@ export function StoryboardWorkspace() {
updateSb.mutate({ id: expanded.id, patch: { sceneImageKey: nextSceneKey } });
}
setPropKeys(storyboardPropKeys.length > 0 ? storyboardPropKeys : (lastTask?.propImageKeys ?? []));
setFirstFrameImageKey(expanded.firstFrameImageKey ?? "");
setFirstFrameImageUrl(expanded.firstFrameImageUrl ?? "");
setLastFrameImageKey(expanded.lastFrameImageKey ?? "");
setLastFrameImageUrl(expanded.lastFrameImageUrl ?? "");
setFreeformPrompt(expanded.videoPrompt?.trim() || (lastTask?.userPrompt?.freeformPrompt ?? ""));
if (typeof lastTask?.videoDuration === "number") {
setSelectedDuration(normalizeVideoDuration(lastTask.videoDuration));
......@@ -467,6 +489,10 @@ export function StoryboardWorkspace() {
characterKeys,
sceneKey,
propKeys,
firstFrameImageKey,
firstFrameImageUrl,
lastFrameImageKey,
lastFrameImageUrl,
freeformPrompt,
selectedDuration,
selectedRatio,
......@@ -475,7 +501,9 @@ export function StoryboardWorkspace() {
});
}, [
expandedId, pid, eid,
shortDescription, characterKeys, sceneKey, propKeys, freeformPrompt,
shortDescription, characterKeys, sceneKey, propKeys,
firstFrameImageKey, firstFrameImageUrl, lastFrameImageKey, lastFrameImageUrl,
freeformPrompt,
selectedDuration, selectedRatio, selectedModel, audioOn,
]);
......@@ -496,6 +524,10 @@ export function StoryboardWorkspace() {
characterImageKeys: characterKeys.length > 0 ? JSON.stringify(characterKeys) : "",
sceneImageKey: sceneKey,
propImageKeys: propKeys.length > 0 ? JSON.stringify(propKeys) : "",
firstFrameImageKey,
firstFrameImageUrl,
lastFrameImageKey,
lastFrameImageUrl,
videoPrompt: freeformPrompt,
durationSeconds: selectedDuration,
},
......@@ -508,7 +540,9 @@ export function StoryboardWorkspace() {
};
}, [
expandedId, pid, eid,
shortDescription, characterKeys, sceneKey, propKeys, freeformPrompt, selectedDuration,
shortDescription, characterKeys, sceneKey, propKeys,
firstFrameImageKey, firstFrameImageUrl, lastFrameImageKey, lastFrameImageUrl,
freeformPrompt, selectedDuration,
]);
const handleEpisodeSelect = (ep: Episode) => {
......@@ -563,6 +597,28 @@ export function StoryboardWorkspace() {
});
};
const handleFrameUpload = async (frameType: "first" | "last", file: File) => {
if (!expanded) return;
const updated = await uploadFrameImage.mutateAsync({ id: expanded.id, frameType, file });
if (frameType === "first") {
setFirstFrameImageKey(updated.firstFrameImageKey ?? "");
setFirstFrameImageUrl(updated.firstFrameImageUrl ?? "");
} else {
setLastFrameImageKey(updated.lastFrameImageKey ?? "");
setLastFrameImageUrl(updated.lastFrameImageUrl ?? "");
}
};
const handleFrameClear = (frameType: "first" | "last") => {
if (frameType === "first") {
setFirstFrameImageKey("");
setFirstFrameImageUrl("");
return;
}
setLastFrameImageKey("");
setLastFrameImageUrl("");
};
const removeCharacter = (key: string) => {
setCharacterKeys((prev) => prev.filter((k) => k !== key));
};
......@@ -578,11 +634,12 @@ export function StoryboardWorkspace() {
};
const promptHasContent = freeformPrompt.trim().length > 0;
// 至少一张参考图(角色/场景/道具任一)+ 提示词非空
const hasAnyReferenceImage = characterKeys.length > 0 || !!sceneKey || propKeys.length > 0;
const frameReferenceCount = (firstFrameImageKey ? 1 : 0) + (lastFrameImageKey ? 1 : 0);
// 至少一张参考图(角色/场景/道具/首尾帧任一)+ 提示词非空
const hasAnyReferenceImage = characterKeys.length > 0 || !!sceneKey || propKeys.length > 0 || frameReferenceCount > 0;
const canGenerate = !!expanded && hasAnyReferenceImage
&& promptHasContent && !generateStructured.isPending;
const referenceCount = characterKeys.length + (sceneKey ? 1 : 0) + propKeys.length;
const referenceCount = characterKeys.length + (sceneKey ? 1 : 0) + propKeys.length + frameReferenceCount;
const handleGenerate = async () => {
if (!expanded || !canGenerate) return;
......@@ -595,6 +652,10 @@ export function StoryboardWorkspace() {
characterImageKeys: JSON.stringify(characterKeys),
sceneImageKey: sceneKey,
propImageKeys: propKeys.length > 0 ? JSON.stringify(propKeys) : "",
firstFrameImageKey,
firstFrameImageUrl,
lastFrameImageKey,
lastFrameImageUrl,
videoPrompt: freeformPrompt.trim(),
durationSeconds: selectedDuration,
},
......@@ -605,6 +666,8 @@ export function StoryboardWorkspace() {
characterImageKeys: characterKeys,
sceneImageKey: sceneKey,
propImageKeys: propKeys.length > 0 ? propKeys : null,
firstFrameImageKey: firstFrameImageKey || null,
lastFrameImageKey: lastFrameImageKey || null,
styleImageKey: styleKey || null,
freeformPrompt: freeformPrompt.trim(),
duration: selectedDuration,
......@@ -837,6 +900,13 @@ export function StoryboardWorkspace() {
onSelectScene={setSceneKey}
propKeys={expandedId === sb.id ? propKeys : []}
onTogglePropKey={togglePropKey}
firstFrameImageKey={expandedId === sb.id ? firstFrameImageKey : sb.firstFrameImageKey ?? ""}
firstFrameImageUrl={expandedId === sb.id ? firstFrameImageUrl : sb.firstFrameImageUrl ?? ""}
lastFrameImageKey={expandedId === sb.id ? lastFrameImageKey : sb.lastFrameImageKey ?? ""}
lastFrameImageUrl={expandedId === sb.id ? lastFrameImageUrl : sb.lastFrameImageUrl ?? ""}
onUploadFrame={handleFrameUpload}
onClearFrame={handleFrameClear}
frameUploading={uploadFrameImage.isPending}
scenes={scenes}
sceneByKey={sceneByKey}
/>
......@@ -942,7 +1012,7 @@ export function StoryboardWorkspace() {
<button
onClick={handleGenerate}
disabled={!canGenerate || generatingVideoId === expanded?.id}
title={!canGenerate ? "需要:至少 1 张参考图(角色/场景/道具任一)+ 提示词" : ""}
title={!canGenerate ? "需要:至少 1 张参考图(角色/场景/道具/首尾帧任一)+ 提示词" : ""}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-md bg-[#7657ff] text-white text-xs font-semibold tracking-wide shadow-[0_9px_20px_rgba(118,87,255,0.24)] transition-all duration-150 hover:bg-[#6747f4] hover:-translate-y-px disabled:opacity-50 disabled:hover:translate-y-0 disabled:hover:shadow-none whitespace-nowrap"
>
{generatingVideoId === expanded?.id ? (
......@@ -1205,6 +1275,13 @@ interface StoryboardCardProps {
onSelectScene: (key: string) => void;
propKeys: string[];
onTogglePropKey: (key: string) => void;
firstFrameImageKey: string;
firstFrameImageUrl: string;
lastFrameImageKey: string;
lastFrameImageUrl: string;
onUploadFrame: (frameType: "first" | "last", file: File) => void | Promise<void>;
onClearFrame: (frameType: "first" | "last") => void;
frameUploading: boolean;
scenes: Scene[];
sceneByKey: Map<string, Scene>;
}
......@@ -1278,6 +1355,17 @@ function StoryboardCard(props: StoryboardCardProps) {
onToggle={props.onTogglePropKey}
/>
{/* 首尾帧图 */}
<FrameReferenceSection
firstFrameImageKey={props.firstFrameImageKey}
firstFrameImageUrl={props.firstFrameImageUrl}
lastFrameImageKey={props.lastFrameImageKey}
lastFrameImageUrl={props.lastFrameImageUrl}
onUpload={props.onUploadFrame}
onClear={props.onClearFrame}
uploading={props.frameUploading}
/>
{/* 删除按钮 */}
<div className="pt-1 flex justify-end">
<button
......@@ -1658,6 +1746,133 @@ function PropGrid({ scenes, sceneKey, selectedKeys, onToggle }: PropGridProps) {
}
// ────────────────────────────────────────────────────────────────────────────
// FrameReferenceSection — first/last frame images used as extra video references
// ────────────────────────────────────────────────────────────────────────────
interface FrameReferenceSectionProps {
firstFrameImageKey: string;
firstFrameImageUrl: string;
lastFrameImageKey: string;
lastFrameImageUrl: string;
onUpload: (frameType: "first" | "last", file: File) => void | Promise<void>;
onClear: (frameType: "first" | "last") => void;
uploading: boolean;
}
function FrameReferenceSection({
firstFrameImageKey,
firstFrameImageUrl,
lastFrameImageKey,
lastFrameImageUrl,
onUpload,
onClear,
uploading,
}: FrameReferenceSectionProps) {
return (
<div className="rounded-md bg-white p-0">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-foreground">首尾帧图</span>
<span className="text-[10px] text-muted-foreground">作为视频参考图</span>
</div>
<div className="grid grid-cols-2 gap-2">
<FrameReferenceTile
label="上传首帧"
imageKey={firstFrameImageKey}
imageUrl={firstFrameImageUrl}
frameType="first"
uploading={uploading}
onUpload={onUpload}
onClear={onClear}
/>
<FrameReferenceTile
label="上传尾帧"
imageKey={lastFrameImageKey}
imageUrl={lastFrameImageUrl}
frameType="last"
uploading={uploading}
onUpload={onUpload}
onClear={onClear}
/>
</div>
<p className="mt-2 rounded-md border border-[#dbeafe] bg-[#eff6ff] px-2 py-1 text-[10px] leading-4 text-[#1d4ed8]">
关键帧会按首帧、尾帧顺序参与视频生成,可随时替换。
</p>
</div>
);
}
interface FrameReferenceTileProps {
label: string;
imageKey: string;
imageUrl: string;
frameType: "first" | "last";
uploading: boolean;
onUpload: (frameType: "first" | "last", file: File) => void | Promise<void>;
onClear: (frameType: "first" | "last") => void;
}
function FrameReferenceTile({
label,
imageKey,
imageUrl,
frameType,
uploading,
onUpload,
onClear,
}: FrameReferenceTileProps) {
const inputRef = useRef<HTMLInputElement>(null);
const handleSelect = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
await onUpload(frameType, file);
};
return (
<div className="relative rounded-md border border-[#dfe3ee] bg-white p-1.5">
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="relative block w-full aspect-[4/3] overflow-hidden rounded-md border border-[#e5e7ef] bg-slate-100 disabled:opacity-60"
title={imageKey ? "点击替换图片" : label}
>
{imageUrl ? (
<img src={imageUrl} alt={label} className="h-full w-full object-cover" />
) : (
<div className="flex h-full w-full flex-col items-center justify-center gap-1 text-muted-foreground">
{uploading ? <Loader2 className="h-5 w-5 animate-spin" /> : <ImageIcon className="h-5 w-5" />}
<span className="text-[11px] font-medium">{label}</span>
</div>
)}
</button>
<div className="mt-1 flex items-center justify-between gap-1">
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={uploading}
className="min-w-0 flex-1 truncate rounded-md px-1.5 py-1 text-left text-[10px] font-medium text-[#344054] hover:bg-[#f6f9ff] disabled:opacity-50"
>
{imageKey ? (frameType === "first" ? "首帧图" : "尾帧图") : label}
</button>
{imageKey && (
<button
type="button"
onClick={() => onClear(frameType)}
disabled={uploading}
className="rounded-md p-1 text-muted-foreground hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
title="清除"
>
<Trash2 className="h-3 w-3" />
</button>
)}
</div>
<input ref={inputRef} type="file" accept="image/*" className="hidden" onChange={handleSelect} />
</div>
);
}
// ────────────────────────────────────────────────────────────────────────────
// CharacterPickerModal — pick base characters or generated character states
// ────────────────────────────────────────────────────────────────────────────
interface CharacterPickerModalProps {
......
......@@ -340,6 +340,15 @@ export function useUpdateStoryboard(projectId: string, episodeId: string) {
});
}
export function useUploadStoryboardFrameImage(projectId: string, episodeId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, frameType, file }: { id: string; frameType: "first" | "last"; file: File }) =>
aiApi.uploadStoryboardFrameImage(projectId, id, frameType, file),
onSuccess: () => qc.invalidateQueries({ queryKey: storyboardsKey(projectId, episodeId) }),
});
}
export function useDeleteStoryboard(projectId: string, episodeId: string) {
const qc = useQueryClient();
return useMutation({
......
......@@ -47,6 +47,10 @@ export interface Storyboard {
sceneImageKey?: string | null;
propImageKeys?: string[] | null;
styleImageKey?: string | null;
firstFrameImageKey?: string | null;
firstFrameImageUrl?: string | null;
lastFrameImageKey?: string | null;
lastFrameImageUrl?: string | null;
}
export interface ShotAsset {
......@@ -157,6 +161,8 @@ export interface StructuredVideoRequest {
sceneImageKey?: string | null;
propImageKeys?: string[] | null;
styleImageKey?: string | null;
firstFrameImageKey?: string | null;
lastFrameImageKey?: string | null;
/** V17:单段 freeform 提示词(与 userPrompt 二选一,优先使用) */
freeformPrompt?: string | null;
userPrompt?: {
......@@ -234,6 +240,21 @@ export const aiApi = {
const r = await apiClient.put(`/projects/${projectId}/storyboards/${storyboardId}`, patch);
return r.data.data;
},
uploadStoryboardFrameImage: async (
projectId: string,
storyboardId: string,
frameType: "first" | "last",
file: File
): Promise<Storyboard> => {
const form = new FormData();
form.append("file", file);
const r = await apiClient.post(
`/projects/${projectId}/storyboards/${storyboardId}/frame-image?frameType=${frameType}`,
form,
{ headers: { "Content-Type": "multipart/form-data" }, timeout: 600_000 }
);
return r.data.data;
},
deleteStoryboard: async (projectId: string, storyboardId: string): Promise<void> => {
await apiClient.delete(`/projects/${projectId}/storyboards/${storyboardId}`);
},
......
......@@ -8,7 +8,7 @@ import java.util.List;
*
* 图片顺序固定(即 @图N 标号):
* @图1..@图K = 角色(K = 角色数量),@图K+1 = 场景,
* @图K+2.. = 道具(按顺序),最后一张 = 风格图(如有)
* @图K+2.. = 道具(按顺序),之后是首/尾帧参考图,最后一张 = 风格图(如有)
*/
public final class SeedancePromptBuilder {
......@@ -24,8 +24,14 @@ public final class SeedancePromptBuilder {
/** V17:单段 freeform 提示词模式(前端单输入框后的主要入口)。 */
public static String buildPromptFreeform(String freeformBody, int characterCount,
boolean hasSceneImage, int propCount, boolean hasStyleImage) {
return buildPromptFreeform(freeformBody, characterCount, hasSceneImage, propCount, 0, hasStyleImage);
}
public static String buildPromptFreeform(String freeformBody, int characterCount,
boolean hasSceneImage, int propCount, int frameCount,
boolean hasStyleImage) {
StringBuilder sb = new StringBuilder();
appendImageHeader(sb, characterCount, hasSceneImage, propCount, hasStyleImage);
appendImageHeader(sb, characterCount, hasSceneImage, propCount, frameCount, hasStyleImage);
sb.append("\n分镜内容:\n");
if (freeformBody != null && !freeformBody.isBlank()) {
sb.append(freeformBody.trim()).append('\n');
......@@ -36,8 +42,14 @@ public final class SeedancePromptBuilder {
public static String buildPrompt(UserPromptParts parts, int characterCount,
boolean hasSceneImage, int propCount, boolean hasStyleImage) {
return buildPrompt(parts, characterCount, hasSceneImage, propCount, 0, hasStyleImage);
}
public static String buildPrompt(UserPromptParts parts, int characterCount,
boolean hasSceneImage, int propCount, int frameCount,
boolean hasStyleImage) {
StringBuilder sb = new StringBuilder();
appendImageHeader(sb, characterCount, hasSceneImage, propCount, hasStyleImage);
appendImageHeader(sb, characterCount, hasSceneImage, propCount, frameCount, hasStyleImage);
sb.append("\n分镜内容:\n");
appendIfPresent(sb, "人物动作", parts.characterAction());
......@@ -56,6 +68,12 @@ public final class SeedancePromptBuilder {
*/
private static void appendImageHeader(StringBuilder sb, int characterCount,
boolean hasSceneImage, int propCount, boolean hasStyleImage) {
appendImageHeader(sb, characterCount, hasSceneImage, propCount, 0, hasStyleImage);
}
private static void appendImageHeader(StringBuilder sb, int characterCount,
boolean hasSceneImage, int propCount, int frameCount,
boolean hasStyleImage) {
int idx = 0;
if (characterCount == 1) {
sb.append("@图").append(idx + 1)
......@@ -83,6 +101,16 @@ public final class SeedancePromptBuilder {
}
idx += propCount;
}
if (frameCount > 0) {
if (frameCount == 1) {
sb.append("@图").append(idx + 1)
.append(" 是分镜首帧或尾帧参考图,请作为画面起止状态参考,不要生硬复制成静态画面。\n");
} else {
sb.append("@图").append(idx + 1).append(" 是首帧参考图,@图").append(idx + 2)
.append(" 是尾帧参考图,请参考起始和结束画面状态,让视频动作自然衔接。\n");
}
idx += frameCount;
}
if (hasStyleImage) {
sb.append("@图").append(idx + 1)
.append(" 是风格参考图,请参考整体美术风格、色彩和质感。\n");
......@@ -111,6 +139,14 @@ public final class SeedancePromptBuilder {
String sceneImageKey,
List<String> propImageKeys,
String styleImageKey) {
return buildOrderedImageKeys(characterImageKeys, sceneImageKey, propImageKeys, null, styleImageKey);
}
public static List<String> buildOrderedImageKeys(List<String> characterImageKeys,
String sceneImageKey,
List<String> propImageKeys,
List<String> frameImageKeys,
String styleImageKey) {
List<String> keys = new ArrayList<>();
if (characterImageKeys != null) {
for (String k : characterImageKeys) {
......@@ -129,6 +165,13 @@ public final class SeedancePromptBuilder {
}
}
}
if (frameImageKeys != null) {
for (String k : frameImageKeys) {
if (k != null && !k.isBlank()) {
keys.add(k);
}
}
}
if (styleImageKey != null && !styleImageKey.isBlank()) {
keys.add(styleImageKey);
}
......
......@@ -2,16 +2,23 @@ package com.yaoai.api.controller;
import com.yaoai.api.dto.ai.StoryboardDTO;
import com.yaoai.api.dto.ai.ShotAssetDTO;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import com.yaoai.common.response.ApiResponse;
import com.yaoai.domain.entity.Storyboard;
import com.yaoai.domain.mapper.StoryboardMapper;
import com.yaoai.pipeline.service.ShotAssetService;
import com.yaoai.pipeline.service.StoryboardPipelineService;
import com.yaoai.security.context.TenantContext;
import com.yaoai.storage.service.TosService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
......@@ -24,6 +31,8 @@ public class StoryboardController {
private final StoryboardPipelineService storyboardService;
private final ShotAssetService shotAssetService;
private final StoryboardMapper storyboardMapper;
private final TosService tosService;
@Operation(summary = "AI 批量生成分集分镜")
@PostMapping("/episodes/{episodeId}/storyboards/generate")
......@@ -79,6 +88,47 @@ public class StoryboardController {
return ApiResponse.success(StoryboardDTO.from(storyboardService.update(storyboardId, tenantId, req)));
}
@Operation(summary = "上传分镜首尾帧参考图")
@PostMapping(value = "/storyboards/{storyboardId}/frame-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ApiResponse<StoryboardDTO> uploadFrameImage(@PathVariable Long projectId,
@PathVariable Long storyboardId,
@RequestParam("frameType") String frameType,
@RequestPart("file") MultipartFile file) throws IOException {
Long tenantId = TenantContext.get();
Storyboard storyboard = storyboardMapper.selectById(storyboardId);
if (storyboard == null
|| !tenantId.equals(storyboard.getTenantId())
|| !projectId.equals(storyboard.getProjectId())) {
throw new BizException(ErrorCode.NOT_FOUND, "分镜不存在");
}
if (!"first".equals(frameType) && !"last".equals(frameType)) {
throw new BizException(ErrorCode.INVALID_PARAM, "frameType 仅支持 first / last");
}
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.INVALID_PARAM, "图片文件不能为空");
}
String contentType = file.getContentType() != null ? file.getContentType() : "application/octet-stream";
if (!contentType.startsWith("image/")) {
throw new BizException(ErrorCode.INVALID_PARAM, "请上传图片文件");
}
String key = TosService.buildKey(tenantId, projectId, "storyboard-frames/" + frameType, file.getOriginalFilename());
tosService.upload(key, file.getInputStream(), file.getSize(), contentType);
String url = tosService.publicUrl(key);
Storyboard patch = new Storyboard();
patch.setId(storyboardId);
if ("first".equals(frameType)) {
patch.setFirstFrameImageKey(key);
patch.setFirstFrameImageUrl(url);
} else {
patch.setLastFrameImageKey(key);
patch.setLastFrameImageUrl(url);
}
storyboardMapper.updateById(patch);
return ApiResponse.success(StoryboardDTO.from(storyboardMapper.selectById(storyboardId)));
}
@Operation(summary = "删除分镜")
@DeleteMapping("/storyboards/{storyboardId}")
public ApiResponse<Void> delete(@PathVariable Long projectId,
......
......@@ -46,8 +46,10 @@ public class VideoTaskController {
|| (req.getCharacterImageKey() != null && !req.getCharacterImageKey().isBlank());
boolean hasScene = req.getSceneImageKey() != null && !req.getSceneImageKey().isBlank();
boolean hasProp = req.getPropImageKeys() != null && !req.getPropImageKeys().isEmpty();
if (!hasCharacter && !hasScene && !hasProp) {
throw new BizException(ErrorCode.INVALID_PARAM, "请至少选择一张参考图(角色/场景/道具任一)");
boolean hasFrame = (req.getFirstFrameImageKey() != null && !req.getFirstFrameImageKey().isBlank())
|| (req.getLastFrameImageKey() != null && !req.getLastFrameImageKey().isBlank());
if (!hasCharacter && !hasScene && !hasProp && !hasFrame) {
throw new BizException(ErrorCode.INVALID_PARAM, "请至少选择一张参考图(角色/场景/道具/首尾帧任一)");
}
Long tenantId = TenantContext.get();
......@@ -60,6 +62,8 @@ public class VideoTaskController {
.sceneImageKey(req.getSceneImageKey())
.propImageKeys(req.getPropImageKeys())
.styleImageKey(req.getStyleImageKey())
.firstFrameImageKey(req.getFirstFrameImageKey())
.lastFrameImageKey(req.getLastFrameImageKey())
.characterAction(up != null ? up.getCharacterAction() : null)
.sceneEvent(up != null ? up.getSceneEvent() : null)
.cameraMovement(up != null ? up.getCameraMovement() : null)
......
......@@ -41,6 +41,10 @@ public class StoryboardDTO {
private String sceneImageKey;
private List<String> propImageKeys;
private String styleImageKey;
private String firstFrameImageKey;
private String firstFrameImageUrl;
private String lastFrameImageKey;
private String lastFrameImageUrl;
public static StoryboardDTO from(Storyboard s) {
StoryboardDTO dto = new StoryboardDTO();
......@@ -68,6 +72,10 @@ public class StoryboardDTO {
dto.setSceneImageKey(s.getSceneImageKey());
dto.setPropImageKeys(parseList(s.getPropImageKeys(), "propImageKeys", s.getId()));
dto.setStyleImageKey(s.getStyleImageKey());
dto.setFirstFrameImageKey(s.getFirstFrameImageKey());
dto.setFirstFrameImageUrl(s.getFirstFrameImageUrl());
dto.setLastFrameImageKey(s.getLastFrameImageKey());
dto.setLastFrameImageUrl(s.getLastFrameImageUrl());
return dto;
}
......
......@@ -46,6 +46,12 @@ public class StructuredVideoGenerateRequest {
/** 风格图(可选,固定为最后一张) */
private String styleImageKey;
/** 首帧参考图(可选,作为视频生成参考图参与,不强制模型首帧锁定) */
private String firstFrameImageKey;
/** 尾帧参考图(可选,作为视频生成参考图参与,不强制模型尾帧锁定) */
private String lastFrameImageKey;
/** 视频时长(秒) */
@Min(value = 5, message = "视频时长最短 5 秒")
@Max(value = 15, message = "视频时长最长 15 秒")
......
-- V21 分镜首尾帧参考图
ALTER TABLE storyboards
ADD COLUMN first_frame_image_key VARCHAR(512) DEFAULT NULL
COMMENT '该分镜首帧参考图 TOS key',
ADD COLUMN first_frame_image_url VARCHAR(1024) DEFAULT NULL
COMMENT '该分镜首帧参考图公开 URL',
ADD COLUMN last_frame_image_key VARCHAR(512) DEFAULT NULL
COMMENT '该分镜尾帧参考图 TOS key',
ADD COLUMN last_frame_image_url VARCHAR(1024) DEFAULT NULL
COMMENT '该分镜尾帧参考图公开 URL';
......@@ -43,6 +43,10 @@ public class Storyboard {
private String sceneImageKey;
private String propImageKeys;
private String styleImageKey;
private String firstFrameImageKey;
private String firstFrameImageUrl;
private String lastFrameImageKey;
private String lastFrameImageUrl;
/** draft / review / approved / generating / generated / failed */
private String status;
......
......@@ -29,6 +29,10 @@ public class StructuredVideoSubmission {
private List<String> propImageKeys;
/** 风格图 TOS key(可空,永远是最后一张) */
private String styleImageKey;
/** 首帧参考图 TOS key(可空,作为额外参考图参与) */
private String firstFrameImageKey;
/** 尾帧参考图 TOS key(可空,作为额外参考图参与) */
private String lastFrameImageKey;
/** 用户原始结构化输入(5 段或单段 freeform),用于落库存证 */
private Map<String, String> userPromptRaw;
......
......@@ -23,6 +23,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
......@@ -188,18 +189,20 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
}
boolean hasScene = s.getSceneImageKey() != null && !s.getSceneImageKey().isBlank();
int propCount = s.getPropImageKeys() != null ? s.getPropImageKeys().size() : 0;
List<String> frameImageKeys = frameImageKeys(s);
int frameCount = frameImageKeys.size();
boolean hasStyle = s.getStyleImageKey() != null && !s.getStyleImageKey().isBlank();
// 至少要有一张参考图(角色/场景/道具任一),否则前端不应允许提交
if (characterKeys.isEmpty() && !hasScene && propCount == 0) {
throw new BizException(ErrorCode.INVALID_PARAM, "至少需要一张参考图(角色/场景/道具任一)");
// 至少要有一张参考图(角色/场景/道具/首尾帧任一),否则前端不应允许提交
if (characterKeys.isEmpty() && !hasScene && propCount == 0 && frameCount == 0) {
throw new BizException(ErrorCode.INVALID_PARAM, "至少需要一张参考图(角色/场景/道具/首尾帧任一)");
}
// 1. 拼装 final prompt + 图片顺序
String finalPrompt;
if (s.getFreeformPrompt() != null && !s.getFreeformPrompt().isBlank()) {
finalPrompt = SeedancePromptBuilder.buildPromptFreeform(
s.getFreeformPrompt(), characterKeys.size(), hasScene, propCount, hasStyle);
s.getFreeformPrompt(), characterKeys.size(), hasScene, propCount, frameCount, hasStyle);
} else {
finalPrompt = SeedancePromptBuilder.buildPrompt(
new SeedancePromptBuilder.UserPromptParts(
......@@ -212,6 +215,7 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
characterKeys.size(),
hasScene,
propCount,
frameCount,
hasStyle
);
}
......@@ -219,6 +223,7 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
characterKeys,
s.getSceneImageKey(),
s.getPropImageKeys(),
frameImageKeys,
s.getStyleImageKey()
);
......@@ -260,7 +265,8 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
java.util.Map.of(
"durationSeconds", durationOrDefault(s.getDurationSeconds()),
"ratio", s.getRatio() != null ? s.getRatio() : "16:9",
"generateAudio", Boolean.TRUE.equals(s.getGenerateAudio())
"generateAudio", Boolean.TRUE.equals(s.getGenerateAudio()),
"frameImageKeys", frameImageKeys
)
);
log.info("Structured video task created: id={}, characters={}, images={}, duration={}s, ratio={}",
......@@ -287,6 +293,8 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
patch.setSceneImageKey(s.getSceneImageKey());
patch.setPropImageKeys(toJson(s.getPropImageKeys()));
patch.setStyleImageKey(s.getStyleImageKey());
patch.setFirstFrameImageKey(s.getFirstFrameImageKey());
patch.setLastFrameImageKey(s.getLastFrameImageKey());
patch.setDurationSeconds(durationOrDefault(s.getDurationSeconds()));
String editablePrompt = s.getFreeformPrompt() != null && !s.getFreeformPrompt().isBlank()
? s.getFreeformPrompt()
......@@ -295,6 +303,17 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
storyboardMapper.updateById(patch);
}
private List<String> frameImageKeys(StructuredVideoSubmission s) {
List<String> keys = new ArrayList<>(2);
if (s.getFirstFrameImageKey() != null && !s.getFirstFrameImageKey().isBlank()) {
keys.add(s.getFirstFrameImageKey());
}
if (s.getLastFrameImageKey() != null && !s.getLastFrameImageKey().isBlank()) {
keys.add(s.getLastFrameImageKey());
}
return keys;
}
private Integer durationOrDefault(Integer durationSeconds) {
if (durationSeconds == null || durationSeconds <= 0) {
return 5;
......
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