feat: add source workspace layout tuning
This commit is contained in:
@@ -4,7 +4,7 @@ import { type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, t
|
||||
import { createPortal } from "react-dom"
|
||||
import {
|
||||
AlertTriangle, BookOpen, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2, Minus,
|
||||
MessageSquare, Mic, Moon, Package, PanelRight, Play, Plus, RefreshCw, Scissors, Send, Sparkles, Sun, Trash2, Upload, Wand2,
|
||||
MessageSquare, Mic, Moon, Package, PanelRight, Play, Plus, RefreshCw, RotateCcw, Scissors, Send, SlidersHorizontal, Sparkles, Sun, Trash2, Upload, Wand2,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
@@ -108,6 +108,34 @@ const BOARD_FRAME_HEIGHT = 1000
|
||||
const BOARD_MIN_SCALE = 0.72
|
||||
const BOARD_MAX_SCALE = 1.6
|
||||
const BOARD_SCALE_PRESETS = [0.72, 0.76, 0.8, 0.86, 0.92, 1, 1.06, 1.16, 1.24, 1.34, 1.48, 1.6]
|
||||
const SOURCE_WORKSPACE_LAYOUT_STORAGE_KEY = "skg-source-workspace-layout:v1"
|
||||
|
||||
type SourceWorkspaceLayout = {
|
||||
leftWidth: number
|
||||
videoHeight: number
|
||||
transcriptHeight: number
|
||||
referenceWidth: number
|
||||
conversionHeight: number
|
||||
subjectEmptyHeight: number
|
||||
}
|
||||
|
||||
const DEFAULT_SOURCE_WORKSPACE_LAYOUT: SourceWorkspaceLayout = {
|
||||
leftWidth: 360,
|
||||
videoHeight: 510,
|
||||
transcriptHeight: 260,
|
||||
referenceWidth: 140,
|
||||
conversionHeight: 500,
|
||||
subjectEmptyHeight: 78,
|
||||
}
|
||||
|
||||
const SOURCE_WORKSPACE_LAYOUT_LIMITS: Record<keyof SourceWorkspaceLayout, { min: number; max: number; step: number; label: string; suffix: string }> = {
|
||||
leftWidth: { min: 320, max: 460, step: 10, label: "左列宽", suffix: "px" },
|
||||
videoHeight: { min: 430, max: 560, step: 10, label: "视频高", suffix: "px" },
|
||||
transcriptHeight: { min: 180, max: 360, step: 10, label: "时间轴高", suffix: "px" },
|
||||
referenceWidth: { min: 118, max: 180, step: 2, label: "参考池宽", suffix: "px" },
|
||||
conversionHeight: { min: 420, max: 640, step: 10, label: "转换层高", suffix: "px" },
|
||||
subjectEmptyHeight: { min: 56, max: 140, step: 4, label: "主体空态", suffix: "px" },
|
||||
}
|
||||
|
||||
const resolveBoardScale = (viewportWidth: number) => {
|
||||
const maxFitScale = clampNumber(viewportWidth / BOARD_FRAME_WIDTH, BOARD_MIN_SCALE, BOARD_MAX_SCALE)
|
||||
@@ -718,6 +746,25 @@ function clampNumber(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function normalizeSourceWorkspaceLayout(value: Partial<SourceWorkspaceLayout> = {}): SourceWorkspaceLayout {
|
||||
const next = { ...DEFAULT_SOURCE_WORKSPACE_LAYOUT, ...value }
|
||||
return Object.fromEntries(
|
||||
(Object.keys(DEFAULT_SOURCE_WORKSPACE_LAYOUT) as Array<keyof SourceWorkspaceLayout>).map((key) => {
|
||||
const limits = SOURCE_WORKSPACE_LAYOUT_LIMITS[key]
|
||||
return [key, clampNumber(Number(next[key]) || DEFAULT_SOURCE_WORKSPACE_LAYOUT[key], limits.min, limits.max)]
|
||||
}),
|
||||
) as SourceWorkspaceLayout
|
||||
}
|
||||
|
||||
function loadSourceWorkspaceLayout() {
|
||||
if (typeof window === "undefined") return DEFAULT_SOURCE_WORKSPACE_LAYOUT
|
||||
try {
|
||||
return normalizeSourceWorkspaceLayout(JSON.parse(window.localStorage.getItem(SOURCE_WORKSPACE_LAYOUT_STORAGE_KEY) || "{}"))
|
||||
} catch {
|
||||
return DEFAULT_SOURCE_WORKSPACE_LAYOUT
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeAudioFeatures(url: string, targetFrames = 640): Promise<AudioFeature[]> {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`audio ${res.status}`)
|
||||
@@ -2729,6 +2776,62 @@ function MaterialColumn({
|
||||
)
|
||||
}
|
||||
|
||||
function SourceWorkspaceLayoutPanel({
|
||||
layout,
|
||||
onChange,
|
||||
onReset,
|
||||
}: {
|
||||
layout: SourceWorkspaceLayout
|
||||
onChange: (layout: SourceWorkspaceLayout) => void
|
||||
onReset: () => void
|
||||
}) {
|
||||
const update = (key: keyof SourceWorkspaceLayout, value: number) => {
|
||||
onChange(normalizeSourceWorkspaceLayout({ ...layout, [key]: value }))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-2 rounded-md border border-[#d6b36a]/24 bg-[#d6b36a]/[0.055] p-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-[10.5px] font-semibold text-[#f4dc88]">临时布局调节</div>
|
||||
<div className="mt-0.5 text-[9px] text-white/36">只保存在当前浏览器;你调准后再固化默认值。</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="inline-flex h-7 items-center gap-1 rounded-md border border-white/10 bg-black/28 px-2 text-[10px] font-semibold text-white/48 transition hover:border-white/24 hover:text-white/76"
|
||||
title="恢复推荐参数"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Object.keys(SOURCE_WORKSPACE_LAYOUT_LIMITS) as Array<keyof SourceWorkspaceLayout>).map((key) => {
|
||||
const item = SOURCE_WORKSPACE_LAYOUT_LIMITS[key]
|
||||
return (
|
||||
<label key={key} className="min-w-0 rounded border border-white/8 bg-black/18 px-2 py-1.5">
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="text-[9.5px] font-semibold text-white/56">{item.label}</span>
|
||||
<span className="font-mono text-[9px] text-white/34">{layout[key]}{item.suffix}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={item.min}
|
||||
max={item.max}
|
||||
step={item.step}
|
||||
value={layout[key]}
|
||||
onChange={(event) => update(key, Number(event.target.value))}
|
||||
className="h-4 w-full accent-[#d6b36a]"
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AudioIntakePanel({
|
||||
job,
|
||||
selectedFrames,
|
||||
@@ -2759,6 +2862,8 @@ function AudioIntakePanel({
|
||||
const [filmstripStatus, setFilmstripStatus] = useState<FilmstripStatus>("idle")
|
||||
const [filmstripDragTime, setFilmstripDragTime] = useState<number | null>(null)
|
||||
const [filmstripBusyTime, setFilmstripBusyTime] = useState<number | null>(null)
|
||||
const [layoutOpen, setLayoutOpen] = useState(false)
|
||||
const [workspaceLayout, setWorkspaceLayout] = useState<SourceWorkspaceLayout>(() => loadSourceWorkspaceLayout())
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const transcriptScrollRef = useRef<HTMLDivElement | null>(null)
|
||||
const rowRefs = useRef<Record<number, HTMLDivElement | null>>({})
|
||||
@@ -2784,6 +2889,12 @@ function AudioIntakePanel({
|
||||
? `当前句 ${activeSegment.start.toFixed(1)}-${activeSegment.end.toFixed(1)}s`
|
||||
: "指针 -"
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(SOURCE_WORKSPACE_LAYOUT_STORAGE_KEY, JSON.stringify(workspaceLayout))
|
||||
} catch { /* ignore unavailable storage */ }
|
||||
}, [workspaceLayout])
|
||||
|
||||
useEffect(() => {
|
||||
if (!job?.id || !audioSrcUrl) {
|
||||
setAudioFeatures([])
|
||||
@@ -2959,21 +3070,49 @@ function AudioIntakePanel({
|
||||
<section className="rounded-lg border border-white/10 bg-black/28 p-2.5">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<SectionTitle icon={<Film className="h-4 w-4" />} title="源视频工作区" />
|
||||
<div className="flex items-center gap-2 font-mono text-[11px] text-white/38">
|
||||
<span>{job.transcript.length} 段</span>
|
||||
<span>{formatSeconds(job.duration)}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 font-mono text-[11px] text-white/38">
|
||||
<span>{job.transcript.length} 段</span>
|
||||
<span>{formatSeconds(job.duration)}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLayoutOpen((open) => !open)}
|
||||
className={`inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-[10px] font-semibold transition ${
|
||||
layoutOpen
|
||||
? "border-[#d6b36a]/55 bg-[#d6b36a]/13 text-[#f4dc88]"
|
||||
: "border-white/10 bg-black/24 text-white/42 hover:border-white/24 hover:text-white/70"
|
||||
}`}
|
||||
title="临时调节源视频工作区布局"
|
||||
>
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||||
布局调节
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 border-t border-white/8 pt-2">
|
||||
{layoutOpen ? (
|
||||
<SourceWorkspaceLayoutPanel
|
||||
layout={workspaceLayout}
|
||||
onChange={setWorkspaceLayout}
|
||||
onReset={() => setWorkspaceLayout(DEFAULT_SOURCE_WORKSPACE_LAYOUT)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="grid gap-2">
|
||||
<div className="grid grid-cols-[460px_minmax(0,1fr)] gap-3">
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{ gridTemplateColumns: `${workspaceLayout.leftWidth}px minmax(0,1fr)` }}
|
||||
>
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<SectionTitle icon={<Play className="h-4 w-4" />} title="原版视频" />
|
||||
<span className="font-mono text-[11px] text-white/38">{currentTime.toFixed(1)}s</span>
|
||||
</div>
|
||||
<div className="relative mx-auto aspect-[9/16] h-[510px] overflow-hidden rounded-md border border-white/10 bg-black">
|
||||
<div
|
||||
className="relative mx-auto aspect-[9/16] overflow-hidden rounded-md border border-white/10 bg-black"
|
||||
style={{ height: workspaceLayout.videoHeight }}
|
||||
>
|
||||
{job.video_url ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
@@ -3012,6 +3151,7 @@ function AudioIntakePanel({
|
||||
activeSegmentIndex={activeSegment?.index ?? null}
|
||||
scrollRef={transcriptScrollRef}
|
||||
rowRefs={rowRefs}
|
||||
maxHeight={workspaceLayout.transcriptHeight}
|
||||
onSeek={seekTo}
|
||||
/>
|
||||
</div>
|
||||
@@ -3069,6 +3209,7 @@ function AudioIntakePanel({
|
||||
runtimeModels={runtimeModels}
|
||||
filmstripDragging={filmstripDragTime !== null}
|
||||
onDropFilmstripFrame={(time) => addFilmstripFrame(time)}
|
||||
layout={workspaceLayout}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3085,6 +3226,7 @@ function TranscriptTimelinePanel({
|
||||
activeSegmentIndex,
|
||||
scrollRef,
|
||||
rowRefs,
|
||||
maxHeight,
|
||||
onSeek,
|
||||
}: {
|
||||
job: Job
|
||||
@@ -3092,6 +3234,7 @@ function TranscriptTimelinePanel({
|
||||
activeSegmentIndex: number | null
|
||||
scrollRef: RefObject<HTMLDivElement | null>
|
||||
rowRefs: { current: Record<number, HTMLDivElement | null> }
|
||||
maxHeight: number
|
||||
onSeek: (time: number) => void
|
||||
}) {
|
||||
return (
|
||||
@@ -3106,7 +3249,7 @@ function TranscriptTimelinePanel({
|
||||
<div>时间</div>
|
||||
<div>原文 / 中文</div>
|
||||
</div>
|
||||
<div ref={scrollRef} className="max-h-[306px] overflow-y-auto">
|
||||
<div ref={scrollRef} className="overflow-y-auto" style={{ maxHeight }}>
|
||||
{job.transcript.map((segment) => {
|
||||
const active = activeSegmentIndex === segment.index
|
||||
return (
|
||||
@@ -3364,6 +3507,7 @@ function SourceSubjectPipeline({
|
||||
runtimeModels,
|
||||
filmstripDragging,
|
||||
onDropFilmstripFrame,
|
||||
layout,
|
||||
}: {
|
||||
job: Job
|
||||
frames: KeyFrame[]
|
||||
@@ -3377,6 +3521,7 @@ function SourceSubjectPipeline({
|
||||
runtimeModels?: RuntimeModels
|
||||
filmstripDragging?: boolean
|
||||
onDropFilmstripFrame?: (time: number) => Promise<KeyFrame | null> | KeyFrame | null | void
|
||||
layout: SourceWorkspaceLayout
|
||||
}) {
|
||||
const [referenceDropActive, setReferenceDropActive] = useState(false)
|
||||
const [agentDropActive, setAgentDropActive] = useState(false)
|
||||
@@ -3939,7 +4084,10 @@ function SourceSubjectPipeline({
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-[150px_minmax(0,1fr)] gap-2">
|
||||
<div
|
||||
className="grid gap-2"
|
||||
style={{ gridTemplateColumns: `${layout.referenceWidth}px minmax(0,1fr)` }}
|
||||
>
|
||||
<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="参考帧池" />
|
||||
@@ -3990,7 +4138,7 @@ function SourceSubjectPipeline({
|
||||
<span>{frames.length} 张</span>
|
||||
<span>{filmstripDragging ? "松手加入" : "点击选择"}</span>
|
||||
</div>
|
||||
<div className="flex max-h-[500px] flex-col gap-1 overflow-y-auto pr-0.5">
|
||||
<div className="flex flex-col gap-1 overflow-y-auto pr-0.5" style={{ maxHeight: layout.conversionHeight }}>
|
||||
{frames.map((frame, index) => {
|
||||
const selected = selectedFrames.has(frame.index)
|
||||
return (
|
||||
@@ -4055,7 +4203,10 @@ function SourceSubjectPipeline({
|
||||
{agentReferenceFrames.length ? `${agentReferenceFrames.length}/${RECONSTRUCTION_FRAME_LIMIT} 图` : "待选图"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-h-[500px] flex-col rounded-md border border-white/10 bg-black/24 p-2">
|
||||
<div
|
||||
className="flex flex-col overflow-y-auto rounded-md border border-white/10 bg-black/24 p-2"
|
||||
style={{ height: layout.conversionHeight }}
|
||||
>
|
||||
<div className="mb-2 grid grid-cols-2 gap-1.5">
|
||||
{SUBJECT_MODEL_BUNDLE_OPTIONS.map((option) => (
|
||||
<button
|
||||
@@ -4420,7 +4571,10 @@ function SourceSubjectPipeline({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-20 items-center justify-center rounded border border-dashed border-white/12 px-3 text-center text-[10.5px] leading-snug text-white/34">
|
||||
<div
|
||||
className="flex items-center justify-center rounded border border-dashed border-white/12 px-3 text-center text-[10.5px] leading-snug text-white/34"
|
||||
style={{ minHeight: layout.subjectEmptyHeight }}
|
||||
>
|
||||
转换层生成完成后,这里会横向展示主体套图。
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user