feat: improve subject conversion composer
This commit is contained in:
2
RULES.md
2
RULES.md
@@ -11,7 +11,7 @@
|
||||
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
|
||||
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
|
||||
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
|
||||
- 当前产品方向(2026-05-20 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区右侧主体链路是“参考帧池 → 转换层 → 主体元素”:参考帧池竖向排列;转换层是轻量对话式生图确认区,参考帧通过左侧缩略图 `+` 送入转换层,用户选择 GPT/Gemini 套件后先分析参考图,再用对话描述复刻/创新/卡通/数量和画面要求;后端返回英文出图 prompt 后必须弹窗确认,用户点确认才生成对应数量的统一多角度套图。右侧主体元素区的套图输出、文件夹分组、单张重生、删除和 hover 预览逻辑保持不变。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
|
||||
- 当前产品方向(2026-05-20 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区右侧主体链路是“参考帧池 → 转换层 → 主体元素”:参考帧池竖向排列;转换层是轻量对话式生图确认区,参考图可通过左侧缩略图 `+`、参考帧拖拽、胶片拖拽或本地图片拖入进入转换层,用户选择 GPT/Gemini 套件后先分析参考图,再在下方消息输入区发送复刻/创新/卡通/数量和画面要求;后端返回英文出图 prompt 后必须弹窗确认,用户点确认才生成对应数量的统一多角度套图。右侧主体元素区的套图输出、文件夹分组、单张重生、删除和 hover 预览逻辑保持不变。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
|
||||
|
||||
## 部署事实
|
||||
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
|
||||
|
||||
54
api/main.py
54
api/main.py
@@ -4706,6 +4706,60 @@ def add_manual_frame(job_id: str, t: float) -> Job:
|
||||
return job
|
||||
|
||||
|
||||
@app.post("/jobs/{job_id}/frames/upload", response_model=Job)
|
||||
async def upload_reference_frame(job_id: str, file: UploadFile = File(...)) -> Job:
|
||||
"""把用户拖入的图片保存为一张参考帧,供转换层和主体生成复用。"""
|
||||
job = JOBS.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "job not found")
|
||||
content_type = (file.content_type or "").lower()
|
||||
suffix = Path(file.filename or "").suffix.lower()
|
||||
if content_type and not content_type.startswith("image/"):
|
||||
raise HTTPException(400, "only image uploads are supported")
|
||||
if not content_type and suffix not in {".jpg", ".jpeg", ".png", ".webp", ".bmp"}:
|
||||
raise HTTPException(400, "only image uploads are supported")
|
||||
|
||||
d = job_dir(job_id)
|
||||
frames_dir = d / "frames"
|
||||
frames_dir.mkdir(parents=True, exist_ok=True)
|
||||
next_idx = max((f.index for f in job.frames), default=-1) + 1
|
||||
tmp = frames_dir / f"{next_idx:03d}.upload"
|
||||
out = frames_dir / f"{next_idx:03d}.jpg"
|
||||
try:
|
||||
await _save_upload_to_path(file, tmp)
|
||||
with Image.open(tmp) as raw:
|
||||
img = ImageOps.exif_transpose(raw).convert("RGB")
|
||||
img.thumbnail((2400, 2400), Image.LANCZOS)
|
||||
img.save(out, "JPEG", quality=92, optimize=True)
|
||||
except Exception as e:
|
||||
try:
|
||||
out.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise HTTPException(400, f"reference image upload failed: {e}")
|
||||
finally:
|
||||
try:
|
||||
tmp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
next_timestamp = max((float(f.timestamp) for f in job.frames), default=float(job.duration or 0)) + 0.01
|
||||
new_frame = KeyFrame(
|
||||
index=next_idx,
|
||||
timestamp=round(next_timestamp, 2),
|
||||
url=f"/jobs/{job_id}/frames/{next_idx}.jpg",
|
||||
description={
|
||||
"scene": "用户拖入的转换层参考图",
|
||||
"objects": [],
|
||||
"style": "uploaded reference image",
|
||||
"suggested_prompt": "",
|
||||
},
|
||||
)
|
||||
merged = sorted(list(job.frames) + [new_frame], key=lambda f: f.timestamp)
|
||||
update(job, frames=merged, message=f"已加入上传参考图,共 {len(merged)} 张")
|
||||
return job
|
||||
|
||||
|
||||
@app.get("/jobs/{job_id}", response_model=Job)
|
||||
def get_job(job_id: str) -> Job:
|
||||
job = JOBS.get(job_id)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -291,8 +291,10 @@ export default function Home() {
|
||||
updateJobInList(updated)
|
||||
setActiveJobId((prev) => prev ?? updated.id)
|
||||
toast.success(`已加帧 @ ${t.toFixed(1)}s · 共 ${updated.frames.length} 张`)
|
||||
return updated
|
||||
} catch (e) {
|
||||
toast.error("加帧失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
return undefined
|
||||
}
|
||||
}, [updateJobInList])
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { 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 {
|
||||
AlertTriangle, BookOpen, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2,
|
||||
@@ -66,6 +66,7 @@ import {
|
||||
subjectTemplateImageUrl,
|
||||
updateElement,
|
||||
updateStoryboard,
|
||||
uploadReferenceFrame,
|
||||
uploadStoryboardAsset,
|
||||
translateText,
|
||||
videoUrl,
|
||||
@@ -2688,7 +2689,7 @@ function AudioIntakePanel({
|
||||
selectedFrames: Set<number>
|
||||
onToggleFrame: (idx: number) => void
|
||||
onJobUpdate: (job: Job) => void
|
||||
onAddFrame?: (jobId: string, t: number) => Promise<void> | void
|
||||
onAddFrame?: (jobId: string, t: number) => Promise<Job | void> | Job | void
|
||||
onDeleteFrame?: (jobId: string, idx: number) => Promise<void> | void
|
||||
runtimeModels?: RuntimeModels
|
||||
}) {
|
||||
@@ -2839,17 +2840,21 @@ function AudioIntakePanel({
|
||||
}
|
||||
|
||||
const addFilmstripFrame = async (time: number) => {
|
||||
if (!job || !onAddFrame) return
|
||||
if (!job || !onAddFrame) return null
|
||||
const next = clampNumber(time, 0, timelineDuration)
|
||||
const duplicate = frames.find((frame) => Math.abs(frame.timestamp - next) < 0.45)
|
||||
if (duplicate) {
|
||||
toast.warning(`附近已有关键帧:${duplicate.timestamp.toFixed(1)}s`)
|
||||
return
|
||||
return duplicate
|
||||
}
|
||||
setFilmstripBusyTime(next)
|
||||
try {
|
||||
await onAddFrame(job.id, next)
|
||||
const known = new Set(frames.map((frame) => frame.index))
|
||||
const updated = await onAddFrame(job.id, next)
|
||||
toast.success(`已加入关键帧:${next.toFixed(1)}s`)
|
||||
const updatedJob = updated && typeof updated === "object" && "frames" in updated ? updated : null
|
||||
const added = updatedJob?.frames.find((frame) => !known.has(frame.index) && Math.abs(frame.timestamp - next) < 0.45) ?? null
|
||||
return added
|
||||
} finally {
|
||||
setFilmstripBusyTime(null)
|
||||
setFilmstripDragTime(null)
|
||||
@@ -2999,7 +3004,7 @@ function AudioIntakePanel({
|
||||
onJobUpdate={onJobUpdate}
|
||||
runtimeModels={runtimeModels}
|
||||
filmstripDragging={filmstripDragTime !== null}
|
||||
onDropFilmstripFrame={(time) => void addFilmstripFrame(time)}
|
||||
onDropFilmstripFrame={(time) => addFilmstripFrame(time)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3305,10 +3310,12 @@ function SourceSubjectPipeline({
|
||||
onJobUpdate: (job: Job) => void
|
||||
runtimeModels?: RuntimeModels
|
||||
filmstripDragging?: boolean
|
||||
onDropFilmstripFrame?: (time: number) => void
|
||||
onDropFilmstripFrame?: (time: number) => Promise<KeyFrame | null> | KeyFrame | null | void
|
||||
}) {
|
||||
const [referenceDropActive, setReferenceDropActive] = useState(false)
|
||||
const [agentDropActive, setAgentDropActive] = useState(false)
|
||||
const [referenceFrameDragging, setReferenceFrameDragging] = useState(false)
|
||||
const [agentReferenceUploadBusy, setAgentReferenceUploadBusy] = useState(false)
|
||||
const [reconstructionDirections, setReconstructionDirections] = useState<Record<SubjectReconstructionMode, string>>(() => ({ ...DEFAULT_RECONSTRUCTION_DIRECTIONS }))
|
||||
const [subjectModelBundle, setSubjectModelBundle] = useState<SubjectModelBundle>(() => job.subject_agent?.model_bundle ?? "gpt")
|
||||
const [agentReferenceFrameIndices, setAgentReferenceFrameIndices] = useState<number[]>(() => job.subject_agent?.source_frame_indices ?? [])
|
||||
@@ -3636,15 +3643,30 @@ function SourceSubjectPipeline({
|
||||
}
|
||||
}
|
||||
|
||||
const mergeAgentReferenceIndices = (current: number[], incoming: number[]) => {
|
||||
let replaced = false
|
||||
const next = [...current]
|
||||
for (const index of incoming) {
|
||||
const numericIndex = Number(index)
|
||||
if (!Number.isFinite(numericIndex) || next.includes(numericIndex)) continue
|
||||
next.push(numericIndex)
|
||||
while (next.length > RECONSTRUCTION_FRAME_LIMIT) {
|
||||
next.shift()
|
||||
replaced = true
|
||||
}
|
||||
}
|
||||
return { next, replaced }
|
||||
}
|
||||
|
||||
const addAgentReferenceFrame = (frame: KeyFrame) => {
|
||||
setAgentReferenceFrameIndices((current) => {
|
||||
if (current.includes(frame.index)) {
|
||||
toast.info("这张参考帧已经在转换层里。")
|
||||
return current
|
||||
}
|
||||
const next = current.length >= RECONSTRUCTION_FRAME_LIMIT ? [...current.slice(1), frame.index] : [...current, frame.index]
|
||||
if (current.length >= RECONSTRUCTION_FRAME_LIMIT) {
|
||||
toast.warning(`最多保留 ${RECONSTRUCTION_FRAME_LIMIT} 张参考帧,已替换为最近拖入的组合。`)
|
||||
const { next, replaced } = mergeAgentReferenceIndices(current, [frame.index])
|
||||
if (replaced) {
|
||||
toast.warning(`最多保留 ${RECONSTRUCTION_FRAME_LIMIT} 张参考图,已替换为最近拖入的组合。`)
|
||||
} else {
|
||||
toast.info(`已加入转换层:${frame.timestamp.toFixed(1)}s。`)
|
||||
}
|
||||
@@ -3652,10 +3674,109 @@ function SourceSubjectPipeline({
|
||||
})
|
||||
}
|
||||
|
||||
const addAgentReferenceIndices = (indices: number[], notice = "已加入转换层") => {
|
||||
if (!indices.length) return
|
||||
setAgentReferenceFrameIndices((current) => {
|
||||
const { next, replaced } = mergeAgentReferenceIndices(current, indices)
|
||||
if (next.length === current.length && next.every((item, idx) => item === current[idx])) {
|
||||
toast.info("这些参考图已经在转换层里。")
|
||||
return current
|
||||
}
|
||||
if (replaced) {
|
||||
toast.warning(`最多保留 ${RECONSTRUCTION_FRAME_LIMIT} 张参考图,已保留最近加入的组合。`)
|
||||
} else {
|
||||
toast.success(`${notice}:${indices.length} 张。`)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const removeAgentReferenceFrame = (frameIndex: number) => {
|
||||
setAgentReferenceFrameIndices((current) => current.filter((index) => index !== frameIndex))
|
||||
}
|
||||
|
||||
const transferHasAgentReference = (transfer: DataTransfer) => {
|
||||
const types = Array.from(transfer.types || [])
|
||||
return (
|
||||
types.includes(SOURCE_KEYFRAME_DRAG_TYPE) ||
|
||||
types.includes(FILMSTRIP_DRAG_TYPE) ||
|
||||
types.includes("Files")
|
||||
)
|
||||
}
|
||||
|
||||
const handleAgentReferenceDragEnter = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!transferHasAgentReference(event.dataTransfer)) return
|
||||
event.preventDefault()
|
||||
setAgentDropActive(true)
|
||||
}
|
||||
|
||||
const handleAgentReferenceDragOver = (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!transferHasAgentReference(event.dataTransfer)) return
|
||||
event.preventDefault()
|
||||
event.dataTransfer.dropEffect = "copy"
|
||||
setAgentDropActive(true)
|
||||
}
|
||||
|
||||
const handleAgentReferenceDragLeave = (event: ReactDragEvent<HTMLElement>) => {
|
||||
const next = event.relatedTarget as Node | null
|
||||
if (next && event.currentTarget.contains(next)) return
|
||||
setAgentDropActive(false)
|
||||
}
|
||||
|
||||
const uploadAgentReferenceFiles = async (files: File[]) => {
|
||||
const imageFiles = files.filter((file) => {
|
||||
const name = file.name.toLowerCase()
|
||||
return file.type.startsWith("image/") || /\.(jpe?g|png|webp|bmp)$/i.test(name)
|
||||
}).slice(0, RECONSTRUCTION_FRAME_LIMIT)
|
||||
if (!imageFiles.length) {
|
||||
toast.warning("只支持拖入图片文件。")
|
||||
return
|
||||
}
|
||||
setAgentReferenceUploadBusy(true)
|
||||
try {
|
||||
let workingJob = job
|
||||
const known = new Set(job.frames.map((frame) => frame.index))
|
||||
const addedIndices: number[] = []
|
||||
for (const file of imageFiles) {
|
||||
const updated = await uploadReferenceFrame(workingJob.id, file)
|
||||
workingJob = updated
|
||||
onJobUpdate(updated)
|
||||
const added = updated.frames.filter((frame) => !known.has(frame.index))
|
||||
added.forEach((frame) => {
|
||||
known.add(frame.index)
|
||||
addedIndices.push(frame.index)
|
||||
})
|
||||
}
|
||||
addAgentReferenceIndices(addedIndices, "已上传并加入转换层")
|
||||
} catch (e) {
|
||||
toast.error("参考图上传失败:" + (e instanceof Error ? e.message : String(e)))
|
||||
} finally {
|
||||
setAgentReferenceUploadBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAgentReferenceDrop = async (event: ReactDragEvent<HTMLElement>) => {
|
||||
if (!transferHasAgentReference(event.dataTransfer)) return
|
||||
event.preventDefault()
|
||||
setAgentDropActive(false)
|
||||
const files = Array.from(event.dataTransfer.files || [])
|
||||
if (files.length) {
|
||||
await uploadAgentReferenceFiles(files)
|
||||
return
|
||||
}
|
||||
const frameIndex = Number(event.dataTransfer.getData(SOURCE_KEYFRAME_DRAG_TYPE))
|
||||
if (Number.isFinite(frameIndex)) {
|
||||
const frame = frames.find((item) => item.index === frameIndex)
|
||||
if (frame) addAgentReferenceFrame(frame)
|
||||
return
|
||||
}
|
||||
const filmstripTime = Number(event.dataTransfer.getData(FILMSTRIP_DRAG_TYPE))
|
||||
if (Number.isFinite(filmstripTime) && onDropFilmstripFrame) {
|
||||
const addedFrame = await onDropFilmstripFrame(filmstripTime)
|
||||
if (addedFrame) addAgentReferenceFrame(addedFrame)
|
||||
}
|
||||
}
|
||||
|
||||
const runSubjectAgentAnalyze = async () => {
|
||||
if (!agentReferenceFrameIndices.length) {
|
||||
toast.warning("先从左侧拖入 1-3 张参考帧,再开始分析。")
|
||||
@@ -3748,7 +3869,7 @@ function SourceSubjectPipeline({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-2 xl:grid-cols-[150px_minmax(210px,0.75fr)_minmax(0,1.25fr)] 2xl:grid-cols-[170px_minmax(240px,0.8fr)_minmax(0,1.3fr)]">
|
||||
<div className="grid gap-2 xl:grid-cols-[150px_minmax(260px,0.9fr)_minmax(0,1.1fr)] 2xl:grid-cols-[170px_minmax(285px,0.95fr)_minmax(0,1.05fr)]">
|
||||
<div className="min-w-0">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<SectionTitle icon={<ImageIcon className="h-4 w-4" />} title="参考帧池" />
|
||||
@@ -3805,7 +3926,15 @@ function SourceSubjectPipeline({
|
||||
return (
|
||||
<div
|
||||
key={frame.index}
|
||||
className="relative"
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData(SOURCE_KEYFRAME_DRAG_TYPE, String(frame.index))
|
||||
event.dataTransfer.effectAllowed = "copy"
|
||||
setReferenceFrameDragging(true)
|
||||
}}
|
||||
onDragEnd={() => setReferenceFrameDragging(false)}
|
||||
className="relative cursor-grab active:cursor-grabbing"
|
||||
title="拖到转换层作为生图参考"
|
||||
>
|
||||
<MediaAssetTile
|
||||
src={effectiveFrameUrl(job.id, frame)}
|
||||
@@ -3856,7 +3985,7 @@ function SourceSubjectPipeline({
|
||||
{agentReferenceFrames.length ? `${agentReferenceFrames.length}/${RECONSTRUCTION_FRAME_LIMIT} 图` : "待选图"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-[410px] rounded-md border border-white/10 bg-black/24 p-2 2xl:min-h-[500px]">
|
||||
<div className="flex min-h-[410px] flex-col rounded-md border border-white/10 bg-black/24 p-2 2xl:min-h-[500px]">
|
||||
<div className="mb-2 grid grid-cols-2 gap-1.5">
|
||||
{SUBJECT_MODEL_BUNDLE_OPTIONS.map((option) => (
|
||||
<button
|
||||
@@ -3876,10 +4005,20 @@ function SourceSubjectPipeline({
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-white/10 bg-black/22 p-2">
|
||||
<div
|
||||
className={`rounded-md border p-2 transition ${
|
||||
agentDropActive || referenceFrameDragging || filmstripDragging || agentReferenceUploadBusy
|
||||
? "border-cyan-200/65 bg-cyan-300/[0.08] ring-1 ring-cyan-200/25"
|
||||
: "border-white/10 bg-black/22"
|
||||
}`}
|
||||
onDragEnter={handleAgentReferenceDragEnter}
|
||||
onDragOver={handleAgentReferenceDragOver}
|
||||
onDragLeave={handleAgentReferenceDragLeave}
|
||||
onDrop={(event) => void handleAgentReferenceDrop(event)}
|
||||
>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] font-semibold text-white/72">参考图</span>
|
||||
<span className="text-[9px] text-white/34">最多 {RECONSTRUCTION_FRAME_LIMIT} 张</span>
|
||||
<span className="text-[10px] font-semibold text-white/72">参考输入</span>
|
||||
<span className="text-[9px] text-white/34">{agentReferenceFrames.length}/{RECONSTRUCTION_FRAME_LIMIT}</span>
|
||||
</div>
|
||||
{agentReferenceFrames.length ? (
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
@@ -3902,19 +4041,26 @@ function SourceSubjectPipeline({
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-24 items-center justify-center rounded border border-dashed border-white/12 px-2 text-center text-[10px] leading-snug text-white/32">
|
||||
从左侧参考帧点 + 加入。
|
||||
<div className="flex h-[116px] flex-col items-center justify-center rounded border border-dashed border-white/15 px-3 text-center text-[10px] leading-snug text-white/34">
|
||||
{agentReferenceUploadBusy ? <Loader2 className="mb-1.5 h-4 w-4 animate-spin text-cyan-100/80" /> : <Upload className="mb-1.5 h-4 w-4 text-cyan-100/55" />}
|
||||
<span className="font-semibold text-white/50">拖入参考帧或本地图片</span>
|
||||
<span className="mt-0.5 text-white/28">也可点左侧缩略图上的 +</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runSubjectAgentAnalyze()}
|
||||
disabled={!agentReferenceFrames.length || !!subjectAgentBusy}
|
||||
className="skg-secondary-action mt-2 inline-flex h-7 w-full items-center justify-center gap-1.5 px-2 text-[10px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{subjectAgentBusy === "analyze" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||||
分析参考图
|
||||
</button>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runSubjectAgentAnalyze()}
|
||||
disabled={!agentReferenceFrames.length || !!subjectAgentBusy || agentReferenceUploadBusy}
|
||||
className="skg-secondary-action inline-flex h-7 flex-1 items-center justify-center gap-1.5 px-2 text-[10px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{subjectAgentBusy === "analyze" || agentReferenceUploadBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||||
分析参考图
|
||||
</button>
|
||||
<span className="shrink-0 rounded border border-white/10 bg-black/24 px-1.5 py-1 text-[9px] text-white/35">
|
||||
图片区
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{agentAnalysis ? (
|
||||
@@ -3945,14 +4091,17 @@ function SourceSubjectPipeline({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-2 rounded-md border border-white/10 bg-black/22 p-2">
|
||||
<div className="mt-2 flex min-h-0 flex-1 flex-col rounded-md border border-white/10 bg-black/22 p-2">
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] font-semibold text-white/72">生图对话</span>
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-white/72">
|
||||
<MessageSquare className="h-3.5 w-3.5 text-cyan-100/55" />
|
||||
生图对话
|
||||
</span>
|
||||
<span className="text-[9px] text-white/34">
|
||||
{reconstructionModeConfig(effectiveAgentMode).label} · {effectiveAgentQuantity} 张
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-28 space-y-1.5 overflow-auto rounded border border-white/8 bg-black/20 p-1.5">
|
||||
<div className="min-h-[86px] flex-1 space-y-1.5 overflow-auto rounded border border-white/8 bg-black/20 p-1.5">
|
||||
{agentMessages.length ? agentMessages.slice(-5).map((message, index) => (
|
||||
<div
|
||||
key={`${message.created_at}-${index}`}
|
||||
@@ -3965,8 +4114,8 @@ function SourceSubjectPipeline({
|
||||
{message.content}
|
||||
</div>
|
||||
)) : (
|
||||
<div className="flex h-14 items-center justify-center text-center text-[10px] leading-snug text-white/30">
|
||||
分析后,直接写你要复刻、创新、卡通、数量和画面要求。
|
||||
<div className="flex h-full min-h-[74px] items-center justify-center px-2 text-center text-[10px] leading-snug text-white/30">
|
||||
直接发送复刻、创新、卡通、数量和画面要求。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -3982,21 +4131,23 @@ function SourceSubjectPipeline({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={agentInput}
|
||||
onChange={(event) => setAgentInput(event.target.value)}
|
||||
placeholder="例如:保留透明骨架和蓝色头带,但人物更大,服装统一,生成6张。"
|
||||
className="mt-2 h-20 w-full resize-none rounded-md border border-white/10 bg-black/35 px-2 py-1.5 text-[10.5px] leading-snug text-white outline-none transition placeholder:text-white/24 focus:border-cyan-200/55"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void sendSubjectAgentRequirement()}
|
||||
disabled={!!subjectAgentBusy || (!agentInput.trim() && !agentRequirement.trim())}
|
||||
className="skg-primary-action mt-2 inline-flex h-8 w-full items-center justify-center gap-1.5 px-2 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{subjectAgentBusy === "message" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
|
||||
生成提示词
|
||||
</button>
|
||||
<div className="mt-2 rounded-md border border-white/10 bg-black/35 p-1.5">
|
||||
<textarea
|
||||
value={agentInput}
|
||||
onChange={(event) => setAgentInput(event.target.value)}
|
||||
placeholder="例如:保留透明骨架和蓝色头带,但人物更大,服装统一,生成6张。"
|
||||
className="h-[72px] w-full resize-none rounded border border-transparent bg-transparent px-1 py-1 text-[10.5px] leading-snug text-white outline-none transition placeholder:text-white/24 focus:border-cyan-200/45"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void sendSubjectAgentRequirement()}
|
||||
disabled={!!subjectAgentBusy || (!agentInput.trim() && !agentRequirement.trim())}
|
||||
className="skg-primary-action mt-1 inline-flex h-8 w-full items-center justify-center gap-1.5 px-2 text-[10.5px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
{subjectAgentBusy === "message" ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
|
||||
发送消息
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{effectivePrompt ? (
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface NodeData {
|
||||
onFramePanelDockChange?: (dock: CanvasPanelDock) => void
|
||||
onCloseExpandedFrame: () => void
|
||||
onAddManualFrame: (t: number) => void
|
||||
onAddManualFrameForJob?: (jobId: string, t: number) => Promise<void> | void
|
||||
onAddManualFrameForJob?: (jobId: string, t: number) => Promise<Job | void> | Job | void
|
||||
onOpenVideoPanel?: (jobId: string) => void
|
||||
onCloseVideoPanel?: () => void
|
||||
onVideoPanelScaleChange?: (scale: number) => void
|
||||
|
||||
@@ -1123,6 +1123,17 @@ export async function addManualFrame(id: string, t: number): Promise<Job> {
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function uploadReferenceFrame(jobId: string, file: File): Promise<Job> {
|
||||
const fd = new FormData()
|
||||
fd.append("file", file)
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/upload`, { method: "POST", body: fd })
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "")
|
||||
throw new Error(`uploadReferenceFrame ${res.status} ${txt.slice(0, 300)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function describeFrame(jobId: string, frameIdx: number): Promise<Job> {
|
||||
const res = await fetch(`${API_BASE}/jobs/${jobId}/frames/${frameIdx}/describe`, { method: "POST" })
|
||||
if (!res.ok) {
|
||||
|
||||
Reference in New Issue
Block a user