Commit 7a80307a authored by yaoke.yk's avatar yaoke.yk

项目封面手动上传

parent 38e3bbe5
import { useState } from "react";
import { useRef, useState, type ChangeEvent } from "react";
import { useParams, useNavigate } from "react-router";
import {
Film,
......@@ -16,7 +16,7 @@ import {
Plus,
X,
} from "lucide-react";
import { useProject } from "../../hooks/useProjects";
import { useProject, useUploadAsset } from "../../hooks/useProjects";
import {
useOutline,
useEpisodes,
......@@ -236,12 +236,33 @@ function ProjectScriptView({
];
const displayCharacters = characters.length > 0 ? characters.slice(0, 4) : fallbackCharacters;
const nextPath = episodes.length === 0 ? `/project/${projectId}/outline` : `/project/${projectId}/characters`;
const coverInputRef = useRef<HTMLInputElement | null>(null);
const uploadCover = useUploadAsset(projectId ?? "");
const coverActionLabel = project?.coverUrl ? "更换封面" : "上传封面";
const handleChooseCover = () => {
if (!projectId || uploadCover.isPending) return;
coverInputRef.current?.click();
};
const handleCoverFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file || !projectId) return;
uploadCover.mutate({ file, assetType: "cover" });
event.target.value = "";
};
return (
<div className="relative h-full overflow-auto bg-[#f7f8fb] text-[#111827]">
<main className="mx-auto max-w-[860px] px-6 pb-32 pt-12">
<section className="flex flex-col gap-8 sm:flex-row sm:items-start">
<div className="h-[218px] w-[154px] flex-shrink-0 overflow-hidden rounded-[3px] bg-[#dfe3ea] shadow-[0_18px_48px_rgba(15,23,42,0.14)]">
<button
type="button"
onClick={handleChooseCover}
disabled={!projectId || uploadCover.isPending}
title={coverActionLabel}
className="group relative h-[218px] w-[154px] flex-shrink-0 overflow-hidden rounded-[3px] bg-[#dfe3ea] text-left shadow-[0_18px_48px_rgba(15,23,42,0.14)] outline-none transition-all hover:-translate-y-0.5 hover:shadow-[0_24px_58px_rgba(15,23,42,0.18)] focus-visible:ring-2 focus-visible:ring-[#6d5cff] focus-visible:ring-offset-2 disabled:cursor-wait"
>
{project?.coverUrl ? (
<img src={project.coverUrl} alt="项目封面" className="h-full w-full object-cover" />
) : (
......@@ -252,7 +273,28 @@ function ProjectScriptView({
</span>
</div>
)}
</div>
<div
className={`pointer-events-none absolute inset-x-0 bottom-0 flex min-h-14 items-end justify-center bg-gradient-to-t from-black/72 via-black/30 to-transparent px-3 pb-3 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 ${
project?.coverUrl && !uploadCover.isPending ? "opacity-0" : "opacity-100"
}`}
>
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/92 px-3 py-1.5 text-xs font-semibold text-[#111827] shadow-sm">
{uploadCover.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ImageIcon className="h-3.5 w-3.5 text-[#6254ff]" />
)}
{uploadCover.isPending ? "上传中..." : coverActionLabel}
</span>
</div>
</button>
<input
ref={coverInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleCoverFileChange}
/>
<div className="min-w-0 flex-1 pt-1">
<h1 className="mb-4 text-[28px] font-semibold leading-[1.22] tracking-normal text-[#0b1220]">
......
......@@ -28,15 +28,21 @@ import {
import type { Storyboard, Character, CharacterState, Scene, Episode, AiTask } from "../../lib/api/ai";
import { useOperationCost } from "../../hooks/useUsage";
const DURATION_OPTIONS = [5, 10, 11, 15, 20, 30];
const MIN_VIDEO_DURATION = 5;
const MAX_VIDEO_DURATION = 15;
const DEFAULT_VIDEO_DURATION = 11;
const DURATION_OPTIONS = Array.from(
{ length: MAX_VIDEO_DURATION - MIN_VIDEO_DURATION + 1 },
(_, index) => MIN_VIDEO_DURATION + index
);
const RATIO_OPTIONS = [
{ value: "16:9", label: "16:9 横屏" },
{ value: "9:16", label: "9:16 竖屏" },
{ value: "1:1", label: "1:1 方形" },
];
const VIDEO_MODEL_OPTIONS = [
{ value: "doubao-seedance-2-0-fast-260128", label: "Doubao-Seedance-2-0" },
{ value: "doubao-seedance-2-0-260128", label: "Doubao-Seedance-2-0 Pro" },
{ value: "doubao-seedance-2-0-fast-260128", label: "Doubao-Seedance-2-0 fast" },
{ value: "doubao-seedance-2-0-260128", label: "Doubao-Seedance-2-0" },
];
const DEFAULT_VIDEO_MODEL = VIDEO_MODEL_OPTIONS[0].value;
const MAX_CHARACTERS = 5;
......@@ -82,6 +88,13 @@ const FOCUS_RING = "focus:outline-none focus:ring-2 focus:ring-[#1c64ff]/20 focu
const CONTROL_SURFACE = "border border-slate-200/80 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.04)]";
const SOFT_PANEL = "border border-slate-200/80 bg-white/95 shadow-[0_8px_24px_rgba(15,23,42,0.05)]";
function normalizeVideoDuration(value: number | null | undefined) {
if (typeof value !== "number" || Number.isNaN(value)) {
return DEFAULT_VIDEO_DURATION;
}
return Math.min(MAX_VIDEO_DURATION, Math.max(MIN_VIDEO_DURATION, Math.round(value)));
}
const FREEFORM_PLACEHOLDER = `【镜头1】 时长2s | 蒙太奇快切 | 固定机位快速切出
画面定格在角色刚转头的瞬间,背景虚化为暖色光斑
......@@ -300,7 +313,7 @@ export function StoryboardWorkspace() {
const [styleKey] = useState<string>(""); // reserved; not exposed in this UI
const [shortDescription, setShortDescription] = useState<string>("");
const [freeformPrompt, setFreeformPrompt] = useState<string>("");
const [selectedDuration, setSelectedDuration] = useState(11);
const [selectedDuration, setSelectedDuration] = useState(DEFAULT_VIDEO_DURATION);
const [selectedRatio, setSelectedRatio] = useState<string>("16:9");
const [selectedModel, setSelectedModel] = useState<string>(DEFAULT_VIDEO_MODEL);
const [audioOn, setAudioOn] = useState(true);
......@@ -399,7 +412,7 @@ export function StoryboardWorkspace() {
setSceneKey(nextSceneKey);
setPropKeys(draft.propKeys);
setFreeformPrompt(draft.freeformPrompt);
setSelectedDuration(draft.selectedDuration);
setSelectedDuration(normalizeVideoDuration(draft.selectedDuration));
setSelectedRatio(draft.selectedRatio);
setSelectedModel(draft.selectedModel);
setAudioOn(draft.audioOn);
......@@ -431,7 +444,9 @@ export function StoryboardWorkspace() {
}
setPropKeys(storyboardPropKeys.length > 0 ? storyboardPropKeys : (lastTask?.propImageKeys ?? []));
setFreeformPrompt(expanded.videoPrompt?.trim() || (lastTask?.userPrompt?.freeformPrompt ?? ""));
if (typeof lastTask?.videoDuration === "number") setSelectedDuration(lastTask.videoDuration);
if (typeof lastTask?.videoDuration === "number") {
setSelectedDuration(normalizeVideoDuration(lastTask.videoDuration));
}
if (lastTask?.videoRatio) setSelectedRatio(lastTask.videoRatio);
if (typeof lastTask?.generateAudio === "boolean") setAudioOn(lastTask.generateAudio);
}, [
......@@ -900,7 +915,7 @@ export function StoryboardWorkspace() {
<div className="relative">
<select
value={selectedDuration}
onChange={(e) => setSelectedDuration(Number(e.target.value))}
onChange={(e) => setSelectedDuration(normalizeVideoDuration(Number(e.target.value)))}
disabled={!expanded}
className={`appearance-none pl-2 pr-6 py-2 rounded-md bg-white text-xs text-[#111827] cursor-pointer w-16 disabled:opacity-50 transition-colors tabular-nums ${CONTROL_SURFACE} ${FOCUS_RING}`}
>
......
......@@ -11,7 +11,7 @@ import java.util.List;
/**
* 分镜视频结构化生成请求。
* 替代旧的「prompt 内夹 @图N + image_keys[] 顺序绑定」方式。
* 替代旧的“prompt 内嵌 @图N + image_keys[] 顺序绑定”方式。
*/
@Data
public class StructuredVideoGenerateRequest {
......@@ -25,21 +25,21 @@ public class StructuredVideoGenerateRequest {
/** @图1 角色图(旧版单角色字段,characterImageKeys 为空时使用) */
private String characterImageKey;
/** V16:多角色出镜(@图1..@图N,按顺序),与 characterImageKey 至少传一个 */
/** V16:多角色出镜,按 @图1..@图N 顺序,和 characterImageKey 至少传一个 */
@Size(max = 5, message = "出镜角色最多 5 个")
private List<String> characterImageKeys;
/** 场景图(可选)。角色/场景/道具至少传一种参考图,由 service 层校验。 */
private String sceneImageKey;
/** 用户结构化提示词(旧版 5 段, freeformPrompt 二选一,至少一项非空) */
/** 用户结构化提示词(旧版 5 段, freeformPrompt 二选一,至少一项非空) */
@Valid
private UserPrompt userPrompt;
/** V17:单段 freeform 提示词(前端简化为单输入框后的主要入口) */
private String freeformPrompt;
/** @图3+ 道具图(可选,按顺序对应 @图3、@图4...,最多 5 张) */
/** @图 + 道具图(可选,按顺序对应 @图N、@图...,最大 5 张) */
@Size(max = 5, message = "道具图最多 5 张")
private List<String> propImageKeys;
......@@ -48,7 +48,7 @@ public class StructuredVideoGenerateRequest {
/** 视频时长(秒) */
@Min(value = 5, message = "视频时长最短 5 秒")
@Max(value = 30, message = "视频时长最长 30 秒")
@Max(value = 15, message = "视频时长最长 15 秒")
private Integer duration = 15;
/** 16:9 / 9:16 / 1:1 */
......
......@@ -94,7 +94,7 @@ public class VideoTaskAsyncProcessor {
}
// 提交 Seedance,传入所有参考图 + 时长
int duration = durationSeconds > 0 ? durationSeconds : 5;
int duration = durationSeconds > 0 ? Math.min(15, Math.max(5, durationSeconds)) : 5;
String externalTaskId = seedanceService.submitVideoTask(presignedUrls, videoPrompt, duration, generateAudio, ratio, model);
log.info("Seedance task submitted: taskId={}, externalId={}, images={}, duration={}s",
taskId, externalTaskId, presignedUrls.size(), duration);
......
......@@ -267,8 +267,7 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
task.getId(), characterKeys.size(), orderedKeys.size(), s.getDurationSeconds(), s.getRatio());
// 3. 复用 async processor:将拼接好的 prompt + 有序 key 列表交给后台
int duration = s.getDurationSeconds() != null && s.getDurationSeconds() > 0
? s.getDurationSeconds() : 15;
int duration = durationOrDefault(s.getDurationSeconds());
String ratio = s.getRatio() != null && !s.getRatio().isBlank() ? s.getRatio() : "16:9";
boolean generateAudio = Boolean.TRUE.equals(s.getGenerateAudio());
asyncProcessor.processTextToVideo(task.getId(), tenantId, s.getUserId(), projectId,
......@@ -297,7 +296,10 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
}
private Integer durationOrDefault(Integer durationSeconds) {
return durationSeconds != null && durationSeconds > 0 ? durationSeconds : 5;
if (durationSeconds == null || durationSeconds <= 0) {
return 5;
}
return Math.min(15, Math.max(5, durationSeconds));
}
private static String toJson(Object value) {
......
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