auto-save 2026-05-13 15:17 (~6)
This commit is contained in:
@@ -1755,6 +1755,13 @@
|
||||
"type": "session-heartbeat",
|
||||
"message": "Claude 会话活跃 · 最近命令:claude · 2 项未提交变更 · 最近提交:auto-save 2026-05-13 15:05 (~2)",
|
||||
"files_changed": 2
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-13T15:11:45+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-13 15:11 (~3)",
|
||||
"hash": "02df0c5",
|
||||
"files_changed": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
61
api/main.py
61
api/main.py
@@ -73,6 +73,17 @@ class StoryboardScene(BaseModel):
|
||||
reference_ids: list[str] = [] # 参考图:选用该分镜里已提取的 element ids 作 reference
|
||||
|
||||
|
||||
class StoryboardImage(BaseModel):
|
||||
"""用户从各处"上推"到分镜头编排区的图片"""
|
||||
ref_id: str # uuid hex 8
|
||||
kind: Literal["keyframe", "cutout"] # keyframe = 关键帧本身 / cutout = 元素提取图
|
||||
frame_idx: int
|
||||
element_id: str | None = None # cutout 时
|
||||
cutout_id: str | None = None # cutout 时(versioned id;老数据可能 == element_id)
|
||||
label: str = "" # 显示用名字
|
||||
created_at: float = 0.0
|
||||
|
||||
|
||||
class KeyElement(BaseModel):
|
||||
"""关键帧里识别 / 用户提取的元素 · 多次提取累积多张图,让用户挑选满意的"""
|
||||
id: str # uuid hex 8
|
||||
@@ -121,6 +132,7 @@ class Job(BaseModel):
|
||||
height: int = 0
|
||||
frames: list[KeyFrame] = Field(default_factory=list)
|
||||
transcript: list[TranscriptSegment] = Field(default_factory=list)
|
||||
storyboard_images: list[StoryboardImage] = Field(default_factory=list)
|
||||
error: str = ""
|
||||
|
||||
|
||||
@@ -1434,6 +1446,55 @@ def update_storyboard(job_id: str, idx: int, req: UpdateStoryboardReq) -> Job:
|
||||
return job
|
||||
|
||||
|
||||
class PushStoryboardImageReq(BaseModel):
|
||||
kind: Literal["keyframe", "cutout"]
|
||||
frame_idx: int
|
||||
element_id: str | None = None
|
||||
cutout_id: str | None = None
|
||||
label: str = ""
|
||||
|
||||
|
||||
@app.post("/jobs/{job_id}/storyboard-images", response_model=Job)
|
||||
def push_storyboard_image(job_id: str, req: PushStoryboardImageReq) -> Job:
|
||||
"""把一张图(关键帧本身或元素提取图)推送到分镜头编排区"""
|
||||
import time as _time
|
||||
job = JOBS.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "job not found")
|
||||
# 防重复推送:相同 frame_idx + element_id + cutout_id 已存在就跳过
|
||||
for existing in job.storyboard_images:
|
||||
if (existing.kind == req.kind
|
||||
and existing.frame_idx == req.frame_idx
|
||||
and existing.element_id == req.element_id
|
||||
and existing.cutout_id == req.cutout_id):
|
||||
return job
|
||||
img = StoryboardImage(
|
||||
ref_id=uuid.uuid4().hex[:8],
|
||||
kind=req.kind,
|
||||
frame_idx=req.frame_idx,
|
||||
element_id=req.element_id,
|
||||
cutout_id=req.cutout_id,
|
||||
label=req.label.strip(),
|
||||
created_at=_time.time(),
|
||||
)
|
||||
update(job, storyboard_images=job.storyboard_images + [img], message=f"上推到分镜头编排 · {req.label or req.kind}")
|
||||
return job
|
||||
|
||||
|
||||
@app.delete("/jobs/{job_id}/storyboard-images/{ref_id}", response_model=Job)
|
||||
def remove_storyboard_image(job_id: str, ref_id: str) -> Job:
|
||||
"""从分镜头编排区移除一张图"""
|
||||
job = JOBS.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "job not found")
|
||||
before = len(job.storyboard_images)
|
||||
new_list = [x for x in job.storyboard_images if x.ref_id != ref_id]
|
||||
if len(new_list) == before:
|
||||
raise HTTPException(404, "storyboard image not found")
|
||||
update(job, storyboard_images=new_list, message="从分镜头编排移除一张图")
|
||||
return job
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}/frames/{idx}/elements/{element_id}/cutouts/{cutout_id}.jpg")
|
||||
def get_cutout_versioned(job_id: str, idx: int, element_id: str, cutout_id: str):
|
||||
p = job_dir(job_id) / "elements" / f"{idx:03d}_{element_id}_{cutout_id}.jpg"
|
||||
|
||||
@@ -304,11 +304,8 @@ export default function Home() {
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* 左侧:竖向 tile 看板(极窄) */}
|
||||
<aside
|
||||
className="relative z-10 flex-shrink-0 border-r border-white/5 bg-black/20 backdrop-blur-xl overflow-y-auto"
|
||||
style={{ width: 88 }}
|
||||
>
|
||||
{/* sidebar tile 列已去掉 · Dashboard 仍渲染(hidden)以保留 keyframe lightbox 等 drawer portal */}
|
||||
<aside className="hidden">
|
||||
<Dashboard ref={dashboardRef} data={nodeData} />
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ export const Dashboard = forwardRef<DashboardHandle, Props>(function Dashboard({
|
||||
<div
|
||||
className="fixed z-[100]"
|
||||
style={{
|
||||
left: 104,
|
||||
left: 24,
|
||||
top: 16,
|
||||
bottom: 16,
|
||||
width: drawerWidth,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { LayoutGrid, ChevronDown, ChevronUp, Sparkle } from "lucide-react"
|
||||
import { type Job, type KeyFrame, cutoutUrl, effectiveFrameUrl, hasCutout } from "@/lib/api"
|
||||
import { LayoutGrid, ChevronDown, ChevronUp, Sparkle, X } from "lucide-react"
|
||||
import { type Job, type KeyFrame, cutoutUrl, effectiveFrameUrl, hasCutout, removeStoryboardImage } from "@/lib/api"
|
||||
import { toast } from "sonner"
|
||||
|
||||
interface Props {
|
||||
job: Job | null
|
||||
@@ -12,7 +13,7 @@ interface Props {
|
||||
onJobUpdate?: (j: Job) => void
|
||||
}
|
||||
|
||||
export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame }: Props) {
|
||||
export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame, onJobUpdate }: Props) {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
useEffect(() => setMounted(true), [])
|
||||
@@ -40,21 +41,19 @@ export function StoryboardBar({ job, selectedFrames, focusedFrame, onFocusFrame
|
||||
? frames.findIndex((f) => f.index === focusFrame.index) + 1
|
||||
: 0
|
||||
|
||||
// 所有"已进入分镜阶段"的提取图(按分镜时间序展平)
|
||||
type Shot = { frameIdx: number; seq: number; elementId: string; elementName: string; cid: string; isLegacy: boolean }
|
||||
const allShots: Shot[] = []
|
||||
frames.forEach((f, i) => {
|
||||
const seq = i + 1
|
||||
;(f.elements ?? []).forEach((e) => {
|
||||
if (e.cutouts && e.cutouts.length > 0) {
|
||||
e.cutouts.forEach((cid) => allShots.push({
|
||||
frameIdx: f.index, seq, elementId: e.id, elementName: e.name_zh, cid, isLegacy: false,
|
||||
}))
|
||||
} else if (e.cutout_id) {
|
||||
allShots.push({ frameIdx: f.index, seq, elementId: e.id, elementName: e.name_zh, cid: e.cutout_id, isLegacy: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
// 用户已"上推"到分镜头编排区的图片
|
||||
const pushedImages = job.storyboard_images ?? []
|
||||
const frameSeqByIdx: Record<number, number> = {}
|
||||
frames.forEach((f, i) => { frameSeqByIdx[f.index] = i + 1 })
|
||||
|
||||
const handleRemovePushed = async (refId: string) => {
|
||||
try {
|
||||
const updated = await removeStoryboardImage(job.id, refId)
|
||||
onJobUpdate?.(updated)
|
||||
} catch (e) {
|
||||
toast.error("移除失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative z-20 flex-shrink-0 border-b border-white/5 bg-black/30 backdrop-blur-xl">
|
||||
|
||||
@@ -76,6 +76,16 @@ export interface TranscriptSegment {
|
||||
zh: string
|
||||
}
|
||||
|
||||
export interface StoryboardImage {
|
||||
ref_id: string
|
||||
kind: "keyframe" | "cutout"
|
||||
frame_idx: number
|
||||
element_id?: string | null
|
||||
cutout_id?: string | null
|
||||
label?: string
|
||||
created_at?: number
|
||||
}
|
||||
|
||||
export interface Job {
|
||||
id: string
|
||||
url: string
|
||||
@@ -88,6 +98,7 @@ export interface Job {
|
||||
height?: number
|
||||
frames: KeyFrame[]
|
||||
transcript: TranscriptSegment[]
|
||||
storyboard_images?: StoryboardImage[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -248,6 +259,31 @@ export function representativeCutoutUrl(
|
||||
return null
|
||||
}
|
||||
|
||||
export async function pushStoryboardImage(
|
||||
jobId: string,
|
||||
body: { kind: "keyframe" | "cutout"; frame_idx: number; element_id?: string | null; cutout_id?: string | null; label?: string },
|
||||
): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/storyboard-images`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`pushStoryboardImage ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function removeStoryboardImage(jobId: string, refId: string): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/storyboard-images/${refId}`, { method: "DELETE" })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`removeStoryboardImage ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function updateStoryboard(
|
||||
jobId: string,
|
||||
frameIdx: number,
|
||||
|
||||
Reference in New Issue
Block a user