Compare commits
2 Commits
77d23a06b3
...
4efb2ce456
| Author | SHA1 | Date | |
|---|---|---|---|
| 4efb2ce456 | |||
| cc12d7c6a7 |
@@ -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 @@
|
||||
## 最近助手会话概览
|
||||
|
||||
- Claude:a9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
|
||||
- Codex:019e4691-7c18-7dc1-ba82-a315eec63163 · 时间未知
|
||||
- Codex:019e4913-d34e-7943-925e-ec1b60ddf937 · 时间未知
|
||||
- Cursor:未找到匹配当前项目的最近会话
|
||||
|
||||
## Claude 最近会话
|
||||
@@ -92,23 +92,51 @@
|
||||
|
||||
## Codex 最近会话
|
||||
|
||||
- Session ID:019e4691-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 ID:019e4913-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
|
||||
|
||||
|
||||
4439
.memory/worklog.json
4439
.memory/worklog.json
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -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"
|
||||
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>
|
||||
</>
|
||||
)
|
||||
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" />
|
||||
) : 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>
|
||||
{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">
|
||||
<Play className="h-3 w-3" />
|
||||
预览片段
|
||||
</a>
|
||||
{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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user