2 Commits

Author SHA1 Message Date
41e71d3865 docs: record fixed source workspace deployment 2026-05-20 21:33:53 +08:00
caa7b730a6 fix: remove source workspace layout tuning 2026-05-20 21:27:19 +08:00
3 changed files with 32 additions and 153 deletions

View File

@@ -16,6 +16,7 @@
## 部署事实
- 平台VPS `76.13.31.179`Ubuntu 24.04 / Docker Compose / Coolify Traefik
- 发布状态已部署并验证2026-05-20主体元素按套图文件夹分组展示主体生成接口提交后立即返回 queued 占位并后台逐视角生成、逐张回填;源视频工作区主体链路为上方竖向参考帧池 + 宽幅对话式转换层、下方主体元素结果栏;转换层通过参考帧 `+` 加入、参考图分析、生图对话、英文 prompt 弹窗确认后才触发主体套图生成;下方主体元素结果栏的套图输出、轮询、文件夹分组、单张重生、删除和 hover 预览逻辑保持不变;胶片双击/拖拽加入参考帧池 + 胶片缓存复用 + 音频解析失败可重试,参考帧缩略图保持小尺寸 9:16 比例 + hover 左侧紧凑预览,旧主体模板区移出主路径 + 逐句时间轴移到原版视频下方并支持双行文案 + 波形同框时间对齐画面胶片 + 胶片密度按钮上移波形顶部 + 去分隔线 + 胶片上下错落 + body 顶层原位大放大 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401认证后首页 200容器内 `/health` 返回 `ok:true`
- 最近部署验证2026-05-20`caa7b73` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520132820.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后 Playwright 以 2048x1060 复测:页面无客户端异常,源视频工作区已撤销“布局调节”按钮和 `localStorage["skg-source-workspace-layout:v1"]` 布局读写,固定为左侧原视频列 380px、9:16 视频高 500px、逐句时间轴最大高 270px、参考帧池 140px、转换层 500px 内部滚动、主体空态 78px验证截图 `/tmp/skg-layout-fixed-no-tuning.png`
- 最近部署验证2026-05-20`0db265f` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520131649.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后 Playwright 打开历史任务并展开“布局调节”:面板显示左列宽、视频高、时间轴高、参考池宽、转换层高、主体空态 6 个滑杆,调参值写入 `localStorage["skg-source-workspace-layout:v1"]`,供用户先在线试比例再固化默认值。
- 最近部署验证2026-05-20`5bffd63` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520123949.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后 Playwright 复测 1440x900 与 2048x1060历史任务加载后转换层占据主操作宽度主体元素下移为转换层下方的紧凑结果栏未再出现右侧三栏挤压滚动到主体元素位置后仍能看到下方分镜工作台承接。
- 最近部署验证2026-05-20`f0f567b` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520120958.tgz`,生产 Docker 重建后脚本内 `./scripts/verify-prod-docker.sh` 通过。线上登录后 Playwright 截图复测 1440x900、1728x1117、2048x1060、2560x1440缩放后的工作台在 1440/1728/2560 这类高度有余量的窗口上下居中2048x1060 保持顶部对齐并承接纵向内容,未出现先前的底部黑边失衡。

File diff suppressed because one or more lines are too long

View File

@@ -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, RotateCcw, Scissors, Send, SlidersHorizontal, Sparkles, Sun, Trash2, Upload, Wand2,
MessageSquare, Mic, Moon, Package, PanelRight, Play, Plus, RefreshCw, Scissors, Send, Sparkles, Sun, Trash2, Upload, Wand2,
} from "lucide-react"
import { toast } from "sonner"
import {
@@ -108,34 +108,12 @@ 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 SOURCE_LEFT_COLUMN_WIDTH = 380
const SOURCE_VIDEO_HEIGHT = 500
const SOURCE_TRANSCRIPT_MAX_HEIGHT = 270
const SOURCE_REFERENCE_POOL_WIDTH = 140
const SOURCE_CONVERSION_HEIGHT = 500
const SOURCE_SUBJECT_EMPTY_HEIGHT = 78
const resolveBoardScale = (viewportWidth: number) => {
const maxFitScale = clampNumber(viewportWidth / BOARD_FRAME_WIDTH, BOARD_MIN_SCALE, BOARD_MAX_SCALE)
@@ -746,25 +724,6 @@ 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}`)
@@ -2776,62 +2735,6 @@ 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,
@@ -2862,8 +2765,6 @@ 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>>({})
@@ -2889,12 +2790,6 @@ 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([])
@@ -3070,39 +2965,17 @@ 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">
<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 className="flex items-center gap-2 font-mono text-[11px] text-white/38">
<span>{job.transcript.length} </span>
<span>{formatSeconds(job.duration)}</span>
</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 gap-3"
style={{ gridTemplateColumns: `${workspaceLayout.leftWidth}px minmax(0,1fr)` }}
style={{ gridTemplateColumns: `${SOURCE_LEFT_COLUMN_WIDTH}px minmax(0,1fr)` }}
>
<div className="min-w-0 space-y-2">
<div className="mb-2 flex items-center justify-between gap-3">
@@ -3111,7 +2984,7 @@ function AudioIntakePanel({
</div>
<div
className="relative mx-auto aspect-[9/16] overflow-hidden rounded-md border border-white/10 bg-black"
style={{ height: workspaceLayout.videoHeight }}
style={{ height: SOURCE_VIDEO_HEIGHT }}
>
{job.video_url ? (
<video
@@ -3151,7 +3024,6 @@ function AudioIntakePanel({
activeSegmentIndex={activeSegment?.index ?? null}
scrollRef={transcriptScrollRef}
rowRefs={rowRefs}
maxHeight={workspaceLayout.transcriptHeight}
onSeek={seekTo}
/>
</div>
@@ -3209,7 +3081,6 @@ function AudioIntakePanel({
runtimeModels={runtimeModels}
filmstripDragging={filmstripDragTime !== null}
onDropFilmstripFrame={(time) => addFilmstripFrame(time)}
layout={workspaceLayout}
/>
</div>
</div>
@@ -3226,7 +3097,6 @@ function TranscriptTimelinePanel({
activeSegmentIndex,
scrollRef,
rowRefs,
maxHeight,
onSeek,
}: {
job: Job
@@ -3234,7 +3104,6 @@ function TranscriptTimelinePanel({
activeSegmentIndex: number | null
scrollRef: RefObject<HTMLDivElement | null>
rowRefs: { current: Record<number, HTMLDivElement | null> }
maxHeight: number
onSeek: (time: number) => void
}) {
return (
@@ -3249,7 +3118,7 @@ function TranscriptTimelinePanel({
<div></div>
<div> / </div>
</div>
<div ref={scrollRef} className="overflow-y-auto" style={{ maxHeight }}>
<div ref={scrollRef} className="overflow-y-auto" style={{ maxHeight: SOURCE_TRANSCRIPT_MAX_HEIGHT }}>
{job.transcript.map((segment) => {
const active = activeSegmentIndex === segment.index
return (
@@ -3507,7 +3376,6 @@ function SourceSubjectPipeline({
runtimeModels,
filmstripDragging,
onDropFilmstripFrame,
layout,
}: {
job: Job
frames: KeyFrame[]
@@ -3521,7 +3389,6 @@ 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)
@@ -4086,7 +3953,7 @@ function SourceSubjectPipeline({
<div className="space-y-2">
<div
className="grid gap-2"
style={{ gridTemplateColumns: `${layout.referenceWidth}px minmax(0,1fr)` }}
style={{ gridTemplateColumns: `${SOURCE_REFERENCE_POOL_WIDTH}px minmax(0,1fr)` }}
>
<div className="min-w-0">
<div className="mb-2 flex items-center justify-between gap-2">
@@ -4138,7 +4005,7 @@ function SourceSubjectPipeline({
<span>{frames.length} </span>
<span>{filmstripDragging ? "松手加入" : "点击选择"}</span>
</div>
<div className="flex flex-col gap-1 overflow-y-auto pr-0.5" style={{ maxHeight: layout.conversionHeight }}>
<div className="flex flex-col gap-1 overflow-y-auto pr-0.5" style={{ maxHeight: SOURCE_CONVERSION_HEIGHT }}>
{frames.map((frame, index) => {
const selected = selectedFrames.has(frame.index)
return (
@@ -4205,7 +4072,7 @@ function SourceSubjectPipeline({
</div>
<div
className="flex flex-col overflow-y-auto rounded-md border border-white/10 bg-black/24 p-2"
style={{ height: layout.conversionHeight }}
style={{ height: SOURCE_CONVERSION_HEIGHT }}
>
<div className="mb-2 grid grid-cols-2 gap-1.5">
{SUBJECT_MODEL_BUNDLE_OPTIONS.map((option) => (
@@ -4573,7 +4440,7 @@ function SourceSubjectPipeline({
) : (
<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 }}
style={{ minHeight: SOURCE_SUBJECT_EMPTY_HEIGHT }}
>
</div>