fix: make canvas the root generation experience
This commit is contained in:
104
web/app/page.tsx
104
web/app/page.tsx
@@ -8,7 +8,6 @@ import {
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
Plus,
|
||||
Sparkles,
|
||||
Upload,
|
||||
X,
|
||||
type LucideIcon,
|
||||
@@ -24,7 +23,6 @@ import {
|
||||
generateStoryboardVideo,
|
||||
getRuntimeHealth,
|
||||
getJob,
|
||||
uploadReferenceFrame,
|
||||
type GeneratedImage,
|
||||
type GeneratedVideo,
|
||||
type Job,
|
||||
@@ -32,26 +30,19 @@ import {
|
||||
type RuntimeSizeOption,
|
||||
} from "@/lib/api"
|
||||
|
||||
type CreationMode = "text-video" | "text-image" | "first-frame-video" | "first-last-frame-video"
|
||||
type CreationMode = "text-image" | "text-video" | "image-video"
|
||||
type BusyTask = CreationMode | "job" | null
|
||||
type UploadSlot = "first" | "last"
|
||||
type UploadSlot = "first"
|
||||
|
||||
type ModeConfig = {
|
||||
id: CreationMode
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
placeholder: string
|
||||
needsFirstFrame?: boolean
|
||||
needsLastFrame?: boolean
|
||||
needsImage?: boolean
|
||||
}
|
||||
|
||||
const MODES: ModeConfig[] = [
|
||||
{
|
||||
id: "text-video",
|
||||
label: "文生视频",
|
||||
icon: Clapperboard,
|
||||
placeholder: "写清楚画面、人物动作、产品出现方式、镜头运动和风格。例如:15 秒竖屏,办公室午休,人物戴上 SKG 颈部按摩仪放松,镜头缓慢推进。",
|
||||
},
|
||||
{
|
||||
id: "text-image",
|
||||
label: "文生图",
|
||||
@@ -59,19 +50,17 @@ const MODES: ModeConfig[] = [
|
||||
placeholder: "写清楚画面、主体、构图、光线和比例。例如:9:16 信息流营销图,真实办公室场景,SKG 颈部按摩仪佩戴清楚。",
|
||||
},
|
||||
{
|
||||
id: "first-frame-video",
|
||||
label: "首帧生视频",
|
||||
icon: Upload,
|
||||
needsFirstFrame: true,
|
||||
placeholder: "上传首帧后写视频变化:人物怎么动、镜头怎么动、产品要保持什么细节、时长多长。",
|
||||
id: "text-video",
|
||||
label: "文生视频",
|
||||
icon: Clapperboard,
|
||||
placeholder: "写清楚画面、人物动作、产品出现方式、镜头运动和风格。例如:15 秒竖屏,办公室午休,人物戴上 SKG 颈部按摩仪放松,镜头缓慢推进。",
|
||||
},
|
||||
{
|
||||
id: "first-last-frame-video",
|
||||
label: "首尾帧生视频",
|
||||
icon: Sparkles,
|
||||
needsFirstFrame: true,
|
||||
needsLastFrame: true,
|
||||
placeholder: "上传首帧和尾帧后,写中间如何过渡、动作节奏、镜头运动和产品细节保持要求。",
|
||||
id: "image-video",
|
||||
label: "图生视频",
|
||||
icon: Upload,
|
||||
needsImage: true,
|
||||
placeholder: "上传图片后,写它要怎么动、镜头怎么运动、产品细节怎么保持、视频节奏和时长。",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -129,9 +118,7 @@ export default function Home() {
|
||||
const [prompt, setPrompt] = useState("")
|
||||
const [seconds, setSeconds] = useState(12)
|
||||
const [firstFrameFile, setFirstFrameFile] = useState<File | null>(null)
|
||||
const [lastFrameFile, setLastFrameFile] = useState<File | null>(null)
|
||||
const [firstFramePreview, setFirstFramePreview] = useState("")
|
||||
const [lastFramePreview, setLastFramePreview] = useState("")
|
||||
const [imageModel, setImageModel] = useState("auto")
|
||||
const [videoModel, setVideoModel] = useState("seedance")
|
||||
const [imageSize, setImageSize] = useState("1024x1536")
|
||||
@@ -149,7 +136,6 @@ export default function Home() {
|
||||
const [busy, setBusy] = useState<BusyTask>(null)
|
||||
const [error, setError] = useState("")
|
||||
const firstInputRef = useRef<HTMLInputElement>(null)
|
||||
const lastInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const activeMode = MODES.find((item) => item.id === mode) ?? MODES[0]
|
||||
const latestImage = latestGeneratedImage(job)
|
||||
@@ -203,16 +189,6 @@ export default function Home() {
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}, [firstFrameFile])
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastFrameFile) {
|
||||
setLastFramePreview("")
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(lastFrameFile)
|
||||
setLastFramePreview(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}, [lastFrameFile])
|
||||
|
||||
useEffect(() => {
|
||||
if (!job || !runningVideo) return
|
||||
const timer = window.setInterval(async () => {
|
||||
@@ -233,18 +209,13 @@ export default function Home() {
|
||||
const onModeChange = (nextMode: CreationMode) => {
|
||||
setMode(nextMode)
|
||||
resetResult()
|
||||
if (nextMode === "text-video" || nextMode === "text-image") {
|
||||
if (nextMode !== "image-video") {
|
||||
setFirstFrameFile(null)
|
||||
setLastFrameFile(null)
|
||||
}
|
||||
if (nextMode === "first-frame-video") {
|
||||
setLastFrameFile(null)
|
||||
}
|
||||
}
|
||||
|
||||
const setUploadFile = (slot: UploadSlot, file: File | null) => {
|
||||
if (slot === "first") setFirstFrameFile(file)
|
||||
if (slot === "last") setLastFrameFile(file)
|
||||
resetResult()
|
||||
}
|
||||
|
||||
@@ -253,12 +224,8 @@ export default function Home() {
|
||||
toast.error("先写提示词")
|
||||
return false
|
||||
}
|
||||
if (activeMode.needsFirstFrame && !firstFrameFile) {
|
||||
toast.error("先上传首帧")
|
||||
return false
|
||||
}
|
||||
if (activeMode.needsLastFrame && !lastFrameFile) {
|
||||
toast.error("先上传尾帧")
|
||||
if (activeMode.needsImage && !firstFrameFile) {
|
||||
toast.error("先上传图片")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -270,13 +237,10 @@ export default function Home() {
|
||||
|
||||
const prepareJob = useCallback(async () => {
|
||||
setBusy("job")
|
||||
let created = await createCreativeImageJob(firstFrameFile)
|
||||
if (mode === "first-last-frame-video" && lastFrameFile) {
|
||||
created = await uploadReferenceFrame(created.id, lastFrameFile)
|
||||
}
|
||||
const created = await createCreativeImageJob(firstFrameFile)
|
||||
setJob(created)
|
||||
return created
|
||||
}, [firstFrameFile, lastFrameFile, mode])
|
||||
}, [firstFrameFile])
|
||||
|
||||
const runImage = async () => {
|
||||
if (!validate()) return
|
||||
@@ -307,13 +271,12 @@ export default function Home() {
|
||||
setError("")
|
||||
try {
|
||||
const target = await prepareJob()
|
||||
const lastFrame = [...target.frames].sort((a, b) => b.index - a.index)[0]
|
||||
const updated = await generateStoryboardVideo(target.id, 0, {
|
||||
prompt: promptWithGuardrails(),
|
||||
duration: seconds,
|
||||
count: 1,
|
||||
first_image: activeMode.needsFirstFrame ? { kind: "keyframe", frame_idx: 0 } : null,
|
||||
last_image: activeMode.needsLastFrame && lastFrame ? { kind: "keyframe", frame_idx: lastFrame.index } : null,
|
||||
first_image: activeMode.needsImage ? { kind: "keyframe", frame_idx: 0 } : null,
|
||||
last_image: null,
|
||||
size: videoSize,
|
||||
model: videoModel,
|
||||
})
|
||||
@@ -397,24 +360,15 @@ export default function Home() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(activeMode.needsFirstFrame || activeMode.needsLastFrame) ? (
|
||||
<div className="mb-3 grid gap-2 sm:grid-cols-2">
|
||||
{activeMode.needsImage ? (
|
||||
<div className="mb-3 grid gap-2">
|
||||
<FrameUpload
|
||||
label="首帧"
|
||||
label="图片"
|
||||
preview={firstFramePreview}
|
||||
required={!!activeMode.needsFirstFrame}
|
||||
required
|
||||
onPick={() => firstInputRef.current?.click()}
|
||||
onClear={() => setUploadFile("first", null)}
|
||||
/>
|
||||
{activeMode.needsLastFrame ? (
|
||||
<FrameUpload
|
||||
label="尾帧"
|
||||
preview={lastFramePreview}
|
||||
required
|
||||
onPick={() => lastInputRef.current?.click()}
|
||||
onClear={() => setUploadFile("last", null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -425,14 +379,6 @@ export default function Home() {
|
||||
className="hidden"
|
||||
onChange={(event) => setUploadFile("first", event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<input
|
||||
ref={lastInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(event) => setUploadFile("last", event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => {
|
||||
@@ -505,12 +451,12 @@ export default function Home() {
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<span>{activeMode.needsFirstFrame ? "图片作为参考帧" : "只根据文字生成"}</span>
|
||||
<span>{activeMode.needsImage ? "图片作为视频参考" : "只根据文字生成"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="/canvas/"
|
||||
href="/"
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-full border border-white/10 bg-white/6 px-4 text-sm font-semibold text-white/72 transition hover:border-cyan-200/24 hover:text-cyan-100"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
|
||||
Reference in New Issue
Block a user