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 营销内容工作台
|
- 项目:SKG Marketing Studio / SKG 营销内容工作台
|
||||||
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||||
- 状态:active
|
- 状态:active
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
## 最近助手会话概览
|
## 最近助手会话概览
|
||||||
|
|
||||||
- Claude:a9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
|
- Claude:a9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
|
||||||
- Codex:019e4691-7c18-7dc1-ba82-a315eec63163 · 时间未知
|
- Codex:019e4913-d34e-7943-925e-ec1b60ddf937 · 时间未知
|
||||||
- Cursor:未找到匹配当前项目的最近会话
|
- Cursor:未找到匹配当前项目的最近会话
|
||||||
|
|
||||||
## Claude 最近会话
|
## Claude 最近会话
|
||||||
@@ -92,23 +92,51 @@
|
|||||||
|
|
||||||
## Codex 最近会话
|
## Codex 最近会话
|
||||||
|
|
||||||
- Session ID:019e4691-7c18-7dc1-ba82-a315eec63163
|
- Session ID:019e4913-d34e-7943-925e-ec1b60ddf937
|
||||||
- Transcript:/Users/kangwan/.codex/sessions/2026/05/21/rollout-2026-05-21T02-06-40-019e4691-7c18-7dc1-ba82-a315eec63163.jsonl
|
- 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-二创验证
|
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||||
- 分支:main
|
- 分支:main
|
||||||
- 敏感字段:已对 token / key / password / secret 做脱敏
|
- 敏感字段:已对 token / key / password / secret 做脱敏
|
||||||
|
|
||||||
### 最近用户要求
|
### 最近用户要求
|
||||||
|
|
||||||
- 不能直接在服务器上的dock二
|
- 那我现在直接给你个服务器,你来弄好,api什么的都配好公司的 界面设计也弄好 你能行么
|
||||||
- 弄么
|
- 上来就是1分钟的 没事
|
||||||
- OK 按照你的来
|
- 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 最近回复
|
### 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 最近会话
|
## Cursor 最近会话
|
||||||
|
|
||||||
@@ -117,8 +145,8 @@
|
|||||||
## 当前仓库状态
|
## 当前仓库状态
|
||||||
|
|
||||||
- 当前分支:main
|
- 当前分支:main
|
||||||
- 未提交变更:8 项
|
- 未提交变更:1 项
|
||||||
- 最近提交:docs: record image timeout deployment
|
- 最近提交:feat: add one-click agent cut terminal
|
||||||
- 变更文件:
|
- 变更文件:
|
||||||
- M .memory/worklog.json
|
- 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 { type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { createPortal } from "react-dom"
|
import { createPortal } from "react-dom"
|
||||||
import {
|
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,
|
MessageSquare, Mic, Moon, Package, PanelRight, Play, Plus, RefreshCw, Scissors, Send, Sparkles, Sun, Trash2, Upload, Wand2,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -863,6 +863,17 @@ function videoSrc(video: GeneratedVideo) {
|
|||||||
return apiAssetUrl(video.url)
|
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) {
|
function audioPreview(job: Job | null) {
|
||||||
if (!job) return "粘贴 TK 链接或上传视频后,系统会先下载视频;下载完成后自动提取音频文案。"
|
if (!job) return "粘贴 TK 链接或上传视频后,系统会先下载视频;下载完成后自动提取音频文案。"
|
||||||
const source = job.audio_script?.source_text?.trim() || job.audio_script?.source_zh?.trim()
|
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[]) => {
|
const clearVideosForRow = (videos: GeneratedVideo[]) => {
|
||||||
if (!videos.length) return
|
if (!videos.length) return
|
||||||
for (const video of videos) onDeleteVideo?.(video.id)
|
for (const video of videos) onDeleteVideo?.(video.id)
|
||||||
@@ -6468,7 +6462,6 @@ function AudioStoryboardPlanPanel({
|
|||||||
job={job}
|
job={job}
|
||||||
videos={rowVideos}
|
videos={rowVideos}
|
||||||
enabled={!!referenceFrame}
|
enabled={!!referenceFrame}
|
||||||
selectedVideoId={selectedVideoIdForRow(row, referenceFrame)}
|
|
||||||
busy={quickVideoBusyRow === row.index}
|
busy={quickVideoBusyRow === row.index}
|
||||||
count={rowVideoCount}
|
count={rowVideoCount}
|
||||||
onCountChange={(count) => patchRowVideoCount(row.index, count)}
|
onCountChange={(count) => patchRowVideoCount(row.index, count)}
|
||||||
@@ -6476,7 +6469,6 @@ function AudioStoryboardPlanPanel({
|
|||||||
onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
|
onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
|
||||||
onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)}
|
onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)}
|
||||||
onClear={() => clearVideosForRow(rowVideos)}
|
onClear={() => clearVideosForRow(rowVideos)}
|
||||||
onSelect={(videoId) => void selectVideoForRow(plannedRow, referenceFrame, videoId)}
|
|
||||||
onDeleteVideo={onDeleteVideo}
|
onDeleteVideo={onDeleteVideo}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -6697,7 +6689,6 @@ function AudioStoryboardPlanPanel({
|
|||||||
videos={rowVideos}
|
videos={rowVideos}
|
||||||
enabled={!!referenceFrame}
|
enabled={!!referenceFrame}
|
||||||
expanded={videosOpen}
|
expanded={videosOpen}
|
||||||
selectedVideoId={selectedVideoIdForRow(row, referenceFrame)}
|
|
||||||
busy={quickVideoBusyRow === row.index}
|
busy={quickVideoBusyRow === row.index}
|
||||||
count={rowVideoCount}
|
count={rowVideoCount}
|
||||||
onCountChange={(count) => patchRowVideoCount(row.index, count)}
|
onCountChange={(count) => patchRowVideoCount(row.index, count)}
|
||||||
@@ -6706,7 +6697,6 @@ function AudioStoryboardPlanPanel({
|
|||||||
onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
|
onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
|
||||||
onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)}
|
onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)}
|
||||||
onClear={() => clearVideosForRow(rowVideos)}
|
onClear={() => clearVideosForRow(rowVideos)}
|
||||||
onSelect={(videoId) => void selectVideoForRow(plannedRow, referenceFrame, videoId)}
|
|
||||||
onDeleteVideo={onDeleteVideo}
|
onDeleteVideo={onDeleteVideo}
|
||||||
/>
|
/>
|
||||||
<div className="mt-1 flex items-center justify-between gap-2">
|
<div className="mt-1 flex items-center justify-between gap-2">
|
||||||
@@ -7062,7 +7052,6 @@ function StoryboardVideoSlots({
|
|||||||
job,
|
job,
|
||||||
videos,
|
videos,
|
||||||
enabled,
|
enabled,
|
||||||
selectedVideoId = "",
|
|
||||||
busy = false,
|
busy = false,
|
||||||
count = 4,
|
count = 4,
|
||||||
onCountChange,
|
onCountChange,
|
||||||
@@ -7070,14 +7059,12 @@ function StoryboardVideoSlots({
|
|||||||
onReroll,
|
onReroll,
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
onClear,
|
onClear,
|
||||||
onSelect,
|
|
||||||
onDeleteVideo,
|
onDeleteVideo,
|
||||||
}: {
|
}: {
|
||||||
job: Job
|
job: Job
|
||||||
videos: GeneratedVideo[]
|
videos: GeneratedVideo[]
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
expanded?: boolean
|
expanded?: boolean
|
||||||
selectedVideoId?: string
|
|
||||||
busy?: boolean
|
busy?: boolean
|
||||||
count?: number
|
count?: number
|
||||||
onCountChange?: (count: number) => void
|
onCountChange?: (count: number) => void
|
||||||
@@ -7086,12 +7073,10 @@ function StoryboardVideoSlots({
|
|||||||
onReroll?: () => void
|
onReroll?: () => void
|
||||||
onRegenerate?: () => void
|
onRegenerate?: () => void
|
||||||
onClear?: () => void
|
onClear?: () => void
|
||||||
onSelect?: (videoId: string) => void
|
|
||||||
onDeleteVideo?: (videoId: string) => void
|
onDeleteVideo?: (videoId: string) => void
|
||||||
}) {
|
}) {
|
||||||
const visible = videos
|
const visible = videos
|
||||||
const runningCount = videos.filter((video) => video.status === "queued" || video.status === "in_progress").length
|
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 targetCount = clampVideoCount(count)
|
||||||
const emptyCount = visible.length ? 0 : Math.max(1, targetCount)
|
const emptyCount = visible.length ? 0 : Math.max(1, targetCount)
|
||||||
return (
|
return (
|
||||||
@@ -7103,7 +7088,6 @@ function StoryboardVideoSlots({
|
|||||||
<span className="shrink-0 text-[10px] text-white/34">
|
<span className="shrink-0 text-[10px] text-white/34">
|
||||||
{videos.length ? `${videos.length} 条${runningCount ? ` · ${runningCount} 生成中` : ""}` : enabled ? "待生成" : "待抽帧"}
|
{videos.length ? `${videos.length} 条${runningCount ? ` · ${runningCount} 生成中` : ""}` : enabled ? "待生成" : "待抽帧"}
|
||||||
</span>
|
</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>
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<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">
|
<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}
|
key={video.id}
|
||||||
job={job}
|
job={job}
|
||||||
video={video}
|
video={video}
|
||||||
selected={selectedVideoId === video.id}
|
|
||||||
className="h-[168px] w-[94px]"
|
className="h-[168px] w-[94px]"
|
||||||
onSelect={onSelect ? () => onSelect(video.id) : undefined}
|
|
||||||
onRegenerate={onRegenerate}
|
onRegenerate={onRegenerate}
|
||||||
onDelete={onDeleteVideo ? () => onDeleteVideo(video.id) : undefined}
|
onDelete={onDeleteVideo ? () => onDeleteVideo(video.id) : undefined}
|
||||||
/>
|
/>
|
||||||
@@ -7265,40 +7247,50 @@ function StoryboardVideoPreview({
|
|||||||
job,
|
job,
|
||||||
video,
|
video,
|
||||||
className = "h-20 w-12",
|
className = "h-20 w-12",
|
||||||
selected = false,
|
|
||||||
onSelect,
|
|
||||||
onRegenerate,
|
onRegenerate,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
job: Job
|
job: Job
|
||||||
video: GeneratedVideo
|
video: GeneratedVideo
|
||||||
className?: string
|
className?: string
|
||||||
selected?: boolean
|
|
||||||
onSelect?: () => void
|
|
||||||
onRegenerate?: () => void
|
onRegenerate?: () => void
|
||||||
onDelete?: () => void
|
onDelete?: () => void
|
||||||
}) {
|
}) {
|
||||||
const src = videoSrc(video)
|
const src = videoSrc(video)
|
||||||
|
const playableSrc = src && video.status === "completed" ? src : ""
|
||||||
const poster = videoPoster(job, video)
|
const poster = videoPoster(job, video)
|
||||||
const running = video.status === "queued" || video.status === "in_progress"
|
const running = video.status === "queued" || video.status === "in_progress"
|
||||||
return (
|
return (
|
||||||
<MediaAssetTile
|
<MediaAssetTile
|
||||||
kind="video"
|
kind="video"
|
||||||
src={src && video.status === "completed" ? src : undefined}
|
src={playableSrc || undefined}
|
||||||
poster={poster}
|
poster={poster}
|
||||||
href={onSelect ? undefined : src || undefined}
|
href={playableSrc || undefined}
|
||||||
alt={`片段 ${shortId(video.id)}`}
|
alt={`片段 ${shortId(video.id)}`}
|
||||||
label={`${shortId(video.id)} · ${video.model}`}
|
label={`${shortId(video.id)} · ${video.model}`}
|
||||||
meta={video.status}
|
meta={video.status}
|
||||||
className={`shrink-0 bg-black/45 ${className}`}
|
className={`shrink-0 bg-black/45 ${className}`}
|
||||||
objectFit="cover"
|
objectFit="cover"
|
||||||
selected={selected}
|
title={playableSrc ? "点击打开视频预览" : `${video.model} · ${video.status}`}
|
||||||
onClick={onSelect}
|
|
||||||
title={`${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>}
|
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}
|
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}
|
onDelete={onDelete}
|
||||||
deleteLabel="删除这个视频候选"
|
deleteLabel="删除这个视频候选"
|
||||||
/>
|
/>
|
||||||
@@ -8177,19 +8169,31 @@ function VideoCandidate({
|
|||||||
const src = videoSrc(video)
|
const src = videoSrc(video)
|
||||||
const poster = videoPoster(job, video)
|
const poster = videoPoster(job, video)
|
||||||
const running = video.status === "queued" || video.status === "in_progress"
|
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 (
|
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={`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">
|
<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">
|
{playableSrc ? (
|
||||||
{src && video.status === "completed" ? (
|
<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="打开视频预览">
|
||||||
<video src={src} poster={poster} muted playsInline className="h-full w-full object-cover" />
|
{thumb}
|
||||||
) : poster ? (
|
</a>
|
||||||
<img src={poster} alt={`片段 ${shortId(video.id)}`} className="h-full w-full object-cover opacity-80" />
|
) : (
|
||||||
) : (
|
<div className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black">
|
||||||
<div className="flex h-full w-full items-center justify-center text-white/30"><Film className="h-4 w-4" /></div>
|
{thumb}
|
||||||
)}
|
</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>
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<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>
|
<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>
|
<span>{video.progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
{video.error && <div className="mt-1 line-clamp-2 text-[11px] text-rose-200/80">{video.error}</div>}
|
{video.error && <div className="mt-1 line-clamp-2 text-[11px] text-rose-200/80">{video.error}</div>}
|
||||||
{src && video.status === "completed" && (
|
{playableSrc && (
|
||||||
<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">
|
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||||
<Play className="h-3 w-3" />
|
<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>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ type MediaAssetTileProps = {
|
|||||||
deleting?: boolean
|
deleting?: boolean
|
||||||
deleteDisabled?: boolean
|
deleteDisabled?: boolean
|
||||||
actions?: MediaAssetAction[]
|
actions?: MediaAssetAction[]
|
||||||
|
actionsAlwaysVisible?: boolean
|
||||||
disablePreview?: boolean
|
disablePreview?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +107,7 @@ export function MediaAssetTile({
|
|||||||
deleting = false,
|
deleting = false,
|
||||||
deleteDisabled = false,
|
deleteDisabled = false,
|
||||||
actions = [],
|
actions = [],
|
||||||
|
actionsAlwaysVisible = false,
|
||||||
disablePreview = false,
|
disablePreview = false,
|
||||||
}: MediaAssetTileProps) {
|
}: MediaAssetTileProps) {
|
||||||
const [position, setPosition] = useState<{ left: number; top: number; width: number } | null>(null)
|
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}
|
{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}
|
{bottom ? <div className="pointer-events-none absolute bottom-1 left-1 right-1 z-10">{bottom}</div> : null}
|
||||||
{(actions.length || onDelete) ? (
|
{(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) => (
|
{actions.map((action) => (
|
||||||
<button
|
<button
|
||||||
key={action.key}
|
key={action.key}
|
||||||
|
|||||||
Reference in New Issue
Block a user