auto-save 2026-05-17 22:14 (~3)

This commit is contained in:
2026-05-17 22:14:21 +08:00
parent 87015e919a
commit e97dcd9b76
3 changed files with 112 additions and 29 deletions

View File

@@ -851,6 +851,8 @@ export function AdRecreationBoard({
selectedFrames={data.selectedFrames}
onToggleFrame={data.onToggleFrame}
onJobUpdate={data.onJobUpdate}
onAddFrame={data.onAddManualFrameForJob}
onDeleteFrame={data.onDeleteFrameForJob}
/>
<AudioStoryboardPlanPanel
job={job}
@@ -990,11 +992,15 @@ function AudioIntakePanel({
selectedFrames,
onToggleFrame,
onJobUpdate,
onAddFrame,
onDeleteFrame,
}: {
job: Job | null
selectedFrames: Set<number>
onToggleFrame: (idx: number) => void
onJobUpdate: (job: Job) => void
onAddFrame?: (jobId: string, t: number) => Promise<void> | void
onDeleteFrame?: (jobId: string, idx: number) => Promise<void> | void
}) {
const [currentTime, setCurrentTime] = useState(0)
const [mediaDuration, setMediaDuration] = useState(0)
@@ -1126,7 +1132,7 @@ function AudioIntakePanel({
/>
</div>
<div className="grid gap-2 xl:grid-cols-[300px_minmax(340px,1fr)_430px] 2xl:grid-cols-[340px_minmax(520px,1fr)_480px]">
<div className="grid gap-2 xl:grid-cols-[340px_minmax(340px,1fr)_320px] 2xl:grid-cols-[410px_minmax(500px,1fr)_320px]">
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3">
<SectionTitle icon={<Play className="h-4 w-4" />} title="原版视频" />
@@ -1138,7 +1144,7 @@ function AudioIntakePanel({
ref={videoRef}
controls
playsInline
className="h-[238px] w-full bg-black object-contain"
className="h-[270px] w-full bg-black object-contain 2xl:h-[300px]"
src={videoSrcUrl}
onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)}
onSeeked={(event) => setCurrentTime(event.currentTarget.currentTime)}
@@ -1152,7 +1158,7 @@ function AudioIntakePanel({
}}
/>
) : (
<div className="flex h-[238px] items-center justify-center text-[12px] text-white/38"></div>
<div className="flex h-[270px] items-center justify-center text-[12px] text-white/38 2xl:h-[300px]"></div>
)}
</div>
</div>
@@ -1162,6 +1168,10 @@ function AudioIntakePanel({
selectedFrames={selectedFrames}
onToggleFrame={onToggleFrame}
onJobUpdate={onJobUpdate}
currentTime={currentTime}
duration={timelineDuration}
onAddFrame={onAddFrame}
onDeleteFrame={onDeleteFrame}
/>
<div className="min-w-0">
@@ -1176,7 +1186,7 @@ function AudioIntakePanel({
<div></div>
<div></div>
</div>
<div className="max-h-[238px] overflow-y-auto">
<div className="max-h-[270px] overflow-y-auto 2xl:max-h-[300px]">
{job.transcript.map((segment) => {
const active = activeSegment?.index === segment.index
return (
@@ -1213,14 +1223,24 @@ function SourceReferenceBuildPanel({
selectedFrames,
onToggleFrame,
onJobUpdate,
currentTime,
duration,
onAddFrame,
onDeleteFrame,
}: {
job: Job
selectedFrames: Set<number>
onToggleFrame: (idx: number) => void
onJobUpdate: (job: Job) => void
currentTime: number
duration: number
onAddFrame?: (jobId: string, t: number) => Promise<void> | void
onDeleteFrame?: (jobId: string, idx: number) => Promise<void> | void
}) {
const [extracting, setExtracting] = useState(false)
const [manualBusy, setManualBusy] = useState(false)
const [subjectBusy, setSubjectBusy] = useState(false)
const [deletingFrame, setDeletingFrame] = useState<number | null>(null)
const frames = useMemo(() => [...job.frames].sort((a, b) => a.timestamp - b.timestamp), [job.frames])
const selectedReferenceFrames = useMemo(
() => frames.filter((frame) => selectedFrames.has(frame.index)),
@@ -1239,6 +1259,7 @@ function SourceReferenceBuildPanel({
return null
}, [frames, selectedReferenceFrames])
const actorAssets = actorSource?.element.subject_assets ?? []
const manualFrameTime = clampNumber(currentTime, 0, Math.max(duration, 1))
const extractKeyframes = async () => {
setExtracting(true)
@@ -1301,6 +1322,26 @@ function SourceReferenceBuildPanel({
}
}
const addFrameAtCurrentTime = async () => {
if (!onAddFrame) return
setManualBusy(true)
try {
await onAddFrame(job.id, manualFrameTime)
} finally {
setManualBusy(false)
}
}
const deleteReferenceFrame = async (idx: number) => {
if (!onDeleteFrame) return
setDeletingFrame(idx)
try {
await onDeleteFrame(job.id, idx)
} finally {
setDeletingFrame(null)
}
}
return (
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-3">
@@ -1309,8 +1350,8 @@ function SourceReferenceBuildPanel({
{frames.length ? `${frames.length}` : "待抽帧"} · {selectedReferenceFrames.length}
</span>
</div>
<div className="h-[238px] overflow-y-auto rounded-md border border-white/10 bg-black/32 p-2">
<div className="grid grid-cols-2 gap-1.5">
<div className="h-[270px] overflow-y-auto rounded-md border border-white/10 bg-black/32 p-2 2xl:h-[300px]">
<div className="grid grid-cols-3 gap-1.5">
<button
type="button"
onClick={() => void extractKeyframes()}
@@ -1320,6 +1361,16 @@ function SourceReferenceBuildPanel({
{extracting || job.status === "splitting" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
12
</button>
<button
type="button"
onClick={() => void addFrameAtCurrentTime()}
disabled={!job.video_url || !onAddFrame || manualBusy || job.status === "splitting"}
title={`按当前播放位置手动抽帧:${manualFrameTime.toFixed(1)}s`}
className="inline-flex h-7 items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.055] px-2 text-[10.5px] font-semibold text-white/66 transition hover:border-emerald-300/35 hover:text-emerald-100 disabled:cursor-not-allowed disabled:opacity-35"
>
{manualBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => void generateSimilarActor()}
@@ -1332,29 +1383,48 @@ function SourceReferenceBuildPanel({
</div>
<div className="mt-2 grid grid-cols-4 gap-1.5">
{frames.slice(0, 12).map((frame, index) => {
{frames.map((frame, index) => {
const selected = selectedFrames.has(frame.index)
return (
<button
<div
key={frame.index}
type="button"
onClick={() => onToggleFrame(frame.index)}
className={`group relative h-12 overflow-hidden rounded border bg-black transition ${
selected ? "border-emerald-300/70" : "border-white/10 hover:border-cyan-300/40"
}`}
title={`关键帧 ${index + 1} · ${frame.timestamp.toFixed(1)}s`}
>
<img src={effectiveFrameUrl(job.id, frame)} alt="" className="h-full w-full object-cover" />
<button
type="button"
onClick={() => onToggleFrame(frame.index)}
className="absolute inset-0 cursor-pointer overflow-hidden focus:outline-none focus:ring-1 focus:ring-cyan-200/70"
>
<img src={effectiveFrameUrl(job.id, frame)} alt="" className="h-full w-full object-cover" />
</button>
<span className="absolute left-1 top-1 rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>
<span className="absolute right-1 top-1 rounded-full bg-black/72 p-0.5">
{selected ? <Check className="h-3 w-3 text-emerald-200" /> : <Circle className="h-3 w-3 text-white/50" />}
</span>
</button>
{onDeleteFrame && (
<button
type="button"
onClick={(event) => {
event.stopPropagation()
void deleteReferenceFrame(frame.index)
}}
disabled={deletingFrame === frame.index}
className="absolute bottom-1 right-1 inline-flex h-6 w-6 items-center justify-center rounded-full border border-rose-200/25 bg-black/78 text-rose-100 opacity-80 transition hover:border-rose-200/55 hover:bg-rose-500/25 hover:opacity-100 focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-rose-100/70 disabled:cursor-not-allowed disabled:opacity-60"
aria-label={`删除关键帧 ${index + 1}`}
title="删除这张关键帧"
>
{deletingFrame === frame.index ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Trash2 className="h-3.5 w-3.5" />}
</button>
)}
</div>
)
})}
{!frames.length && (
<div className="col-span-4 flex h-[106px] items-center justify-center rounded border border-dashed border-white/12 text-[11px] text-white/34">
12
12
</div>
)}
</div>