2 Commits

Author SHA1 Message Date
4efb2ce456 fix: make storyboard video clicks previewable 2026-05-21 17:29:16 +08:00
cc12d7c6a7 auto-save 2026-05-21 17:16 (~2) 2026-05-21 17:16:54 +08:00
5 changed files with 2344 additions and 2295 deletions

View File

@@ -1,6 +1,6 @@
# 项目接力
- 生成时间May 21, 2026 at 13:48
- 生成时间May 21, 2026 at 17:15
- 项目SKG Marketing Studio / SKG 营销内容工作台
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 状态active
@@ -9,7 +9,7 @@
## 最近助手会话概览
- Claudea9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
- Codex019e4691-7c18-7dc1-ba82-a315eec63163 · 时间未知
- Codex019e4913-d34e-7943-925e-ec1b60ddf937 · 时间未知
- Cursor未找到匹配当前项目的最近会话
## Claude 最近会话
@@ -92,23 +92,51 @@
## Codex 最近会话
- Session ID019e4691-7c18-7dc1-ba82-a315eec63163
- Transcript/Users/kangwan/.codex/sessions/2026/05/21/rollout-2026-05-21T02-06-40-019e4691-7c18-7dc1-ba82-a315eec63163.jsonl
- Session ID019e4913-d34e-7943-925e-ec1b60ddf937
- Transcript/Users/kangwan/.codex/sessions/2026/05/21/rollout-2026-05-21T13-48-16-019e4913-d34e-7943-925e-ec1b60ddf937.jsonl
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 分支main
- 敏感字段:已对 token / key / password / secret 做脱敏
### 最近用户要求
- 不能直接在服务器上的dock二
- 弄么
- OK 按照你的来
- 那我现在直接给你个服务器你来弄好api什么的都配好公司的 界面设计也弄好 你能行么
- 上来就是1分钟的 没事
- https://www.figma.com/design/petlyV2zio31BdZghC1alb/iOS-15-UI-Kit-for-Figma--Community-?node-id=5-54&p=f&t=l1NYiFeSZNk5bWtp-0 设计都在这里找ssh root@2.24.28.41 根密码应该可以在记忆里看到
### Codex 最近回复
> 代码正在同步到服务器 `/tmp/skg-marketing-staging-20260521134626`,同步过程排除了生产数据、密钥、本地环境文件和构建产物。这个 staging 目录会用自己的容器名和网络,不会接管生产域名
> 推送第一次被沙箱拦住了,我按规则用提升权限重试 `git push origin main`。`.memory/worklog.json` 又被会话心跳改了一次,这类辅助日志我先不再反复追提交,避免变成无限心跳提交
> 同步完成。接下来我会在 staging 目录里复制服务器已有的生产环境变量文件,但只用于临时容器;同时生成一份临时 compose去掉 Traefik 标签和生产容器名,改成只监听服务器本机 `127.0.0.1:18080`。
> 已上线。页面我已经打开了,公网入口是:
>
> `http://2.24.28.41:4290/agent/`
>
> 现在流程是你要的极简形态:左侧只放 TikTok 链接和产品图上传,中间是 Agent Terminal 实时日志,右侧是成片播放器、分镜 contact sheet 和下载入口。后端不是“裸 Codex 乱跑”,而是 `AgentRun` 状态机下载、素材准备、12 段 x 5 秒规划、生成、失败重试、审片、合成,最终约 1 分钟。
>
> 已验证:
> - 远端 `skg-agent-api` / `skg-agent-web` 容器正常运行
> - 未登录 `/agent/` 正确跳 `/login/`
> - 登录后 `/agent/` 返回 200
> - `/api/agent-runs` 返回数组
> - 容器内 `/health` 为 `ok:true` 且 `auth_configured:true`
> - 本地 `python3 -m py_compile api/main.py` 通过
> - 本地 `web/npm run build` 通过
>
> 已推送到 Gitea`77d23a0 feat: add one-click agent cut terminal`
>
> 最后状态:`git status -sb` 显示 `main...origin/main` 已同步;只剩 `.memory/worklog.json` 被会话心跳再次改动,我没有继续追提交这个自动日志,避免无限心跳提交。
>
> 我没有额外代你点一次真实 1 分钟生成,因为那会立刻消耗视频 API现在页面已经可以你上传产品图后直接触发。
>
> <oai-mem-citation>
> <citation_entries>
> MEMORY.md:72-85|note=[used host context pointer for 2.24.28.41]
> </citation_entries>
> <rollout_ids>
> 019e44c6-d8fe-7090-949d-f57d527a6f36
> </rollout_ids>
> </…
## Cursor 最近会话
@@ -117,8 +145,8 @@
## 当前仓库状态
- 当前分支main
- 未提交变更:8
- 最近提交:docs: record image timeout deployment
- 未提交变更:1
- 最近提交:feat: add one-click agent cut terminal
- 变更文件:
- M .memory/worklog.json

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
import { type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom"
import {
AlertTriangle, BookOpen, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2, Minus,
AlertTriangle, BookOpen, Check, ChevronDown, Circle, Download, Film, FileText, Image as ImageIcon, Info, Link2, Loader2, Minus,
MessageSquare, Mic, Moon, Package, PanelRight, Play, Plus, RefreshCw, Scissors, Send, Sparkles, Sun, Trash2, Upload, Wand2,
} from "lucide-react"
import { toast } from "sonner"
@@ -863,6 +863,17 @@ function videoSrc(video: GeneratedVideo) {
return apiAssetUrl(video.url)
}
function downloadMedia(url: string, filename: string) {
if (!url || typeof document === "undefined") return
const link = document.createElement("a")
link.href = url
link.download = filename
link.rel = "noreferrer"
document.body.appendChild(link)
link.click()
link.remove()
}
function audioPreview(job: Job | null) {
if (!job) return "粘贴 TK 链接或上传视频后,系统会先下载视频;下载完成后自动提取音频文案。"
const source = job.audio_script?.source_text?.trim() || job.audio_script?.source_zh?.trim()
@@ -5584,23 +5595,6 @@ function AudioStoryboardPlanPanel({
}
}
const selectVideoForRow = async (row: AudioStoryboardRow, frame: KeyFrame | null, videoId: string) => {
if (!job || !frame) return
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) }
try {
const legacyRowIndex = legacyRowIndexForFrame(frame.index)
const savedSceneForRow = storyboardSceneBelongsToRow(frame.storyboard, row.index, legacyRowIndex)
? frame.storyboard
: null
const scene = buildSceneForPlannedRow(plannedRow, frame, savedSceneForRow, videoId)
const updated = await updateStoryboard(job.id, frame.index, scene)
onJobUpdate?.(updated)
toast.success(`分镜 ${row.index + 1} 已选用该视频`)
} catch (e) {
toast.error("选用视频失败:" + (e instanceof Error ? e.message : String(e)))
}
}
const clearVideosForRow = (videos: GeneratedVideo[]) => {
if (!videos.length) return
for (const video of videos) onDeleteVideo?.(video.id)
@@ -6468,7 +6462,6 @@ function AudioStoryboardPlanPanel({
job={job}
videos={rowVideos}
enabled={!!referenceFrame}
selectedVideoId={selectedVideoIdForRow(row, referenceFrame)}
busy={quickVideoBusyRow === row.index}
count={rowVideoCount}
onCountChange={(count) => patchRowVideoCount(row.index, count)}
@@ -6476,7 +6469,6 @@ function AudioStoryboardPlanPanel({
onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)}
onClear={() => clearVideosForRow(rowVideos)}
onSelect={(videoId) => void selectVideoForRow(plannedRow, referenceFrame, videoId)}
onDeleteVideo={onDeleteVideo}
/>
</div>
@@ -6697,7 +6689,6 @@ function AudioStoryboardPlanPanel({
videos={rowVideos}
enabled={!!referenceFrame}
expanded={videosOpen}
selectedVideoId={selectedVideoIdForRow(row, referenceFrame)}
busy={quickVideoBusyRow === row.index}
count={rowVideoCount}
onCountChange={(count) => patchRowVideoCount(row.index, count)}
@@ -6706,7 +6697,6 @@ function AudioStoryboardPlanPanel({
onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)}
onClear={() => clearVideosForRow(rowVideos)}
onSelect={(videoId) => void selectVideoForRow(plannedRow, referenceFrame, videoId)}
onDeleteVideo={onDeleteVideo}
/>
<div className="mt-1 flex items-center justify-between gap-2">
@@ -7062,7 +7052,6 @@ function StoryboardVideoSlots({
job,
videos,
enabled,
selectedVideoId = "",
busy = false,
count = 4,
onCountChange,
@@ -7070,14 +7059,12 @@ function StoryboardVideoSlots({
onReroll,
onRegenerate,
onClear,
onSelect,
onDeleteVideo,
}: {
job: Job
videos: GeneratedVideo[]
enabled: boolean
expanded?: boolean
selectedVideoId?: string
busy?: boolean
count?: number
onCountChange?: (count: number) => void
@@ -7086,12 +7073,10 @@ function StoryboardVideoSlots({
onReroll?: () => void
onRegenerate?: () => void
onClear?: () => void
onSelect?: (videoId: string) => void
onDeleteVideo?: (videoId: string) => void
}) {
const visible = videos
const runningCount = videos.filter((video) => video.status === "queued" || video.status === "in_progress").length
const selectedVideo = selectedVideoId ? videos.find((video) => video.id === selectedVideoId) : null
const targetCount = clampVideoCount(count)
const emptyCount = visible.length ? 0 : Math.max(1, targetCount)
return (
@@ -7103,7 +7088,6 @@ function StoryboardVideoSlots({
<span className="shrink-0 text-[10px] text-white/34">
{videos.length ? `${videos.length}${runningCount ? ` · ${runningCount} 生成中` : ""}` : enabled ? "待生成" : "待抽帧"}
</span>
{selectedVideo ? <span className="rounded border border-emerald-300/20 bg-emerald-300/[0.08] px-1.5 py-0.5 text-[10px] text-emerald-100/72"> {shortId(selectedVideo.id)}</span> : null}
</div>
<div className="flex flex-wrap items-center gap-1.5">
<label className="inline-flex h-7 items-center gap-1 rounded-md border border-white/10 bg-black/36 px-1.5 text-[10px] font-semibold text-white/48">
@@ -7146,9 +7130,7 @@ function StoryboardVideoSlots({
key={video.id}
job={job}
video={video}
selected={selectedVideoId === video.id}
className="h-[168px] w-[94px]"
onSelect={onSelect ? () => onSelect(video.id) : undefined}
onRegenerate={onRegenerate}
onDelete={onDeleteVideo ? () => onDeleteVideo(video.id) : undefined}
/>
@@ -7265,40 +7247,50 @@ function StoryboardVideoPreview({
job,
video,
className = "h-20 w-12",
selected = false,
onSelect,
onRegenerate,
onDelete,
}: {
job: Job
video: GeneratedVideo
className?: string
selected?: boolean
onSelect?: () => void
onRegenerate?: () => void
onDelete?: () => void
}) {
const src = videoSrc(video)
const playableSrc = src && video.status === "completed" ? src : ""
const poster = videoPoster(job, video)
const running = video.status === "queued" || video.status === "in_progress"
return (
<MediaAssetTile
kind="video"
src={src && video.status === "completed" ? src : undefined}
src={playableSrc || undefined}
poster={poster}
href={onSelect ? undefined : src || undefined}
href={playableSrc || undefined}
alt={`片段 ${shortId(video.id)}`}
label={`${shortId(video.id)} · ${video.model}`}
meta={video.status}
className={`shrink-0 bg-black/45 ${className}`}
objectFit="cover"
selected={selected}
onClick={onSelect}
title={`${video.model} · ${video.status}`}
title={playableSrc ? "点击打开视频预览" : `${video.model} · ${video.status}`}
bottom={<span className="block truncate rounded bg-black/70 px-1 py-0.5 text-center font-mono text-[9px] text-white/62">{running ? "生成中" : video.status === "failed" ? "失败" : shortId(video.id)}</span>}
topLeft={selected ? <span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-emerald-400 text-black"><Check className="h-3 w-3" /></span> : undefined}
topRight={running ? <Loader2 className="h-3 w-3 animate-spin text-cyan-100" /> : undefined}
actions={onRegenerate ? [{ key: "regen", label: "重生一个候选", icon: <RefreshCw className="h-3 w-3" />, onClick: onRegenerate, tone: "cyan" }] : []}
actions={[
...(playableSrc ? [{
key: "download",
label: "下载视频",
icon: <Download className="h-3 w-3" />,
onClick: () => downloadMedia(playableSrc, `skg-storyboard-${shortId(video.id)}.mp4`),
tone: "cyan" as const,
}] : []),
...(onRegenerate ? [{
key: "regen",
label: "重生一个候选",
icon: <RefreshCw className="h-3 w-3" />,
onClick: onRegenerate,
tone: "neutral" as const,
}] : []),
]}
actionsAlwaysVisible={!!playableSrc}
onDelete={onDelete}
deleteLabel="删除这个视频候选"
/>
@@ -8177,19 +8169,31 @@ function VideoCandidate({
const src = videoSrc(video)
const poster = videoPoster(job, video)
const running = video.status === "queued" || video.status === "in_progress"
return (
<div className={`rounded-lg border p-2 transition ${selected ? "border-rose-400/70 bg-rose-500/10" : "border-white/10 bg-black/30"}`}>
<div className="flex gap-2">
<button type="button" onClick={onToggle} className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black">
{src && video.status === "completed" ? (
<video src={src} poster={poster} muted playsInline className="h-full w-full object-cover" />
const playableSrc = src && video.status === "completed" ? src : ""
const thumb = (
<>
{playableSrc ? (
<video src={playableSrc} poster={poster} muted playsInline className="h-full w-full object-cover" />
) : poster ? (
<img src={poster} alt={`片段 ${shortId(video.id)}`} className="h-full w-full object-cover opacity-80" />
) : (
<div className="flex h-full w-full items-center justify-center text-white/30"><Film className="h-4 w-4" /></div>
)}
<div className="absolute right-1 top-1 rounded-full bg-black/70 p-0.5">{selected ? <Check className="h-3 w-3 text-rose-200" /> : <Circle className="h-3 w-3 text-white/55" />}</div>
</button>
</>
)
return (
<div className={`rounded-lg border p-2 transition ${selected ? "border-rose-400/70 bg-rose-500/10" : "border-white/10 bg-black/30"}`}>
<div className="flex gap-2">
{playableSrc ? (
<a href={playableSrc} target="_blank" rel="noreferrer" className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black" title="打开视频预览">
{thumb}
</a>
) : (
<div className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black">
{thumb}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="truncate font-mono text-[12px] text-white/80">{shortId(video.id)} · {video.model}</div>
@@ -8204,11 +8208,17 @@ function VideoCandidate({
<span>{video.progress}%</span>
</div>
{video.error && <div className="mt-1 line-clamp-2 text-[11px] text-rose-200/80">{video.error}</div>}
{src && video.status === "completed" && (
<a href={src} target="_blank" rel="noreferrer" className="mt-2 inline-flex items-center gap-1 text-[11px] font-medium text-cyan-200 hover:text-cyan-100">
{playableSrc && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<a href={playableSrc} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[11px] font-medium text-cyan-200 hover:text-cyan-100">
<Play className="h-3 w-3" />
</a>
<a href={playableSrc} download={`skg-storyboard-${shortId(video.id)}.mp4`} className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-200 hover:text-emerald-100">
<Download className="h-3 w-3" />
</a>
</div>
)}
</div>
</div>

View File

@@ -46,6 +46,7 @@ type MediaAssetTileProps = {
deleting?: boolean
deleteDisabled?: boolean
actions?: MediaAssetAction[]
actionsAlwaysVisible?: boolean
disablePreview?: boolean
}
@@ -106,6 +107,7 @@ export function MediaAssetTile({
deleting = false,
deleteDisabled = false,
actions = [],
actionsAlwaysVisible = false,
disablePreview = false,
}: MediaAssetTileProps) {
const [position, setPosition] = useState<{ left: number; top: number; width: number } | null>(null)
@@ -200,7 +202,7 @@ export function MediaAssetTile({
{topRight ? <div className="pointer-events-none absolute right-1 top-1 z-10">{topRight}</div> : null}
{bottom ? <div className="pointer-events-none absolute bottom-1 left-1 right-1 z-10">{bottom}</div> : null}
{(actions.length || onDelete) ? (
<div className="absolute right-1 top-1 z-20 flex flex-col gap-0.5 opacity-0 transition group-hover:opacity-100 group-focus-within:opacity-100">
<div className={`absolute right-1 top-1 z-20 flex flex-col gap-0.5 transition ${actionsAlwaysVisible ? "opacity-100" : "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"}`}>
{actions.map((action) => (
<button
key={action.key}