feat: move keyframes beside transcript timeline
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1697,6 +1697,8 @@ function AudioIntakePanel({
|
|||||||
const [audioFeatures, setAudioFeatures] = useState<AudioFeature[]>([])
|
const [audioFeatures, setAudioFeatures] = useState<AudioFeature[]>([])
|
||||||
const [audioFeatureStatus, setAudioFeatureStatus] = useState<AudioFeatureStatus>("idle")
|
const [audioFeatureStatus, setAudioFeatureStatus] = useState<AudioFeatureStatus>("idle")
|
||||||
const [manualBusy, setManualBusy] = useState(false)
|
const [manualBusy, setManualBusy] = useState(false)
|
||||||
|
const [extracting, setExtracting] = useState(false)
|
||||||
|
const [deletingFrame, setDeletingFrame] = useState<number | null>(null)
|
||||||
const [waveHoverTime, setWaveHoverTime] = useState<number | null>(null)
|
const [waveHoverTime, setWaveHoverTime] = useState<number | null>(null)
|
||||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||||
const rowRefs = useRef<Record<number, HTMLDivElement | null>>({})
|
const rowRefs = useRef<Record<number, HTMLDivElement | null>>({})
|
||||||
@@ -1720,6 +1722,11 @@ function AudioIntakePanel({
|
|||||||
)
|
)
|
||||||
}, [job, mediaDuration])
|
}, [job, mediaDuration])
|
||||||
const activeSegment = job?.transcript.find((segment) => currentTime >= segment.start && currentTime <= Math.max(segment.end, segment.start + 0.2))
|
const activeSegment = job?.transcript.find((segment) => currentTime >= segment.start && currentTime <= Math.max(segment.end, segment.start + 0.2))
|
||||||
|
const frames = useMemo(() => job ? [...job.frames].sort((a, b) => a.timestamp - b.timestamp) : [], [job])
|
||||||
|
const selectedReferenceFrames = useMemo(
|
||||||
|
() => frames.filter((frame) => selectedFrames.has(frame.index)),
|
||||||
|
[frames, selectedFrames],
|
||||||
|
)
|
||||||
const waveTimeHint = waveHoverTime !== null
|
const waveTimeHint = waveHoverTime !== null
|
||||||
? `指针停点 ${waveHoverTime.toFixed(1)}s`
|
? `指针停点 ${waveHoverTime.toFixed(1)}s`
|
||||||
: activeSegment
|
: activeSegment
|
||||||
@@ -1802,6 +1809,33 @@ function AudioIntakePanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extractKeyframes = async () => {
|
||||||
|
if (!job) return
|
||||||
|
setExtracting(true)
|
||||||
|
try {
|
||||||
|
for (const frame of job.frames) {
|
||||||
|
if (selectedFrames.has(frame.index)) onToggleFrame(frame.index)
|
||||||
|
}
|
||||||
|
const updated = await analyzeJob(job.id, 12, "motion", "replace", "accurate")
|
||||||
|
onJobUpdate(updated)
|
||||||
|
toast.info("已按动作峰值逻辑重新抽取 12 张参考帧,完成后在时间轴左侧选择主角参考。")
|
||||||
|
} catch (e) {
|
||||||
|
toast.error("12 张关键帧抽取失败:" + (e instanceof Error ? e.message : String(e)))
|
||||||
|
} finally {
|
||||||
|
setExtracting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteReferenceFrame = async (idx: number) => {
|
||||||
|
if (!job || !onDeleteFrame) return
|
||||||
|
setDeletingFrame(idx)
|
||||||
|
try {
|
||||||
|
await onDeleteFrame(job.id, idx)
|
||||||
|
} finally {
|
||||||
|
setDeletingFrame(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return <EmptyState text="先在左侧粘贴 TK 链接或上传本地视频。点击开始后,会先下载视频,再自动解析原音频文案、讲话人节奏和背景音。" />
|
return <EmptyState text="先在左侧粘贴 TK 链接或上传本地视频。点击开始后,会先下载视频,再自动解析原音频文案、讲话人节奏和背景音。" />
|
||||||
}
|
}
|
||||||
@@ -1900,44 +1934,58 @@ function AudioIntakePanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0">
|
<div className="grid gap-2 xl:grid-cols-[360px_minmax(0,1fr)] 2xl:grid-cols-[400px_minmax(0,1fr)]">
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<SourceKeyframePicker
|
||||||
<SectionTitle icon={<FileText className="h-4 w-4" />} title="逐句时间轴" />
|
job={job}
|
||||||
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[11px] text-white/45">{job.transcript.length} 段</span>
|
frames={frames}
|
||||||
</div>
|
selectedFrames={selectedFrames}
|
||||||
{job.transcript.length ? (
|
selectedReferenceFrames={selectedReferenceFrames}
|
||||||
<div className="overflow-hidden rounded-md border border-white/10">
|
extracting={extracting}
|
||||||
<div className="grid grid-cols-[76px_minmax(0,1fr)] border-b border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-semibold text-white/50">
|
deletingFrame={deletingFrame}
|
||||||
<div>时间</div>
|
onToggleFrame={onToggleFrame}
|
||||||
<div>原文 / 中文</div>
|
onExtract={() => void extractKeyframes()}
|
||||||
</div>
|
onDeleteFrame={onDeleteFrame ? (idx) => void deleteReferenceFrame(idx) : undefined}
|
||||||
<div className="max-h-[288px] overflow-y-auto 2xl:max-h-[346px]">
|
/>
|
||||||
{job.transcript.map((segment) => {
|
|
||||||
const active = activeSegment?.index === segment.index
|
<div className="min-w-0">
|
||||||
return (
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<div
|
<SectionTitle icon={<FileText className="h-4 w-4" />} title="逐句时间轴" />
|
||||||
key={segment.index}
|
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[11px] text-white/45">{job.transcript.length} 段</span>
|
||||||
ref={(node) => { rowRefs.current[segment.index] = node }}
|
|
||||||
onClick={() => seekTo(segment.start)}
|
|
||||||
className={`grid cursor-pointer grid-cols-[76px_minmax(0,1fr)] gap-2 border-b px-3 py-1.5 text-[11.5px] leading-snug transition last:border-b-0 ${
|
|
||||||
active
|
|
||||||
? "border-emerald-300/18 bg-emerald-300/[0.12] text-white"
|
|
||||||
: "border-white/8 text-white/64 hover:bg-white/[0.045]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className={`font-mono text-[10.5px] ${active ? "text-emerald-100" : "text-white/38"}`}>{segment.start.toFixed(1)}-{segment.end.toFixed(1)}s</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="truncate" title={segment.en}>{segment.en || <span className="text-white/30">-</span>}</div>
|
|
||||||
<div className={`mt-0.5 truncate text-[11px] ${active ? "text-emerald-50/80" : "text-white/42"}`} title={segment.zh}>{segment.zh || <span className="text-white/30">翻译中</span>}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
{job.transcript.length ? (
|
||||||
<EmptyState text={processing ? "音频解析中,完成后这里会按时间列出原文案和中文翻译。" : "下载完成后会自动解析音频;也可以点击右上角“解析音频”手动重试。"} />
|
<div className="overflow-hidden rounded-md border border-white/10">
|
||||||
)}
|
<div className="grid grid-cols-[76px_minmax(0,1fr)] border-b border-white/10 bg-white/[0.04] px-3 py-1.5 text-[11px] font-semibold text-white/50">
|
||||||
|
<div>时间</div>
|
||||||
|
<div>原文 / 中文</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[288px] overflow-y-auto 2xl:max-h-[346px]">
|
||||||
|
{job.transcript.map((segment) => {
|
||||||
|
const active = activeSegment?.index === segment.index
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={segment.index}
|
||||||
|
ref={(node) => { rowRefs.current[segment.index] = node }}
|
||||||
|
onClick={() => seekTo(segment.start)}
|
||||||
|
className={`grid cursor-pointer grid-cols-[76px_minmax(0,1fr)] gap-2 border-b px-3 py-1.5 text-[11.5px] leading-snug transition last:border-b-0 ${
|
||||||
|
active
|
||||||
|
? "border-emerald-300/18 bg-emerald-300/[0.12] text-white"
|
||||||
|
: "border-white/8 text-white/64 hover:bg-white/[0.045]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`font-mono text-[10.5px] ${active ? "text-emerald-100" : "text-white/38"}`}>{segment.start.toFixed(1)}-{segment.end.toFixed(1)}s</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate" title={segment.en}>{segment.en || <span className="text-white/30">-</span>}</div>
|
||||||
|
<div className={`mt-0.5 truncate text-[11px] ${active ? "text-emerald-50/80" : "text-white/42"}`} title={segment.zh}>{segment.zh || <span className="text-white/30">翻译中</span>}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState text={processing ? "音频解析中,完成后这里会按时间列出原文案和中文翻译。" : "下载完成后会自动解析音频;也可以点击右上角“解析音频”手动重试。"} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1945,9 +1993,7 @@ function AudioIntakePanel({
|
|||||||
<SourceReferenceBuildPanel
|
<SourceReferenceBuildPanel
|
||||||
job={job}
|
job={job}
|
||||||
selectedFrames={selectedFrames}
|
selectedFrames={selectedFrames}
|
||||||
onToggleFrame={onToggleFrame}
|
|
||||||
onJobUpdate={onJobUpdate}
|
onJobUpdate={onJobUpdate}
|
||||||
onDeleteFrame={onDeleteFrame}
|
|
||||||
runtimeModels={runtimeModels}
|
runtimeModels={runtimeModels}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1956,25 +2002,137 @@ function AudioIntakePanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SourceKeyframePicker({
|
||||||
|
job,
|
||||||
|
frames,
|
||||||
|
selectedFrames,
|
||||||
|
selectedReferenceFrames,
|
||||||
|
extracting,
|
||||||
|
deletingFrame,
|
||||||
|
onToggleFrame,
|
||||||
|
onExtract,
|
||||||
|
onDeleteFrame,
|
||||||
|
}: {
|
||||||
|
job: Job
|
||||||
|
frames: KeyFrame[]
|
||||||
|
selectedFrames: Set<number>
|
||||||
|
selectedReferenceFrames: KeyFrame[]
|
||||||
|
extracting: boolean
|
||||||
|
deletingFrame: number | null
|
||||||
|
onToggleFrame: (idx: number) => void
|
||||||
|
onExtract: () => void
|
||||||
|
onDeleteFrame?: (idx: number) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<SectionTitle icon={<ImageIcon className="h-4 w-4" />} title="关键帧" />
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[10px] text-white/42">
|
||||||
|
已选 {selectedReferenceFrames.length || "全部"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onExtract}
|
||||||
|
disabled={!job.video_url || extracting || job.status === "splitting"}
|
||||||
|
title="自动按动作峰值抽 12 张参考帧,更偏向手势、表情变化、节奏点和镜头变化"
|
||||||
|
className="inline-flex h-7 items-center justify-center gap-1 rounded-md bg-white px-2 text-[10.5px] font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{extracting || job.status === "splitting" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
|
||||||
|
自动 12 张
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid min-h-[205px] gap-1.5 rounded-md border border-white/10 bg-black/32 p-1.5 xl:grid-cols-[minmax(0,1fr)_86px] 2xl:min-h-[260px] 2xl:grid-cols-[minmax(0,1fr)_96px]">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-2">
|
||||||
|
<span className="text-[10px] text-white/34">参考帧池</span>
|
||||||
|
<span className="text-[9.5px] text-white/28">悬停放大</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid max-h-[178px] grid-cols-[repeat(auto-fill,minmax(38px,1fr))] gap-1 overflow-y-auto pr-0.5 2xl:max-h-[232px]">
|
||||||
|
{frames.map((frame, index) => {
|
||||||
|
const selected = selectedFrames.has(frame.index)
|
||||||
|
return (
|
||||||
|
<MediaAssetTile
|
||||||
|
key={frame.index}
|
||||||
|
src={effectiveFrameUrl(job.id, frame)}
|
||||||
|
alt={`关键帧 ${index + 1}`}
|
||||||
|
label={`参考帧 ${String(index + 1).padStart(2, "0")}`}
|
||||||
|
meta={`${frame.timestamp.toFixed(1)}s`}
|
||||||
|
className="aspect-[9/16]"
|
||||||
|
objectFit="contain"
|
||||||
|
selected={selected}
|
||||||
|
title={`关键帧 ${index + 1} · ${frame.timestamp.toFixed(1)}s`}
|
||||||
|
onClick={() => onToggleFrame(frame.index)}
|
||||||
|
topLeft={<span className="rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>}
|
||||||
|
topRight={<span className="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>}
|
||||||
|
onDelete={onDeleteFrame ? () => onDeleteFrame(frame.index) : undefined}
|
||||||
|
deleting={deletingFrame === frame.index}
|
||||||
|
deleteLabel={`删除关键帧 ${index + 1}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{!frames.length && (
|
||||||
|
<div className="col-span-full flex h-[106px] items-center justify-center rounded border border-dashed border-white/12 px-2 text-center text-[10.5px] leading-snug text-white/34">
|
||||||
|
自动抽帧或在原版视频上用当前点抽帧。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="min-w-0 rounded-md border border-emerald-300/12 bg-emerald-300/[0.035] p-1">
|
||||||
|
<div className="mb-1 flex items-center justify-between gap-1">
|
||||||
|
<span className="text-[9.5px] font-semibold text-emerald-50/70">已选</span>
|
||||||
|
<span className="font-mono text-[9px] text-emerald-50/55">{selectedReferenceFrames.length}</span>
|
||||||
|
</div>
|
||||||
|
{selectedReferenceFrames.length ? (
|
||||||
|
<div className="grid max-h-[168px] grid-cols-2 gap-1 overflow-y-auto pr-0.5 2xl:max-h-[222px]">
|
||||||
|
{selectedReferenceFrames.map((frame) => {
|
||||||
|
const order = frames.findIndex((item) => item.index === frame.index)
|
||||||
|
const label = order >= 0 ? String(order + 1).padStart(2, "0") : String(frame.index)
|
||||||
|
return (
|
||||||
|
<MediaAssetTile
|
||||||
|
key={frame.index}
|
||||||
|
src={effectiveFrameUrl(job.id, frame)}
|
||||||
|
alt={`已选关键帧 ${label}`}
|
||||||
|
label={`已选 ${label}`}
|
||||||
|
meta={`${frame.timestamp.toFixed(1)}s`}
|
||||||
|
className="aspect-[9/16]"
|
||||||
|
objectFit="contain"
|
||||||
|
selected
|
||||||
|
title={`点击取消选择 · ${frame.timestamp.toFixed(1)}s`}
|
||||||
|
onClick={() => onToggleFrame(frame.index)}
|
||||||
|
topLeft={<span className="rounded bg-black/72 px-1 font-mono text-[8.5px] text-emerald-100/80">{label}</span>}
|
||||||
|
topRight={<span className="rounded-full bg-black/72 p-0.5"><Check className="h-2.5 w-2.5 text-emerald-200" /></span>}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-[90px] items-center justify-center rounded border border-dashed border-emerald-300/12 px-1 text-center text-[9.5px] leading-snug text-white/30">
|
||||||
|
未选时默认使用全部帧。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SourceReferenceBuildPanel({
|
function SourceReferenceBuildPanel({
|
||||||
job,
|
job,
|
||||||
selectedFrames,
|
selectedFrames,
|
||||||
onToggleFrame,
|
|
||||||
onJobUpdate,
|
onJobUpdate,
|
||||||
onDeleteFrame,
|
|
||||||
runtimeModels,
|
runtimeModels,
|
||||||
}: {
|
}: {
|
||||||
job: Job
|
job: Job
|
||||||
selectedFrames: Set<number>
|
selectedFrames: Set<number>
|
||||||
onToggleFrame: (idx: number) => void
|
|
||||||
onJobUpdate: (job: Job) => void
|
onJobUpdate: (job: Job) => void
|
||||||
onDeleteFrame?: (jobId: string, idx: number) => Promise<void> | void
|
|
||||||
runtimeModels?: RuntimeModels
|
runtimeModels?: RuntimeModels
|
||||||
}) {
|
}) {
|
||||||
const [extracting, setExtracting] = useState(false)
|
|
||||||
const [subjectBusy, setSubjectBusy] = useState(false)
|
const [subjectBusy, setSubjectBusy] = useState(false)
|
||||||
const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null)
|
const [subjectAssetBusy, setSubjectAssetBusy] = useState<string | null>(null)
|
||||||
const [deletingFrame, setDeletingFrame] = useState<number | null>(null)
|
|
||||||
const [subjectStyle, setSubjectStyle] = useState<SubjectStyleMode>("transparent_human")
|
const [subjectStyle, setSubjectStyle] = useState<SubjectStyleMode>("transparent_human")
|
||||||
const [subjectDirection, setSubjectDirection] = useState("")
|
const [subjectDirection, setSubjectDirection] = useState("")
|
||||||
const [characterLibrary, setCharacterLibrary] = useState<CharacterLibraryItem[]>([])
|
const [characterLibrary, setCharacterLibrary] = useState<CharacterLibraryItem[]>([])
|
||||||
@@ -2011,9 +2169,9 @@ function SourceReferenceBuildPanel({
|
|||||||
})
|
})
|
||||||
}, [actorAssets])
|
}, [actorAssets])
|
||||||
const referenceCountLabel = selectedReferenceFrames.length
|
const referenceCountLabel = selectedReferenceFrames.length
|
||||||
? `使用已选 ${selectedReferenceFrames.length} 张`
|
? `已选 ${selectedReferenceFrames.length} 张参考帧`
|
||||||
: frames.length
|
: frames.length
|
||||||
? `默认使用全部 ${frames.length} 张`
|
? `默认使用全部 ${frames.length} 张参考帧`
|
||||||
: "待抽帧"
|
: "待抽帧"
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2028,22 +2186,6 @@ function SourceReferenceBuildPanel({
|
|||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const extractKeyframes = async () => {
|
|
||||||
setExtracting(true)
|
|
||||||
try {
|
|
||||||
for (const frame of job.frames) {
|
|
||||||
if (selectedFrames.has(frame.index)) onToggleFrame(frame.index)
|
|
||||||
}
|
|
||||||
const updated = await analyzeJob(job.id, 12, "motion", "replace", "accurate")
|
|
||||||
onJobUpdate(updated)
|
|
||||||
toast.info("已按动作峰值逻辑重新抽取 12 张参考帧,完成后在这里人工选择主角参考。")
|
|
||||||
} catch (e) {
|
|
||||||
toast.error("12 张关键帧抽取失败:" + (e instanceof Error ? e.message : String(e)))
|
|
||||||
} finally {
|
|
||||||
setExtracting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const generateSimilarActor = async () => {
|
const generateSimilarActor = async () => {
|
||||||
if (!frames.length) {
|
if (!frames.length) {
|
||||||
toast.warning("请先自动抽帧 12 张,或在原版视频上手动补帧。")
|
toast.warning("请先自动抽帧 12 张,或在原版视频上手动补帧。")
|
||||||
@@ -2095,16 +2237,6 @@ function SourceReferenceBuildPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteReferenceFrame = async (idx: number) => {
|
|
||||||
if (!onDeleteFrame) return
|
|
||||||
setDeletingFrame(idx)
|
|
||||||
try {
|
|
||||||
await onDeleteFrame(job.id, idx)
|
|
||||||
} finally {
|
|
||||||
setDeletingFrame(null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const regenerateSubjectAsset = async (asset: SubjectAsset) => {
|
const regenerateSubjectAsset = async (asset: SubjectAsset) => {
|
||||||
if (!actorSource) return
|
if (!actorSource) return
|
||||||
setSubjectAssetBusy(`regen:${asset.id}`)
|
setSubjectAssetBusy(`regen:${asset.id}`)
|
||||||
@@ -2150,99 +2282,15 @@ function SourceReferenceBuildPanel({
|
|||||||
return (
|
return (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<SectionTitle icon={<ImageIcon className="h-4 w-4" />} title="关键帧 / 相似主体" />
|
<SectionTitle icon={<Sparkles className="h-4 w-4" />} title="相似主体 / 主体模板" />
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[11px] text-white/45">
|
<span className="rounded-md border border-white/10 bg-black/35 px-2 py-1 text-[11px] text-white/45">
|
||||||
{referenceCountLabel}
|
{referenceCountLabel}
|
||||||
</span>
|
</span>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void extractKeyframes()}
|
|
||||||
disabled={!job.video_url || extracting || job.status === "splitting"}
|
|
||||||
title="自动按动作峰值抽 12 张参考帧,更偏向手势、表情变化、节奏点和镜头变化"
|
|
||||||
className="inline-flex h-8 items-center justify-center gap-1 rounded-md bg-white px-3 text-[11px] font-semibold text-black transition hover:bg-white/90 disabled:cursor-not-allowed disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{extracting || job.status === "splitting" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Scissors className="h-3.5 w-3.5" />}
|
|
||||||
自动抽帧 12 张
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-[250px] overflow-y-auto rounded-md border border-white/10 bg-black/32 p-2 2xl:h-[290px]">
|
<div className="rounded-md border border-white/10 bg-black/32 p-2">
|
||||||
<div className="grid gap-2 xl:grid-cols-[minmax(0,1fr)_138px] 2xl:grid-cols-[minmax(0,1fr)_158px]">
|
<div>
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<span className="text-[10.5px] text-white/34">缩略图完整显示,悬停看大图。</span>
|
|
||||||
<span className="text-[10.5px] text-white/30">不勾选则默认用全部帧</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 grid grid-cols-[repeat(auto-fill,minmax(38px,1fr))] gap-1">
|
|
||||||
{frames.map((frame, index) => {
|
|
||||||
const selected = selectedFrames.has(frame.index)
|
|
||||||
return (
|
|
||||||
<MediaAssetTile
|
|
||||||
key={frame.index}
|
|
||||||
src={effectiveFrameUrl(job.id, frame)}
|
|
||||||
alt={`关键帧 ${index + 1}`}
|
|
||||||
label={`参考帧 ${String(index + 1).padStart(2, "0")}`}
|
|
||||||
meta={`${frame.timestamp.toFixed(1)}s`}
|
|
||||||
className="aspect-[9/16]"
|
|
||||||
objectFit="contain"
|
|
||||||
selected={selected}
|
|
||||||
title={`关键帧 ${index + 1} · ${frame.timestamp.toFixed(1)}s`}
|
|
||||||
onClick={() => onToggleFrame(frame.index)}
|
|
||||||
topLeft={<span className="rounded bg-black/72 px-1 font-mono text-[9px] text-white/70">{String(index + 1).padStart(2, "0")}</span>}
|
|
||||||
topRight={<span className="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>}
|
|
||||||
onDelete={onDeleteFrame ? () => void deleteReferenceFrame(frame.index) : undefined}
|
|
||||||
deleting={deletingFrame === frame.index}
|
|
||||||
deleteLabel={`删除关键帧 ${index + 1}`}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{!frames.length && (
|
|
||||||
<div className="col-span-full flex h-[106px] items-center justify-center rounded border border-dashed border-white/12 text-[11px] text-white/34">
|
|
||||||
点击“自动抽帧 12 张”,或在原版视频播放器上用“当前点抽帧”补充人物参考。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside className="min-w-0 rounded-md border border-emerald-300/12 bg-emerald-300/[0.035] p-1.5">
|
|
||||||
<div className="mb-1 flex items-center justify-between gap-1">
|
|
||||||
<span className="text-[10px] font-semibold text-emerald-50/70">已选关键帧</span>
|
|
||||||
<span className="rounded border border-emerald-300/15 bg-black/24 px-1.5 py-0.5 font-mono text-[9px] text-emerald-50/55">{selectedReferenceFrames.length}</span>
|
|
||||||
</div>
|
|
||||||
{selectedReferenceFrames.length ? (
|
|
||||||
<div className="grid max-h-[176px] grid-cols-3 gap-1 overflow-y-auto pr-0.5 2xl:max-h-[210px]">
|
|
||||||
{selectedReferenceFrames.map((frame) => {
|
|
||||||
const order = frames.findIndex((item) => item.index === frame.index)
|
|
||||||
const label = order >= 0 ? String(order + 1).padStart(2, "0") : String(frame.index)
|
|
||||||
return (
|
|
||||||
<MediaAssetTile
|
|
||||||
key={frame.index}
|
|
||||||
src={effectiveFrameUrl(job.id, frame)}
|
|
||||||
alt={`已选关键帧 ${label}`}
|
|
||||||
label={`已选 ${label}`}
|
|
||||||
meta={`${frame.timestamp.toFixed(1)}s`}
|
|
||||||
className="aspect-[9/16]"
|
|
||||||
objectFit="contain"
|
|
||||||
selected
|
|
||||||
title={`点击取消选择 · ${frame.timestamp.toFixed(1)}s`}
|
|
||||||
onClick={() => onToggleFrame(frame.index)}
|
|
||||||
topLeft={<span className="rounded bg-black/72 px-1 font-mono text-[8.5px] text-emerald-100/80">{label}</span>}
|
|
||||||
topRight={<span className="rounded-full bg-black/72 p-0.5"><Check className="h-2.5 w-2.5 text-emerald-200" /></span>}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-[106px] items-center justify-center rounded border border-dashed border-emerald-300/12 px-2 text-center text-[10px] leading-snug text-white/30">
|
|
||||||
左侧点击关键帧后,会直接出现在这里。
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 border-t border-white/8 pt-2">
|
|
||||||
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2 text-[10px] text-white/36">
|
<div className="mb-1.5 flex flex-wrap items-center justify-between gap-2 text-[10px] text-white/36">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>相似主体白底视图</span>
|
<span>相似主体白底视图</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user