fix: make canvas the root generation experience
This commit is contained in:
@@ -45,7 +45,7 @@
|
||||
"type" : "oauth_app"
|
||||
}
|
||||
],
|
||||
"description" : "SKG 营销内容生产平台:默认首页面向公司团队成员的个人隔离创作空间,终端可见品牌位只保留 SKG logo。主路径为文生图、图生图、文生视频、图生视频和营销图文方案生成;每个登录用户只看到自己的任务和结果。任务详情页沉淀参考图、生成图、视频候选、提示词和图文方案,可继续生成、删除和复用。画布用于整理多次生成结果;旧 TK 复刻\/一键出片能力保留为高级入口。",
|
||||
"description" : "SKG 营销内容生产平台:根域名 https:\/\/marketing.skg.com 登录后直接进入个人生成画布,终端可见品牌位只保留 SKG logo。主路径为文生图、文生视频、图生视频;每个登录用户只看到自己的任务和结果。画布用于整理多次生成结果,图片\/视频资产继续写入当前用户自己的后端 job;旧 TK 复刻\/一键出片能力保留为高级入口。",
|
||||
"kind" : "app",
|
||||
"name" : "SKG 营销内容生产平台",
|
||||
"ownership" : "company",
|
||||
@@ -84,11 +84,6 @@
|
||||
"type" : "backend",
|
||||
"url" : "https:\/\/marketing.skg.com\/api"
|
||||
},
|
||||
{
|
||||
"label" : "production-canvas",
|
||||
"type" : "app",
|
||||
"url" : "https:\/\/marketing.skg.com\/canvas\/"
|
||||
},
|
||||
{
|
||||
"label" : "agent-cut-preview",
|
||||
"type" : "app",
|
||||
|
||||
8
RULES.md
8
RULES.md
@@ -4,7 +4,7 @@
|
||||
- 后台启动(不弹 Terminal):`./scripts/start-dev-background.sh`(通过 macOS launchd 后台托管;前端 4290 + 后端 4291,日志写入 `.logs/`)
|
||||
- 后台停止:`./scripts/stop-dev-background.sh`
|
||||
- 前端 dev:`cd web && npm run dev`(Next.js 16,端口 4290)
|
||||
- 画布 dev:`cd web && npm run dev:canvas`(Vue / Vite,端口 4292;生产构建会输出到 `/canvas/`)
|
||||
- 画布 dev:`cd web && npm run dev:canvas`(Vue / Vite,端口 4292;生产构建会作为根域名工作台输出)
|
||||
- 后端 dev:`cd api && uvicorn main:app --host 127.0.0.1 --port 4291`(FastAPI,端口 4291,重任务用)
|
||||
- 注意:后端不要带 `--reload` 跑长下载 / 抽帧 / 音频任务;reload 会等待后台任务结束,导致 4291 端口占用但新请求卡住。
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
|
||||
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
|
||||
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
|
||||
- 当前产品方向(2026-05-25 单对话框 + logo-only 画布版):默认首页彻底从“信息流广告复刻管线”切换为多人通用的 SKG 营销内容生产平台入口,服务公司内部成员同时使用。终端可见品牌位只放 SKG logo,不再把“生图生视频”“SKG 生成画布”或长系统名放在主界面上。首页默认只保留一个中央对话框,不再显示侧栏、灵感区、任务列表或大结果面板;用户先选择四种生成方式之一:文生视频、文生图、首帧生视频、首尾帧生视频,然后手写提示词并点击生成。首帧 / 首尾帧模式只露必要图片上传位,图片模式显示尺寸选择,视频模式显示画幅和真实可用时长选择。后端 `/health` 向前端返回可选图片 / 视频模型、图片尺寸、视频画幅和视频时长,首页允许用户选择图片模型(自动、GPT Image 2、Gemini 图片兜底)和视频模型(Seedance、Kling、Veo 3 等别名;实际可用模型以环境变量映射为准)。当前 Doubao / Seedance 生产链路单条视频最长按 15 秒暴露,不在 UI 显示 30 秒;如后续要 30 秒,需要改成多段生成后合成。用户登录后仍只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离;结果生成后从对话框下方进入 `/detail/?job=<id>` 沉淀参考图、生成图、视频候选和提示词。新增 `/canvas/` 作为个人画布入口,基于 huobao-canvas 交互逻辑改造为 SKG 内部版,界面去除原可见品牌/API 设置,生成调用本项目后端 `/api`,每个浏览器的画布项目先保存在本地 localStorage,图片/视频资产仍按登录用户写入后端 job。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架。
|
||||
- 当前产品方向(2026-05-25 三模式 + logo-only 根域名画布版):默认首页彻底从“信息流广告复刻管线”切换为多人通用的 SKG 营销内容生产平台入口,服务公司内部成员同时使用。`https://marketing.skg.com` 登录后直接进入个人生成画布,`/canvas/` 只作为旧链接兼容跳转到根域名。终端可见品牌位只放 SKG logo,不再把“生图生视频”“SKG 生成画布”或长系统名放在主界面上。首页和画布底部输入框都只保留三个用户能直接理解的入口:文生图、文生视频、图生视频;不再把“首帧生视频 / 首尾帧生视频”这类模型实现概念作为主入口。图生视频只显示“上传图片”,内部仍用后端 first_image 能力提交。用户选择生成方式、必要时上传图片、手写提示词并点击生成;图片模式显示尺寸选择,视频模式显示画幅和真实可用时长选择。后端 `/health` 向前端返回可选图片 / 视频模型、图片尺寸、视频画幅和视频时长,首页允许用户选择图片模型(自动、GPT Image 2、Gemini 图片兜底)和视频模型(Seedance、Kling、Veo 3 等别名;实际可用模型以环境变量映射为准)。当前 Doubao / Seedance 生产链路单条视频最长按 15 秒暴露,不在 UI 显示 30 秒;如后续要 30 秒,需要改成多段生成后合成。用户登录后仍只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离;生成调用本项目后端 `/api`,每个浏览器的画布项目先保存在本地 localStorage,图片/视频资产仍按登录用户写入后端 job。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架。
|
||||
|
||||
## 部署事实
|
||||
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
|
||||
@@ -64,7 +64,7 @@
|
||||
- 最近部署验证(2026-05-20):`f1c710e` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层中间栏先清空为待重构占位,不再接收拖拽或触发 subject-agent / subject-assets;右侧主体元素输出逻辑保持不变。
|
||||
- 最近部署验证(2026-05-20):`7e763cf` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层改为参考帧分析 + 对话生成提示词 + 弹窗确认后再生成主体套图;右侧主体元素输出逻辑保持不变。部署时发现服务器 `WEB_AUTH_*` 环境变量缺失导致 `/auth/check` 503,已从 `/root/skg-marketing-studio-login.txt` 和新 session secret 恢复服务器 `deploy/.env.production` 后重启验证通过;后续同步生产代码必须继续排除服务器真实 `deploy/.env.production`。
|
||||
- 主站 / 前端:`https://marketing.skg.com`
|
||||
- 画布:`https://marketing.skg.com/canvas/`
|
||||
- 旧画布路径:`https://marketing.skg.com/canvas/`(仅兼容跳转到根域名)
|
||||
- API / 后端:`https://marketing.skg.com/api`
|
||||
- 代码仓库 / Gitea:`https://git.kang-kang.com/kangwan/20260512-skg-tk`
|
||||
- 文档 / 解析:`docs/source-analysis.html`(项目内独立文档,不公开挂主应用路由)
|
||||
@@ -73,7 +73,7 @@
|
||||
- 生产部署唯一入口:`./scripts/deploy-prod-safe.sh`(先在服务器备份 `deploy/.env.production`、`data/jobs`、资源库和 `secrets`,再用受保护 rsync 同步代码,最后 Docker 重建并运行 `verify-prod-docker.sh`)
|
||||
- 生产容器重建命令:`docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d --build`;只允许脚本内部或明确只重启容器时使用,不允许再用裸 `rsync --delete` 手动同步。
|
||||
- 独立预览容器重建命令:服务器 `/opt/skg-marketing-studio` 下执行 `docker compose -f docker-compose.standalone.yml --env-file deploy/.env.production up -d --build`;Web 暴露 `0.0.0.0:4290->80`,后端仅在 compose 内部网络暴露,`/api/` 由 Web 容器 Nginx 反代并复用应用内登录校验。
|
||||
- 生产架构:`web` 容器用 Nginx 承载 Next 静态导出;`/login/`、`/_next/`、`/assets/`、`/skg-logo-black.svg`、`/oasis-source/` 等登录页必需静态资源公开访问;未登录访问工作台跳转 `/login/`;`/canvas/` 是受同一登录保护的 Vue / Vite 画布静态应用,Nginx fallback 到 `/canvas/index.html`;`/api/` 通过 Nginx `auth_request` 校验 FastAPI 会话 Cookie 后反代到 `skg-marketing-api:4291`;Traefik 通过 `coolify` 外部网络接入 80/443
|
||||
- 生产架构:`web` 容器用 Nginx 承载 Next 静态导出与根域名 Vue / Vite 画布静态应用;构建时先生成画布,再 Next 静态导出,最后用画布产物覆盖 `web/out/index.html` 和 `/assets/`,使登录后的 `/` 直接进入画布;`/canvas/` 只做 308 兼容跳转到 `/`。`/login/`、`/_next/`、`/assets/`、`/skg-logo-black.svg`、`/oasis-source/` 等登录页必需静态资源公开访问;未登录访问工作台跳转 `/login/`;`/api/` 通过 Nginx `auth_request` 校验 FastAPI 会话 Cookie 后反代到 `skg-marketing-api:4291`;Traefik 通过 `coolify` 外部网络接入 80/443
|
||||
- Web 验收必须以生产 Docker 形态为准:前端是 `next export` 静态产物 + Nginx,不是 `next dev` / `next start`。任何 Web 改动部署后必须运行 `./scripts/verify-prod-docker.sh`,确认 `/login/`、`/_next/`、`/api/health`、本地 API 地址泄漏和 API 镜像 `.env` 污染检查通过;不能只用本地 `npm run build` 作为上线依据。
|
||||
- 当前音频解析:`https://ai.skg.com/azure/v1` 的 `gpt-4o-transcribe` 当前返回 `DeploymentNotFound`,且官方 Azure OpenAI transcription 路径探测也未返回可用部署;生产临时复制本地成功策略,直接使用容器内多语言 `faster-whisper` 真实转写,默认语种为 `auto`,支持中文、英文和其他多语言原文识别,关闭 Gemini 多模态音频兜底。拿到真实 Azure ASR deployment 名后再恢复 `ASR_REMOTE_ENABLED=true`,并保持 `ASR_LANGUAGE` 为空或 `auto`,除非明确只想强制单一语种。
|
||||
- 持久化目录:服务器 `./data/jobs` 挂载到后端 `/data/jobs`;全局资源中心持久化在 `./data/asset_library`、`./data/prompt_library` 和 `./data/_trash`
|
||||
|
||||
@@ -107,14 +107,11 @@ server {
|
||||
}
|
||||
|
||||
location = /canvas {
|
||||
return 308 /canvas/;
|
||||
return 308 /;
|
||||
}
|
||||
|
||||
location /canvas/ {
|
||||
auth_request /__auth;
|
||||
error_page 401 = @login_redirect;
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri/ /canvas/index.html;
|
||||
location ~ ^/canvas/(.*)$ {
|
||||
return 308 /$1$is_args$args;
|
||||
}
|
||||
|
||||
location = /skg-logo-black.svg {
|
||||
|
||||
File diff suppressed because one or more lines are too long
104
web/app/page.tsx
104
web/app/page.tsx
@@ -8,7 +8,6 @@ import {
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
Plus,
|
||||
Sparkles,
|
||||
Upload,
|
||||
X,
|
||||
type LucideIcon,
|
||||
@@ -24,7 +23,6 @@ import {
|
||||
generateStoryboardVideo,
|
||||
getRuntimeHealth,
|
||||
getJob,
|
||||
uploadReferenceFrame,
|
||||
type GeneratedImage,
|
||||
type GeneratedVideo,
|
||||
type Job,
|
||||
@@ -32,26 +30,19 @@ import {
|
||||
type RuntimeSizeOption,
|
||||
} from "@/lib/api"
|
||||
|
||||
type CreationMode = "text-video" | "text-image" | "first-frame-video" | "first-last-frame-video"
|
||||
type CreationMode = "text-image" | "text-video" | "image-video"
|
||||
type BusyTask = CreationMode | "job" | null
|
||||
type UploadSlot = "first" | "last"
|
||||
type UploadSlot = "first"
|
||||
|
||||
type ModeConfig = {
|
||||
id: CreationMode
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
placeholder: string
|
||||
needsFirstFrame?: boolean
|
||||
needsLastFrame?: boolean
|
||||
needsImage?: boolean
|
||||
}
|
||||
|
||||
const MODES: ModeConfig[] = [
|
||||
{
|
||||
id: "text-video",
|
||||
label: "文生视频",
|
||||
icon: Clapperboard,
|
||||
placeholder: "写清楚画面、人物动作、产品出现方式、镜头运动和风格。例如:15 秒竖屏,办公室午休,人物戴上 SKG 颈部按摩仪放松,镜头缓慢推进。",
|
||||
},
|
||||
{
|
||||
id: "text-image",
|
||||
label: "文生图",
|
||||
@@ -59,19 +50,17 @@ const MODES: ModeConfig[] = [
|
||||
placeholder: "写清楚画面、主体、构图、光线和比例。例如:9:16 信息流营销图,真实办公室场景,SKG 颈部按摩仪佩戴清楚。",
|
||||
},
|
||||
{
|
||||
id: "first-frame-video",
|
||||
label: "首帧生视频",
|
||||
icon: Upload,
|
||||
needsFirstFrame: true,
|
||||
placeholder: "上传首帧后写视频变化:人物怎么动、镜头怎么动、产品要保持什么细节、时长多长。",
|
||||
id: "text-video",
|
||||
label: "文生视频",
|
||||
icon: Clapperboard,
|
||||
placeholder: "写清楚画面、人物动作、产品出现方式、镜头运动和风格。例如:15 秒竖屏,办公室午休,人物戴上 SKG 颈部按摩仪放松,镜头缓慢推进。",
|
||||
},
|
||||
{
|
||||
id: "first-last-frame-video",
|
||||
label: "首尾帧生视频",
|
||||
icon: Sparkles,
|
||||
needsFirstFrame: true,
|
||||
needsLastFrame: true,
|
||||
placeholder: "上传首帧和尾帧后,写中间如何过渡、动作节奏、镜头运动和产品细节保持要求。",
|
||||
id: "image-video",
|
||||
label: "图生视频",
|
||||
icon: Upload,
|
||||
needsImage: true,
|
||||
placeholder: "上传图片后,写它要怎么动、镜头怎么运动、产品细节怎么保持、视频节奏和时长。",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -129,9 +118,7 @@ export default function Home() {
|
||||
const [prompt, setPrompt] = useState("")
|
||||
const [seconds, setSeconds] = useState(12)
|
||||
const [firstFrameFile, setFirstFrameFile] = useState<File | null>(null)
|
||||
const [lastFrameFile, setLastFrameFile] = useState<File | null>(null)
|
||||
const [firstFramePreview, setFirstFramePreview] = useState("")
|
||||
const [lastFramePreview, setLastFramePreview] = useState("")
|
||||
const [imageModel, setImageModel] = useState("auto")
|
||||
const [videoModel, setVideoModel] = useState("seedance")
|
||||
const [imageSize, setImageSize] = useState("1024x1536")
|
||||
@@ -149,7 +136,6 @@ export default function Home() {
|
||||
const [busy, setBusy] = useState<BusyTask>(null)
|
||||
const [error, setError] = useState("")
|
||||
const firstInputRef = useRef<HTMLInputElement>(null)
|
||||
const lastInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const activeMode = MODES.find((item) => item.id === mode) ?? MODES[0]
|
||||
const latestImage = latestGeneratedImage(job)
|
||||
@@ -203,16 +189,6 @@ export default function Home() {
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}, [firstFrameFile])
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastFrameFile) {
|
||||
setLastFramePreview("")
|
||||
return
|
||||
}
|
||||
const url = URL.createObjectURL(lastFrameFile)
|
||||
setLastFramePreview(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}, [lastFrameFile])
|
||||
|
||||
useEffect(() => {
|
||||
if (!job || !runningVideo) return
|
||||
const timer = window.setInterval(async () => {
|
||||
@@ -233,18 +209,13 @@ export default function Home() {
|
||||
const onModeChange = (nextMode: CreationMode) => {
|
||||
setMode(nextMode)
|
||||
resetResult()
|
||||
if (nextMode === "text-video" || nextMode === "text-image") {
|
||||
if (nextMode !== "image-video") {
|
||||
setFirstFrameFile(null)
|
||||
setLastFrameFile(null)
|
||||
}
|
||||
if (nextMode === "first-frame-video") {
|
||||
setLastFrameFile(null)
|
||||
}
|
||||
}
|
||||
|
||||
const setUploadFile = (slot: UploadSlot, file: File | null) => {
|
||||
if (slot === "first") setFirstFrameFile(file)
|
||||
if (slot === "last") setLastFrameFile(file)
|
||||
resetResult()
|
||||
}
|
||||
|
||||
@@ -253,12 +224,8 @@ export default function Home() {
|
||||
toast.error("先写提示词")
|
||||
return false
|
||||
}
|
||||
if (activeMode.needsFirstFrame && !firstFrameFile) {
|
||||
toast.error("先上传首帧")
|
||||
return false
|
||||
}
|
||||
if (activeMode.needsLastFrame && !lastFrameFile) {
|
||||
toast.error("先上传尾帧")
|
||||
if (activeMode.needsImage && !firstFrameFile) {
|
||||
toast.error("先上传图片")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -270,13 +237,10 @@ export default function Home() {
|
||||
|
||||
const prepareJob = useCallback(async () => {
|
||||
setBusy("job")
|
||||
let created = await createCreativeImageJob(firstFrameFile)
|
||||
if (mode === "first-last-frame-video" && lastFrameFile) {
|
||||
created = await uploadReferenceFrame(created.id, lastFrameFile)
|
||||
}
|
||||
const created = await createCreativeImageJob(firstFrameFile)
|
||||
setJob(created)
|
||||
return created
|
||||
}, [firstFrameFile, lastFrameFile, mode])
|
||||
}, [firstFrameFile])
|
||||
|
||||
const runImage = async () => {
|
||||
if (!validate()) return
|
||||
@@ -307,13 +271,12 @@ export default function Home() {
|
||||
setError("")
|
||||
try {
|
||||
const target = await prepareJob()
|
||||
const lastFrame = [...target.frames].sort((a, b) => b.index - a.index)[0]
|
||||
const updated = await generateStoryboardVideo(target.id, 0, {
|
||||
prompt: promptWithGuardrails(),
|
||||
duration: seconds,
|
||||
count: 1,
|
||||
first_image: activeMode.needsFirstFrame ? { kind: "keyframe", frame_idx: 0 } : null,
|
||||
last_image: activeMode.needsLastFrame && lastFrame ? { kind: "keyframe", frame_idx: lastFrame.index } : null,
|
||||
first_image: activeMode.needsImage ? { kind: "keyframe", frame_idx: 0 } : null,
|
||||
last_image: null,
|
||||
size: videoSize,
|
||||
model: videoModel,
|
||||
})
|
||||
@@ -397,24 +360,15 @@ export default function Home() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(activeMode.needsFirstFrame || activeMode.needsLastFrame) ? (
|
||||
<div className="mb-3 grid gap-2 sm:grid-cols-2">
|
||||
{activeMode.needsImage ? (
|
||||
<div className="mb-3 grid gap-2">
|
||||
<FrameUpload
|
||||
label="首帧"
|
||||
label="图片"
|
||||
preview={firstFramePreview}
|
||||
required={!!activeMode.needsFirstFrame}
|
||||
required
|
||||
onPick={() => firstInputRef.current?.click()}
|
||||
onClear={() => setUploadFile("first", null)}
|
||||
/>
|
||||
{activeMode.needsLastFrame ? (
|
||||
<FrameUpload
|
||||
label="尾帧"
|
||||
preview={lastFramePreview}
|
||||
required
|
||||
onPick={() => lastInputRef.current?.click()}
|
||||
onClear={() => setUploadFile("last", null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -425,14 +379,6 @@ export default function Home() {
|
||||
className="hidden"
|
||||
onChange={(event) => setUploadFile("first", event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<input
|
||||
ref={lastInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(event) => setUploadFile("last", event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => {
|
||||
@@ -505,12 +451,12 @@ export default function Home() {
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<span>{activeMode.needsFirstFrame ? "图片作为参考帧" : "只根据文字生成"}</span>
|
||||
<span>{activeMode.needsImage ? "图片作为视频参考" : "只根据文字生成"}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="/canvas/"
|
||||
href="/"
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-full border border-white/10 bg-white/6 px-4 text-sm font-semibold text-white/72 transition hover:border-cyan-200/24 hover:text-cyan-100"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
|
||||
@@ -55,8 +55,8 @@ const props = defineProps({
|
||||
|
||||
// Image role options | 图片角色选项
|
||||
const imageRoleOptions = [
|
||||
{ label: '首帧', key: 'first_frame_image' },
|
||||
{ label: '尾帧', key: 'last_frame_image' },
|
||||
{ label: '图片', key: 'first_frame_image' },
|
||||
{ label: '结束图', key: 'last_frame_image' },
|
||||
{ label: '参考图', key: 'input_reference' }
|
||||
]
|
||||
|
||||
@@ -66,7 +66,7 @@ const currentRole = computed(() => props.data?.imageRole || 'first_frame_image')
|
||||
// Current role label | 当前角色标签
|
||||
const currentRoleLabel = computed(() => {
|
||||
const option = imageRoleOptions.find(o => o.key === currentRole.value)
|
||||
return option?.label || '首帧'
|
||||
return option?.label || '图片'
|
||||
})
|
||||
|
||||
// Calculate bezier path | 计算贝塞尔路径
|
||||
@@ -95,7 +95,7 @@ const edgeStyle = computed(() => ({
|
||||
|
||||
// Handle role selection | 处理角色选择
|
||||
const handleRoleSelect = (role) => {
|
||||
// If selecting first_frame or last_frame, ensure uniqueness | 如果选择首帧或尾帧,确保唯一性
|
||||
// Keep endpoint image roles unique when advanced users edit edge roles.
|
||||
if (role === 'first_frame_image' || role === 'last_frame_image') {
|
||||
// Find other edges connected to the same target with the same role | 查找连接到同一目标且具有相同角色的其他边
|
||||
const sameTargetEdges = edges.value.filter(edge =>
|
||||
|
||||
@@ -932,7 +932,7 @@ const handleVideoGen = () => {
|
||||
sourceHandle: 'right',
|
||||
targetHandle: 'left',
|
||||
type: 'imageRole',
|
||||
data: { imageRole: 'first_frame_image' } // Default to first frame | 默认首帧
|
||||
data: { imageRole: 'first_frame_image' } // Default reference image | 默认参考图
|
||||
})
|
||||
|
||||
// Connect text node to config node | 连接文本节点到配置节点
|
||||
|
||||
@@ -82,16 +82,8 @@
|
||||
提示词 {{ connectedPrompt ? '✓' : '○' }}
|
||||
</span>
|
||||
<span class="px-2 py-0.5 rounded-full"
|
||||
:class="imagesByRole.firstFrame ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
|
||||
首帧 {{ imagesByRole.firstFrame ? '✓' : '○' }}
|
||||
</span>
|
||||
<span class="px-2 py-0.5 rounded-full"
|
||||
:class="imagesByRole.lastFrame ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
|
||||
尾帧 {{ imagesByRole.lastFrame ? '✓' : '○' }}
|
||||
</span>
|
||||
<span class="px-2 py-0.5 rounded-full"
|
||||
:class="imagesByRole.referenceImages.length > 0 ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
|
||||
参考图 {{ imagesByRole.referenceImages.length > 0 ? `✓ ${imagesByRole.referenceImages.length}` : '○' }}
|
||||
:class="connectedImages.length > 0 ? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' : 'bg-gray-100 text-gray-500 dark:bg-gray-800'">
|
||||
图片 {{ connectedImages.length > 0 ? `✓ ${connectedImages.length}` : '○' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -195,7 +187,7 @@ const connectedImages = computed(() => {
|
||||
edgeId: edge.id,
|
||||
url: sourceNode.data.url,
|
||||
base64: sourceNode.data.base64,
|
||||
role: edge.data?.imageRole || 'first_frame_image' // Default to first frame | 默认首帧
|
||||
role: edge.data?.imageRole || 'first_frame_image' // Default reference image | 默认参考图
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -385,12 +377,12 @@ const handleGenerate = async () => {
|
||||
params.prompt = prompt
|
||||
}
|
||||
|
||||
// Add first frame image | 添加首帧图片
|
||||
// Add primary reference image | 添加主参考图
|
||||
if (first_frame_image) {
|
||||
params.first_frame_image = first_frame_image
|
||||
}
|
||||
|
||||
// Add last frame image | 添加尾帧图片
|
||||
// Add optional ending reference image | 添加可选结束参考图
|
||||
if (last_frame_image) {
|
||||
params.last_frame_image = last_frame_image
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const routes = [
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory('/canvas/'),
|
||||
history: createWebHistory('/'),
|
||||
routes
|
||||
})
|
||||
|
||||
|
||||
@@ -164,22 +164,15 @@
|
||||
v-if="needsFirstFrame"
|
||||
class="px-3 py-1.5 text-xs rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-secondary)] cursor-pointer hover:text-[var(--text-primary)]"
|
||||
>
|
||||
{{ firstFrameFile ? `首帧 · ${firstFrameFile.name}` : '上传首帧' }}
|
||||
{{ firstFrameFile ? `图片 · ${firstFrameFile.name}` : '上传图片' }}
|
||||
<input type="file" accept="image/*" class="hidden" @change="event => handleFrameFile('first', event)" />
|
||||
</label>
|
||||
<label
|
||||
v-if="needsLastFrame"
|
||||
class="px-3 py-1.5 text-xs rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-secondary)] cursor-pointer hover:text-[var(--text-primary)]"
|
||||
>
|
||||
{{ lastFrameFile ? `尾帧 · ${lastFrameFile.name}` : '上传尾帧' }}
|
||||
<input type="file" accept="image/*" class="hidden" @change="event => handleFrameFile('last', event)" />
|
||||
</label>
|
||||
<button
|
||||
v-if="firstFrameFile || lastFrameFile"
|
||||
v-if="firstFrameFile"
|
||||
@click="clearFrameFiles"
|
||||
class="px-2 py-1.5 text-xs rounded-lg text-[var(--text-secondary)] hover:bg-[var(--bg-secondary)]"
|
||||
>
|
||||
清空帧
|
||||
清空图片
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
@@ -194,10 +187,7 @@
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="firstFramePreview" class="h-8 w-8 overflow-hidden rounded-md border border-[var(--border-color)] bg-[var(--bg-secondary)]">
|
||||
<img :src="firstFramePreview" alt="首帧" class="h-full w-full object-cover" />
|
||||
</span>
|
||||
<span v-if="lastFramePreview" class="h-8 w-8 overflow-hidden rounded-md border border-[var(--border-color)] bg-[var(--bg-secondary)]">
|
||||
<img :src="lastFramePreview" alt="尾帧" class="h-full w-full object-cover" />
|
||||
<img :src="firstFramePreview" alt="参考图片" class="h-full w-full object-cover" />
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -370,22 +360,17 @@ const showGrid = ref(true)
|
||||
const isProcessing = ref(false)
|
||||
|
||||
const creationModes = [
|
||||
{ id: 'text-video', label: '文生视频' },
|
||||
{ id: 'text-image', label: '文生图' },
|
||||
{ id: 'first-frame-video', label: '首帧生视频' },
|
||||
{ id: 'first-last-frame-video', label: '首尾帧生视频' }
|
||||
{ id: 'text-video', label: '文生视频' },
|
||||
{ id: 'image-video', label: '图生视频' }
|
||||
]
|
||||
const creationMode = ref('text-video')
|
||||
const creationMode = ref('text-image')
|
||||
const firstFrameFile = ref(null)
|
||||
const lastFrameFile = ref(null)
|
||||
const firstFramePreview = ref('')
|
||||
const lastFramePreview = ref('')
|
||||
const needsFirstFrame = computed(() => creationMode.value === 'first-frame-video' || creationMode.value === 'first-last-frame-video')
|
||||
const needsLastFrame = computed(() => creationMode.value === 'first-last-frame-video')
|
||||
const needsFirstFrame = computed(() => creationMode.value === 'image-video')
|
||||
const canSubmit = computed(() => {
|
||||
if (!chatInput.value.trim()) return false
|
||||
if (needsFirstFrame.value && !firstFrameFile.value) return false
|
||||
if (needsLastFrame.value && !lastFrameFile.value) return false
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -443,18 +428,14 @@ const nodeTypeOptions = [
|
||||
// Input placeholder | 输入占位符
|
||||
const inputPlaceholder = computed(() => {
|
||||
if (creationMode.value === 'text-image') return '写清楚画面、主体、构图、光线、比例和 SKG 产品露出方式'
|
||||
if (creationMode.value === 'first-frame-video') return '上传首帧后,写人物动作、镜头运动、产品细节保持和视频节奏'
|
||||
if (creationMode.value === 'first-last-frame-video') return '上传首帧和尾帧后,写中间如何过渡、动作节奏和产品细节保持'
|
||||
if (creationMode.value === 'image-video') return '上传图片后,写人物动作、镜头运动、产品细节保持和视频节奏'
|
||||
return '写清楚画面、动作、镜头、产品出现方式、视频比例和时长'
|
||||
})
|
||||
|
||||
const setCreationMode = (mode) => {
|
||||
creationMode.value = mode
|
||||
if (mode === 'text-video' || mode === 'text-image') {
|
||||
if (mode !== 'image-video') {
|
||||
clearFrameFiles()
|
||||
} else if (mode === 'first-frame-video') {
|
||||
lastFrameFile.value = null
|
||||
lastFramePreview.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,20 +454,13 @@ const handleFrameFile = (slot, event) => {
|
||||
if (firstFramePreview.value) URL.revokeObjectURL(firstFramePreview.value)
|
||||
firstFrameFile.value = file
|
||||
firstFramePreview.value = url
|
||||
} else {
|
||||
if (lastFramePreview.value) URL.revokeObjectURL(lastFramePreview.value)
|
||||
lastFrameFile.value = file
|
||||
lastFramePreview.value = url
|
||||
}
|
||||
}
|
||||
|
||||
const clearFrameFiles = () => {
|
||||
if (firstFramePreview.value) URL.revokeObjectURL(firstFramePreview.value)
|
||||
if (lastFramePreview.value) URL.revokeObjectURL(lastFramePreview.value)
|
||||
firstFrameFile.value = null
|
||||
lastFrameFile.value = null
|
||||
firstFramePreview.value = ''
|
||||
lastFramePreview.value = ''
|
||||
}
|
||||
|
||||
// Add new node | 添加新节点
|
||||
@@ -575,7 +549,7 @@ const onConnect = (params) => {
|
||||
addEdge({
|
||||
...params,
|
||||
type: 'imageRole',
|
||||
data: { imageRole: 'first_frame_image' } // Default to first frame | 默认首帧
|
||||
data: { imageRole: 'first_frame_image' } // Default reference image | 默认参考图
|
||||
})
|
||||
} else if (sourceNode?.type === 'text' && targetNode?.type === 'imageConfig') {
|
||||
// Use promptOrder edge type | 使用提示词顺序边类型
|
||||
@@ -773,25 +747,15 @@ const sendMessage = async () => {
|
||||
const firstId = addNode('image', { x: baseX, y: baseY + 160 }, {
|
||||
url: dataUrl,
|
||||
base64: dataUrl,
|
||||
label: '首帧'
|
||||
label: '参考图'
|
||||
})
|
||||
imageNodeIds.push({ id: firstId, role: 'first_frame_image' })
|
||||
promptY = baseY - 140
|
||||
updateNode(textNodeId, { zIndex: 5 })
|
||||
}
|
||||
|
||||
if (needsLastFrame.value && lastFrameFile.value) {
|
||||
const dataUrl = await fileToDataUrl(lastFrameFile.value)
|
||||
const lastId = addNode('image', { x: baseX, y: baseY + 440 }, {
|
||||
url: dataUrl,
|
||||
base64: dataUrl,
|
||||
label: '尾帧'
|
||||
})
|
||||
imageNodeIds.push({ id: lastId, role: 'last_frame_image' })
|
||||
}
|
||||
|
||||
const videoConfigNodeId = addNode('videoConfig', { x: videoX, y: promptY }, {
|
||||
label: creationMode.value === 'text-video' ? '文生视频' : '生视频',
|
||||
label: creationMode.value === 'text-video' ? '文生视频' : '图生视频',
|
||||
autoExecute: true
|
||||
})
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
base: '/canvas/',
|
||||
base: '/',
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "pnpm build:canvas && next build",
|
||||
"build:canvas": "cd canvas-app && pnpm build && node ../scripts/sync-canvas-dist.mjs",
|
||||
"build": "pnpm build:canvas && next build && node scripts/sync-canvas-root.mjs",
|
||||
"build:canvas": "cd canvas-app && pnpm build",
|
||||
"build:next": "next build",
|
||||
"dev": "next dev -p 4290",
|
||||
"dev:canvas": "cd canvas-app && pnpm dev --host 0.0.0.0 --port 4292",
|
||||
|
||||
28
web/scripts/sync-canvas-root.mjs
Normal file
28
web/scripts/sync-canvas-root.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import { cp, mkdir, readdir, rm } from "node:fs/promises"
|
||||
import { existsSync } from "node:fs"
|
||||
import { dirname, resolve } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
const webRoot = resolve(here, "..")
|
||||
const source = resolve(webRoot, "canvas-app", "dist")
|
||||
const target = resolve(webRoot, "out")
|
||||
|
||||
if (!existsSync(source)) {
|
||||
throw new Error(`Canvas build output missing: ${source}`)
|
||||
}
|
||||
|
||||
if (!existsSync(target)) {
|
||||
throw new Error(`Next export output missing: ${target}`)
|
||||
}
|
||||
|
||||
await mkdir(target, { recursive: true })
|
||||
|
||||
for (const entry of await readdir(source)) {
|
||||
const from = resolve(source, entry)
|
||||
const to = resolve(target, entry)
|
||||
await rm(to, { recursive: true, force: true })
|
||||
await cp(from, to, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
await rm(resolve(target, "canvas"), { recursive: true, force: true })
|
||||
Reference in New Issue
Block a user