Files
20260512-skg-tk/web/app/page.tsx
kang 6201ee9a7d fix(web): tolerant polling, objectURL cleanup, throttled pointermove
- home/detail video pollers no longer clearInterval on a single transient error
  (give up only after 10 consecutive failures), matching the agent page
- agent page creates preview objectURLs inside useEffect so each has a matching
  revoke under strict-mode double-invocation
- login pointermove throttled via rAF and skipped on coarse pointers
- source-analysis.html: changelog entry for this hardening pass

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 02:04:59 +08:00

587 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import {
ArrowUp,
Clapperboard,
ExternalLink,
Image as ImageIcon,
Loader2,
Plus,
Upload,
X,
type LucideIcon,
} from "lucide-react"
import { Toaster, toast } from "sonner"
import { MediaAssetTile } from "@/components/media-asset-tile"
import {
apiAssetUrl,
createCreativeImageJob,
deleteGeneratedImage,
deleteGeneratedVideo,
generateImage,
generateStoryboardVideo,
getRuntimeHealth,
getJob,
type GeneratedImage,
type GeneratedVideo,
type Job,
type RuntimeModelOption,
type RuntimeSizeOption,
} from "@/lib/api"
type CreationMode = "text-image" | "text-video" | "image-video"
type BusyTask = CreationMode | "job" | null
type UploadSlot = "first"
type ModeConfig = {
id: CreationMode
label: string
icon: LucideIcon
placeholder: string
needsImage?: boolean
}
const MODES: ModeConfig[] = [
{
id: "text-image",
label: "文生图",
icon: ImageIcon,
placeholder: "写清楚画面、主体、构图、光线和比例。例如9:16 信息流营销图真实办公室场景SKG 颈部按摩仪佩戴清楚。",
},
{
id: "text-video",
label: "文生视频",
icon: Clapperboard,
placeholder: "写清楚画面、人物动作、产品出现方式、镜头运动和风格。例如15 秒竖屏,办公室午休,人物戴上 SKG 颈部按摩仪放松,镜头缓慢推进。",
},
{
id: "image-video",
label: "图生视频",
icon: Upload,
needsImage: true,
placeholder: "上传图片后,写它要怎么动、镜头怎么运动、产品细节怎么保持、视频节奏和时长。",
},
]
function cx(...items: Array<string | false | null | undefined>) {
return items.filter(Boolean).join(" ")
}
function allGeneratedImages(job: Job | null): GeneratedImage[] {
if (!job) return []
return job.frames.flatMap((frame) => frame.generated_images ?? []).sort((a, b) => b.created_at - a.created_at)
}
function latestGeneratedImage(job: Job | null): GeneratedImage | null {
return allGeneratedImages(job)[0] ?? null
}
function latestGeneratedVideo(job: Job | null): GeneratedVideo | null {
return [...(job?.generated_videos ?? [])].sort((a, b) => b.created_at - a.created_at)[0] ?? null
}
function videoSrc(job: Job, video: GeneratedVideo) {
return apiAssetUrl(video.url || `/jobs/${job.id}/storyboard-videos/${video.id}.mp4`)
}
function videoStatusText(video: GeneratedVideo) {
if (video.status === "queued") {
return video.queue_message || (video.queue_position && video.queue_position > 1 ? `排队中 · 前方 ${video.queue_position - 1} 个任务` : "排队中 · 即将开始")
}
if (video.status === "in_progress") {
return video.queue_message ? `${video.queue_message} · ${Math.round(video.progress)}%` : `生成中 · ${Math.round(video.progress)}%`
}
if (video.status === "completed") return `${Math.round(video.duration)}s · 可播放`
return "失败 · 可重试"
}
function isVideoMode(mode: CreationMode) {
return mode !== "text-image"
}
const DEFAULT_IMAGE_SIZE_OPTIONS: RuntimeSizeOption[] = [
{ id: "1024x1536", label: "竖图 2:3", value: "1024x1536" },
{ id: "1024x1024", label: "方图 1:1", value: "1024x1024" },
{ id: "1536x1024", label: "横图 3:2", value: "1536x1024" },
{ id: "auto", label: "自动", value: "auto" },
]
const DEFAULT_VIDEO_SIZE_OPTIONS: RuntimeSizeOption[] = [
{ id: "720x1280", label: "竖屏 9:16", value: "720x1280" },
{ id: "1280x720", label: "横屏 16:9", value: "1280x720" },
{ id: "1024x1024", label: "方形 1:1", value: "1024x1024" },
]
export default function Home() {
const [mode, setMode] = useState<CreationMode>("text-video")
const [prompt, setPrompt] = useState("")
const [seconds, setSeconds] = useState(12)
const [firstFrameFile, setFirstFrameFile] = useState<File | null>(null)
const [firstFramePreview, setFirstFramePreview] = useState("")
const [imageModel, setImageModel] = useState("auto")
const [videoModel, setVideoModel] = useState("seedance")
const [imageSize, setImageSize] = useState("1024x1536")
const [videoSize, setVideoSize] = useState("720x1280")
const [videoDurationOptions, setVideoDurationOptions] = useState<number[]>([5, 8, 10, 12, 15])
const [imageOptions, setImageOptions] = useState<RuntimeModelOption[]>([
{ id: "auto", label: "自动", model: "gpt-image-2", available: true },
])
const [videoOptions, setVideoOptions] = useState<RuntimeModelOption[]>([
{ id: "seedance", label: "Seedance", model: "seedance", available: true },
])
const [imageSizeOptions, setImageSizeOptions] = useState<RuntimeSizeOption[]>(DEFAULT_IMAGE_SIZE_OPTIONS)
const [videoSizeOptions, setVideoSizeOptions] = useState<RuntimeSizeOption[]>(DEFAULT_VIDEO_SIZE_OPTIONS)
const [job, setJob] = useState<Job | null>(null)
const [busy, setBusy] = useState<BusyTask>(null)
const [error, setError] = useState("")
const firstInputRef = useRef<HTMLInputElement>(null)
const activeMode = MODES.find((item) => item.id === mode) ?? MODES[0]
const latestImage = latestGeneratedImage(job)
const latestVideo = latestGeneratedVideo(job)
const runningVideo = (job?.generated_videos ?? []).some((item) => item.status === "queued" || item.status === "in_progress")
const submitting = busy === mode || busy === "job"
useEffect(() => {
getRuntimeHealth()
.then((health) => {
const models = health.models
const nextImageOptions = models?.image_options?.length
? models.image_options
: [
{ id: "auto", label: "自动", model: models?.image || "gpt-image-2", available: true },
{ id: models?.image || "gpt-image-2", label: "GPT Image 2", model: models?.image || "gpt-image-2", available: true },
]
const nextVideoOptions = models?.video_options?.length
? models.video_options
: [{ id: models?.video || "seedance", label: "Seedance", model: models?.video || "seedance", available: !!models?.video_configured }]
const nextImageSizeOptions = models?.image_size_options?.length ? models.image_size_options : DEFAULT_IMAGE_SIZE_OPTIONS
const nextVideoSizeOptions = models?.video_size_options?.length ? models.video_size_options : DEFAULT_VIDEO_SIZE_OPTIONS
const nextDurationOptions = models?.video_duration_options?.length ? models.video_duration_options : [5, 8, 10, 12, 15]
setImageOptions(nextImageOptions)
setVideoOptions(nextVideoOptions)
setImageSizeOptions(nextImageSizeOptions)
setVideoSizeOptions(nextVideoSizeOptions)
setVideoDurationOptions(nextDurationOptions)
if (!nextImageOptions.some((item) => item.id === imageModel)) setImageModel(nextImageOptions[0]?.id || "auto")
if (!nextVideoOptions.some((item) => item.id === videoModel)) setVideoModel(nextVideoOptions[0]?.id || "seedance")
if (!nextImageSizeOptions.some((item) => item.value === imageSize)) setImageSize(nextImageSizeOptions[0]?.value || "1024x1536")
if (!nextVideoSizeOptions.some((item) => item.value === videoSize)) setVideoSize(nextVideoSizeOptions[0]?.value || "720x1280")
if (!nextDurationOptions.includes(seconds)) setSeconds(nextDurationOptions.includes(12) ? 12 : (nextDurationOptions[0] ?? 5))
})
.catch(() => {
setImageOptions([{ id: "auto", label: "自动", model: "gpt-image-2", available: true }])
setVideoOptions([{ id: "seedance", label: "Seedance", model: "seedance", available: true }])
setImageSizeOptions(DEFAULT_IMAGE_SIZE_OPTIONS)
setVideoSizeOptions(DEFAULT_VIDEO_SIZE_OPTIONS)
setVideoDurationOptions([5, 8, 10, 12, 15])
})
}, [])
useEffect(() => {
if (!firstFrameFile) {
setFirstFramePreview("")
return
}
const url = URL.createObjectURL(firstFrameFile)
setFirstFramePreview(url)
return () => URL.revokeObjectURL(url)
}, [firstFrameFile])
useEffect(() => {
if (!job || !runningVideo) return
let failures = 0
const timer = window.setInterval(async () => {
try {
setJob(await getJob(job.id))
failures = 0
} catch {
// one transient 5xx / network blip must not freeze progress forever;
// only give up after sustained failures
failures += 1
if (failures >= 10) window.clearInterval(timer)
}
}, 2600)
return () => window.clearInterval(timer)
}, [job, runningVideo])
const resetResult = () => {
setJob(null)
setError("")
}
const onModeChange = (nextMode: CreationMode) => {
setMode(nextMode)
resetResult()
if (nextMode !== "image-video") {
setFirstFrameFile(null)
}
}
const setUploadFile = (slot: UploadSlot, file: File | null) => {
if (slot === "first") setFirstFrameFile(file)
resetResult()
}
const validate = () => {
if (!prompt.trim()) {
toast.error("先写提示词")
return false
}
if (activeMode.needsImage && !firstFrameFile) {
toast.error("先上传图片")
return false
}
return true
}
const promptWithGuardrails = () => (
`${prompt.trim()}\n\nKeep the product shape stable when a product appears. Use a clean vertical marketing composition unless the prompt says otherwise.`
)
const prepareJob = useCallback(async () => {
setBusy("job")
const created = await createCreativeImageJob(firstFrameFile)
setJob(created)
return created
}, [firstFrameFile])
const runImage = async () => {
if (!validate()) return
setBusy("text-image")
setError("")
try {
const target = await prepareJob()
const updated = await generateImage(target.id, 0, {
prompt: promptWithGuardrails(),
mode: "text",
model: imageModel,
size: imageSize,
})
setJob(updated)
toast.success("图片已生成")
} catch (e) {
const message = e instanceof Error ? e.message : "生图失败"
setError(message)
toast.error(message)
} finally {
setBusy(null)
}
}
const runVideo = async () => {
if (!validate()) return
setBusy(mode)
setError("")
try {
const target = await prepareJob()
const updated = await generateStoryboardVideo(target.id, 0, {
prompt: promptWithGuardrails(),
duration: seconds,
count: 1,
first_image: activeMode.needsImage ? { kind: "keyframe", frame_idx: 0 } : null,
last_image: null,
size: videoSize,
model: videoModel,
})
setJob(updated)
toast.success("已加入生成队列")
} catch (e) {
const message = e instanceof Error ? e.message : "生视频失败"
setError(message)
toast.error(message)
} finally {
setBusy(null)
}
}
const runPrimary = () => {
if (mode === "text-image") return runImage()
return runVideo()
}
const deleteImage = async (image: GeneratedImage) => {
if (!job) return
try {
setJob(await deleteGeneratedImage(job.id, 0, image.id))
toast.success("图片已删除")
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败")
}
}
const deleteVideo = async (video: GeneratedVideo) => {
if (!job) return
try {
setJob(await deleteGeneratedVideo(job.id, video.id))
toast.success("视频已删除")
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败")
}
}
return (
<main className="min-h-screen bg-[#090a0f] text-white">
<Toaster richColors position="top-center" />
<div className="flex min-h-screen flex-col">
<header className="flex h-14 shrink-0 items-center justify-between px-4 text-xs text-white/42 sm:px-6">
<div className="inline-flex h-8 items-center rounded-full bg-white px-3 shadow-[0_10px_30px_rgba(0,0,0,0.24)]">
<img src="/skg-logo-black.svg" alt="SKG" className="h-4 w-auto" />
</div>
{job ? (
<a href={`/detail/?job=${job.id}`} className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-white/52 transition hover:bg-white/8 hover:text-white">
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : null}
</header>
<section className="flex flex-1 items-center justify-center px-4 pb-16 pt-6">
<div className="grid w-full max-w-[760px] gap-5">
<div className="grid justify-items-center gap-2 text-center">
<h1 className="text-xl font-semibold tracking-normal text-white/92"></h1>
<p className="text-sm text-white/34"></p>
</div>
<section className="rounded-[18px] border border-white/8 bg-[#1c1f28] p-3 shadow-[0_28px_90px_rgba(0,0,0,0.32)]">
<div className="mb-3 grid grid-cols-2 gap-2 sm:grid-cols-4">
{MODES.map((item) => {
const Icon = item.icon
const selected = item.id === mode
return (
<button
key={item.id}
type="button"
onClick={() => onModeChange(item.id)}
className={cx(
"inline-flex h-10 items-center justify-center gap-2 rounded-xl border px-2 text-sm font-semibold transition",
selected ? "border-cyan-300/28 bg-cyan-300/12 text-cyan-100" : "border-white/7 bg-black/14 text-white/48 hover:border-white/14 hover:text-white",
)}
>
<Icon className="h-4 w-4" />
{item.label}
</button>
)
})}
</div>
{activeMode.needsImage ? (
<div className="mb-3 grid gap-2">
<FrameUpload
label="图片"
preview={firstFramePreview}
required
onPick={() => firstInputRef.current?.click()}
onClear={() => setUploadFile("first", null)}
/>
</div>
) : null}
<input
ref={firstInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={(event) => setUploadFile("first", event.target.files?.[0] ?? null)}
/>
<textarea
value={prompt}
onChange={(event) => {
setPrompt(event.target.value)
setError("")
}}
placeholder={activeMode.placeholder}
className="min-h-36 w-full resize-none rounded-xl border border-white/7 bg-black/18 px-4 py-3 text-[15px] leading-7 text-white outline-none placeholder:text-white/24 focus:border-cyan-200/28"
/>
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-white/38">
<label className="inline-flex h-9 items-center gap-2 rounded-xl border border-white/7 bg-black/14 px-3">
<select
value={isVideoMode(mode) ? videoModel : imageModel}
onChange={(event) => {
if (isVideoMode(mode)) setVideoModel(event.target.value)
else setImageModel(event.target.value)
}}
className="max-w-36 bg-transparent text-white/76 outline-none"
>
{(isVideoMode(mode) ? videoOptions : imageOptions).map((item) => (
<option key={item.id} value={item.id} disabled={item.available === false}>
{item.label}
</option>
))}
</select>
</label>
{isVideoMode(mode) ? (
<>
<label className="inline-flex h-9 items-center gap-2 rounded-xl border border-white/7 bg-black/14 px-3">
<select
value={videoSize}
onChange={(event) => setVideoSize(event.target.value)}
className="max-w-32 bg-transparent text-white/76 outline-none"
>
{videoSizeOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="inline-flex h-9 items-center gap-2 rounded-xl border border-white/7 bg-black/14 px-3">
<select
value={seconds}
onChange={(event) => setSeconds(Number(event.target.value))}
className="bg-transparent text-white/76 outline-none"
>
{videoDurationOptions.map((value) => <option key={value} value={value}>{value}s</option>)}
</select>
</label>
</>
) : (
<label className="inline-flex h-9 items-center gap-2 rounded-xl border border-white/7 bg-black/14 px-3">
<select
value={imageSize}
onChange={(event) => setImageSize(event.target.value)}
className="max-w-32 bg-transparent text-white/76 outline-none"
>
{imageSizeOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
)}
<span>{activeMode.needsImage ? "图片作为视频参考" : "只根据文字生成"}</span>
</div>
<div className="flex items-center gap-2">
<a
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" />
</a>
<button
type="button"
onClick={runPrimary}
disabled={!!busy}
className="inline-flex h-10 min-w-28 items-center justify-center gap-2 rounded-full bg-cyan-200 px-5 text-sm font-semibold text-black transition hover:bg-white disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowUp className="h-4 w-4" />}
</button>
</div>
</div>
</section>
{error ? (
<div className="rounded-2xl border border-rose-300/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">{error}</div>
) : null}
{(latestImage || latestVideo) && (
<section className="rounded-[18px] border border-white/8 bg-[#161922] p-3">
<div className="mb-3 flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-white/82"></h2>
{job ? <a href={`/detail/?job=${job.id}`} className="text-xs font-semibold text-cyan-200/82 hover:text-cyan-100"></a> : null}
</div>
{latestVideo && job ? (
<div className="grid gap-2">
{latestVideo.status === "queued" || latestVideo.status === "in_progress" ? (
<div className="flex items-center justify-between gap-3 rounded-xl border border-cyan-200/12 bg-cyan-200/8 px-3 py-2 text-xs text-cyan-50/78">
<span>{videoStatusText(latestVideo)}</span>
{latestVideo.status === "queued" && latestVideo.queue_size ? <span className="text-cyan-100/46"> {latestVideo.queue_position || 0}/{latestVideo.queue_size}</span> : null}
</div>
) : null}
<MediaAssetTile
kind="video"
src={latestVideo.status === "completed" ? videoSrc(job, latestVideo) : undefined}
poster={apiAssetUrl(latestVideo.poster_url)}
objectFit="cover"
previewObjectFit="contain"
className="aspect-video w-full rounded-xl"
videoControls={latestVideo.status === "completed"}
label={latestVideo.model}
meta={videoStatusText(latestVideo)}
previewDetail={latestVideo.error || undefined}
emptyText={latestVideo.status === "failed" ? "失败" : undefined}
busy={latestVideo.status === "queued" || latestVideo.status === "in_progress"}
onDelete={() => deleteVideo(latestVideo)}
/>
</div>
) : latestImage ? (
<MediaAssetTile
src={apiAssetUrl(latestImage.url)}
alt="generated image"
objectFit="cover"
previewObjectFit="contain"
className="aspect-video w-full rounded-xl"
label={latestImage.model}
meta={latestImage.mode}
onDelete={() => deleteImage(latestImage)}
/>
) : null}
</section>
)}
</div>
</section>
</div>
</main>
)
}
function FrameUpload({
label,
preview,
required,
onPick,
onClear,
}: {
label: string
preview: string
required: boolean
onPick: () => void
onClear: () => void
}) {
return (
<div className="relative">
<button
type="button"
onClick={onPick}
className="flex h-24 w-full items-center justify-center overflow-hidden rounded-xl border border-dashed border-white/12 bg-black/16 text-sm font-semibold text-white/44 transition hover:border-cyan-200/30 hover:text-white"
>
{preview ? (
<MediaAssetTile
src={preview}
alt={label}
objectFit="cover"
previewObjectFit="contain"
className="h-full w-full rounded-xl"
/>
) : (
<span className="inline-flex items-center gap-2">
<Plus className="h-4 w-4" />
{label}{required ? "" : "(可选)"}
</span>
)}
</button>
{preview ? (
<button
type="button"
onClick={onClear}
className="absolute right-2 top-2 rounded-lg bg-black/60 p-1 text-white/70 transition hover:bg-black hover:text-white"
aria-label={`移除${label}`}
title={`移除${label}`}
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
)
}