diff --git a/RULES.md b/RULES.md
index 6be0507..0e1b424 100644
--- a/RULES.md
+++ b/RULES.md
@@ -134,6 +134,7 @@
- `FEISHU_OAUTH_SCOPE`:飞书 OAuth 授权范围;默认空值,按飞书应用后台已开权限执行。
- `FEISHU_ALLOWED_EMAIL_DOMAINS` / `FEISHU_ALLOWED_EMAILS` / `FEISHU_ALLOWED_TENANT_KEYS`:可选飞书账号白名单;留空时由飞书应用可见范围控制。
- `AUTH_DATA_ISOLATION_ENABLED`:多用户数据隔离开关,生产保持 `true`;新建 `Job` / `AgentRun` 会写入当前登录用户 owner,列表和详情访问只返回本人数据。
+- `VIDEO_QUEUE_MAX_CONCURRENT` / `VIDEO_QUEUE_MAX_CONCURRENT_PER_USER`:视频生成进程内队列并发上限,生产默认全局同时 2 个、单用户同时 1 个;同一用户连续提交会排队,其他用户仍可获得执行机会。当前队列不依赖 Redis,API 容器重启会把未完成视频标记为失败并提示重新生成。
- `FFMPEG_BIN` / `FFPROBE_BIN`:可选本地媒体二进制路径;本机 Homebrew ffmpeg 动态库损坏时,后端会自动跳过不可用的 PATH 版本并尝试本机静态 ffmpeg 备选,生产仍建议使用系统 ffmpeg/ffprobe
- 生产环境变量:服务器只使用 `deploy/.env.production`,模板为 `deploy/.env.production.example`;真实 Key 不入库
- 同步生产代码时必须排除服务器真实 `deploy/.env.production`,只同步 `deploy/.env.production.example`;网页登录密码、session secret、ASR/API Key 只保留在服务器环境文件和 `/root/skg-marketing-studio-login.txt`
diff --git a/api/.env.example b/api/.env.example
index 96baa14..b56fe99 100644
--- a/api/.env.example
+++ b/api/.env.example
@@ -9,6 +9,8 @@ WEB_AUTH_SESSION_SECRET=
WEB_AUTH_COOKIE_NAME=skg_marketing_session
WEB_AUTH_COOKIE_SECURE=false
AUTH_DATA_ISOLATION_ENABLED=true
+VIDEO_QUEUE_MAX_CONCURRENT=2
+VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1
# 飞书免登录(OAuth)。生产回调地址需同步配置到飞书开放平台应用安全设置。
FEISHU_APP_ID=
diff --git a/api/main.py b/api/main.py
index b15dcfe..a9e143d 100644
--- a/api/main.py
+++ b/api/main.py
@@ -16,6 +16,7 @@ import threading
import time
import uuid
from contextlib import asynccontextmanager
+from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from urllib.parse import urlencode
@@ -269,6 +270,8 @@ FEISHU_ALLOWED_EMAIL_DOMAINS = os.getenv("FEISHU_ALLOWED_EMAIL_DOMAINS", "").str
FEISHU_ALLOWED_EMAILS = os.getenv("FEISHU_ALLOWED_EMAILS", "").strip()
FEISHU_ALLOWED_TENANT_KEYS = os.getenv("FEISHU_ALLOWED_TENANT_KEYS", "").strip()
AUTH_DATA_ISOLATION_ENABLED = os.getenv("AUTH_DATA_ISOLATION_ENABLED", "true").strip().lower() not in {"0", "false", "no", "off"}
+VIDEO_QUEUE_MAX_CONCURRENT = max(1, int(os.getenv("VIDEO_QUEUE_MAX_CONCURRENT", "2").strip() or "2"))
+VIDEO_QUEUE_MAX_CONCURRENT_PER_USER = max(1, int(os.getenv("VIDEO_QUEUE_MAX_CONCURRENT_PER_USER", "1").strip() or "1"))
PASSWORD_AUTH_CONFIGURED = bool(WEB_AUTH_USERNAME and WEB_AUTH_PASSWORD and WEB_AUTH_SESSION_SECRET)
FEISHU_AUTH_CONFIGURED = bool(FEISHU_APP_ID and FEISHU_APP_SECRET and WEB_AUTH_SESSION_SECRET)
WEB_AUTH_CONFIGURED = bool(PASSWORD_AUTH_CONFIGURED or FEISHU_AUTH_CONFIGURED)
@@ -466,6 +469,9 @@ class GeneratedVideo(BaseModel):
progress: int = 0
error: str = ""
created_at: float = 0.0
+ queue_position: int = 0
+ queue_size: int = 0
+ queue_message: str = ""
class VideoSourceRef(BaseModel):
@@ -907,6 +913,20 @@ AUDIO_WORKERS_RUNNING: set[str] = set()
AUDIO_WORKERS_LOCK = threading.Lock()
+@dataclass
+class VideoQueueTask:
+ job_id: str
+ video_id: str
+ owner_id: str
+ args: tuple
+ created_at: float
+
+
+VIDEO_QUEUE: list[VideoQueueTask] = []
+VIDEO_QUEUE_RUNNING: dict[str, VideoQueueTask] = {}
+VIDEO_QUEUE_LOCK = threading.Lock()
+
+
def ensure_auth_configured() -> None:
if not WEB_AUTH_CONFIGURED:
raise HTTPException(503, "WEB_AUTH_SESSION_SECRET 以及账号密码或飞书 OAuth 未配置")
@@ -1777,6 +1797,124 @@ def update_generated_video(job_id: str, video_id: str, **kw) -> None:
update(job, generated_videos=updated)
+def generated_video_exists(job_id: str, video_id: str) -> bool:
+ job = JOBS.get(job_id)
+ return bool(job and any(v.id == video_id for v in job.generated_videos))
+
+
+def _video_queue_owner(job: Job) -> str:
+ return (job.owner_id or f"job:{job.id}").strip()
+
+
+def _video_queue_owner_running_locked(owner_id: str) -> bool:
+ return any(task.owner_id == owner_id for task in VIDEO_QUEUE_RUNNING.values())
+
+
+def _refresh_video_queue_positions_locked() -> None:
+ queue_size = len(VIDEO_QUEUE)
+ for position, task in enumerate(VIDEO_QUEUE, start=1):
+ if not generated_video_exists(task.job_id, task.video_id):
+ continue
+ if _video_queue_owner_running_locked(task.owner_id):
+ message = "排队中 · 你的上一个视频生成中"
+ elif position == 1:
+ message = "排队中 · 即将开始"
+ else:
+ message = f"排队中 · 前方 {position - 1} 个任务"
+ update_generated_video(
+ task.job_id,
+ task.video_id,
+ status="queued",
+ progress=0,
+ queue_position=position,
+ queue_size=queue_size,
+ queue_message=message,
+ )
+
+
+def _video_queue_running_by_owner_locked() -> dict[str, int]:
+ counts: dict[str, int] = {}
+ for task in VIDEO_QUEUE_RUNNING.values():
+ counts[task.owner_id] = counts.get(task.owner_id, 0) + 1
+ return counts
+
+
+def dispatch_video_queue() -> None:
+ tasks_to_start: list[VideoQueueTask] = []
+ with VIDEO_QUEUE_LOCK:
+ running_by_owner = _video_queue_running_by_owner_locked()
+ while len(VIDEO_QUEUE_RUNNING) < VIDEO_QUEUE_MAX_CONCURRENT:
+ selected_index = -1
+ for index, task in enumerate(VIDEO_QUEUE):
+ if not generated_video_exists(task.job_id, task.video_id):
+ selected_index = index
+ break
+ if running_by_owner.get(task.owner_id, 0) < VIDEO_QUEUE_MAX_CONCURRENT_PER_USER:
+ selected_index = index
+ break
+ if selected_index < 0:
+ break
+
+ task = VIDEO_QUEUE.pop(selected_index)
+ if not generated_video_exists(task.job_id, task.video_id):
+ continue
+
+ VIDEO_QUEUE_RUNNING[task.video_id] = task
+ running_by_owner[task.owner_id] = running_by_owner.get(task.owner_id, 0) + 1
+ update_generated_video(
+ task.job_id,
+ task.video_id,
+ status="in_progress",
+ progress=1,
+ queue_position=0,
+ queue_size=len(VIDEO_QUEUE),
+ queue_message="准备素材…",
+ )
+ tasks_to_start.append(task)
+ _refresh_video_queue_positions_locked()
+
+ for task in tasks_to_start:
+ threading.Thread(target=_run_video_queue_task, args=(task,), daemon=True).start()
+
+
+def _run_video_queue_task(task: VideoQueueTask) -> None:
+ try:
+ if generated_video_exists(task.job_id, task.video_id):
+ render_storyboard_video(*task.args)
+ finally:
+ with VIDEO_QUEUE_LOCK:
+ VIDEO_QUEUE_RUNNING.pop(task.video_id, None)
+ _refresh_video_queue_positions_locked()
+ dispatch_video_queue()
+
+
+def enqueue_video_task(job: Job, video_id: str, task_args: tuple) -> None:
+ task = VideoQueueTask(
+ job_id=job.id,
+ video_id=video_id,
+ owner_id=_video_queue_owner(job),
+ args=task_args,
+ created_at=time.time(),
+ )
+ with VIDEO_QUEUE_LOCK:
+ VIDEO_QUEUE.append(task)
+ _refresh_video_queue_positions_locked()
+ dispatch_video_queue()
+
+
+def cancel_queued_video_task(job_id: str, video_id: str) -> bool:
+ removed = False
+ with VIDEO_QUEUE_LOCK:
+ before = len(VIDEO_QUEUE)
+ VIDEO_QUEUE[:] = [task for task in VIDEO_QUEUE if not (task.job_id == job_id and task.video_id == video_id)]
+ removed = len(VIDEO_QUEUE) != before
+ if removed:
+ _refresh_video_queue_positions_locked()
+ if removed:
+ dispatch_video_queue()
+ return removed
+
+
@asynccontextmanager
async def lifespan(_: FastAPI):
try:
@@ -1838,6 +1976,23 @@ async def lifespan(_: FastAPI):
recovered_frames.append(f)
if subject_generation_interrupted:
update(job, frames=recovered_frames, message="服务重启 · 上次主体生成已中断,可重新生成")
+ video_generation_interrupted = False
+ recovered_videos = []
+ for video in job.generated_videos:
+ if video.status in {"queued", "in_progress"}:
+ recovered_videos.append(video.model_copy(update={
+ "status": "failed",
+ "progress": 100,
+ "error": "服务重启 · 上次视频生成已中断,请重新生成",
+ "queue_position": 0,
+ "queue_size": 0,
+ "queue_message": "",
+ }))
+ video_generation_interrupted = True
+ else:
+ recovered_videos.append(video)
+ if video_generation_interrupted:
+ update(job, generated_videos=recovered_videos, message="服务重启 · 上次视频生成已中断,请重新生成")
JOBS[p.name] = job
except Exception:
pass
@@ -7850,7 +8005,7 @@ def render_storyboard_video(
product_img = out_dir / f"product_reference_{i}.jpg"
prepare_video_reference(product_ref_path, product_img)
prepared_product_imgs.append(product_img)
- update_generated_video(job_id, local_id, status="in_progress", progress=5)
+ update_generated_video(job_id, local_id, status="in_progress", progress=5, queue_message="准备素材…")
with httpx.Client(timeout=120) as client:
payload = {"model": model, "prompt": prompt, "size": size}
payload[VIDEO_DURATION_FIELD] = seconds
@@ -7880,7 +8035,14 @@ def render_storyboard_video(
status = normalize_video_status(data.get("status"))
progress = video_progress(data, 5)
direct_url = video_url_from_response(data)
- update_generated_video(job_id, local_id, provider_id=video_api_id, status=status, progress=progress)
+ update_generated_video(
+ job_id,
+ local_id,
+ provider_id=video_api_id,
+ status=status,
+ progress=progress,
+ queue_message="生成中…" if status in {"queued", "in_progress"} else "",
+ )
deadline = time.time() + VIDEO_POLL_TIMEOUT_SECONDS
while status in {"queued", "in_progress"} and time.time() < deadline:
@@ -7891,10 +8053,16 @@ def render_storyboard_video(
status = normalize_video_status(pdata.get("status"))
progress = video_progress(pdata, progress)
direct_url = video_url_from_response(pdata) or direct_url
- update_generated_video(job_id, local_id, status=status, progress=progress)
+ update_generated_video(
+ job_id,
+ local_id,
+ status=status,
+ progress=progress,
+ queue_message="生成中…" if status in {"queued", "in_progress"} else "",
+ )
if status != "completed":
- update_generated_video(job_id, local_id, status="failed", error=f"video status: {status}", progress=progress)
+ update_generated_video(job_id, local_id, status="failed", error=f"video status: {status}", progress=progress, queue_message="")
return
download_generated_video(client, base, headers, video_api_id, direct_url, out_mp4)
@@ -7905,9 +8073,12 @@ def render_storyboard_video(
progress=100,
url=f"/jobs/{job_id}/storyboard-videos/{local_id}.mp4",
error="",
+ queue_position=0,
+ queue_size=0,
+ queue_message="",
)
except Exception as e:
- update_generated_video(job_id, local_id, status="failed", error=str(e)[:500])
+ update_generated_video(job_id, local_id, status="failed", error=str(e)[:500], queue_message="")
@app.post("/jobs/{job_id}/frames/{idx}/storyboard/quick-plan", response_model=StoryboardScene)
@@ -8015,6 +8186,7 @@ def _enqueue_storyboard_videos(job: Job, frame: KeyFrame, req: GenerateStoryboar
source_ref = None
items: list[GeneratedVideo] = []
ids: list[str] = []
+ queued_tasks: list[tuple[str, tuple]] = []
for i in range(count):
local_id = uuid.uuid4().hex[:12]
ids.append(local_id)
@@ -8033,13 +8205,13 @@ def _enqueue_storyboard_videos(job: Job, frame: KeyFrame, req: GenerateStoryboar
duration=float(seconds),
progress=0,
created_at=time.time(),
+ queue_message="排队中…",
))
task_args = (job.id, local_id, "", ref_path, variant_prompt, model, seconds, video_size, source_ref, last_ref_path, reference_ref_paths, primary_role)
- if bg is not None:
- bg.add_task(render_storyboard_video, *task_args)
- else:
- threading.Thread(target=render_storyboard_video, args=task_args, daemon=True).start()
+ queued_tasks.append((local_id, task_args))
update(job, generated_videos=items + job.generated_videos, message=f"视频候选已提交 · 分镜 {frame.index + 1} · {count} 条")
+ for local_id, task_args in queued_tasks:
+ enqueue_video_task(job, local_id, task_args)
return ids
@@ -9482,6 +9654,7 @@ def delete_storyboard_video(job_id: str, video_id: str) -> Job:
kept = [v for v in job.generated_videos if v.id != video_id]
if len(kept) == before:
raise HTTPException(404, "generated video not found")
+ cancel_queued_video_task(job_id, video_id)
out_dir = job_dir(job_id) / "storyboard_videos" / video_id
if out_dir.exists():
try:
diff --git a/deploy/.env.production.example b/deploy/.env.production.example
index cf44f29..2ec829c 100644
--- a/deploy/.env.production.example
+++ b/deploy/.env.production.example
@@ -16,6 +16,8 @@ WEB_AUTH_SESSION_SECRET=
WEB_AUTH_COOKIE_NAME=skg_marketing_session
WEB_AUTH_COOKIE_SECURE=true
AUTH_DATA_ISOLATION_ENABLED=true
+VIDEO_QUEUE_MAX_CONCURRENT=2
+VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1
# Feishu OAuth login. Register this callback in the Feishu developer console:
# https://marketing.skg.com/api/auth/feishu/callback
diff --git a/docs/source-analysis.html b/docs/source-analysis.html
index e8bba28..1073201 100644
--- a/docs/source-analysis.html
+++ b/docs/source-analysis.html
@@ -581,8 +581,8 @@
03
选择模型和规格
GET /health 返回 image_options、image_size_options、video_options、video_size_options 和 video_duration_options;首页按当前生成方式切换模型、图片尺寸、视频画幅和视频时长。
04
上传帧 / 空白任务
POST /creative/jobs/image 创建 0 号关键帧;首尾帧模式再用 /frames/upload 上传尾帧。
05
手写提示词
首页不再生成营销文案或自动展开产品 / 人群配置,用户直接写图片或视频提示词。
- 06
生成图片 / 视频
generateImage 传 mode=text、图片模型和图片尺寸;generateStoryboardVideo 提交文本、模型、画幅、时长、可选 first_image 和可选 last_image。
- 07
结果沉淀
首页只在对话框下方显示最新图片或视频;所有图片/视频缩略图继续复用 MediaAssetTile。
+ 06
生成图片 / 视频
generateImage 传 mode=text、图片模型和图片尺寸;generateStoryboardVideo 提交文本、模型、画幅、时长、可选 first_image 和可选 last_image。视频提交后先写入 queued 占位,再由后端队列按并发上限启动。
+ 07
结果沉淀
首页只在对话框下方显示最新图片或视频;视频会显示排队位置、生成进度、完成播放或失败可重试状态;所有图片/视频缩略图继续复用 MediaAssetTile。
08
详情页
/detail/?job=<id> 展示参考图、全量生成图、视频候选、提示词和营销图文,并支持继续生成。
09
高级复刻
旧 AdRecreationBoard 与 /agent/ 作为高级入口保留,不再是默认路径。
@@ -597,7 +597,7 @@
web/next.config.mjs | Next.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 eslint 顶层配置,避免本地 dev 出现配置 Issue 提示。 |
web/app/globals.css | 全局主题变量、登录页视觉样式、信息流工作台玻璃拟态 token、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。工作台在 skg-board-theme 内按 Figma 本地 MCP 参考改成黑灰玻璃系统:深灰背景、#383838 胶囊侧栏、rgba(255,255,255,.1) 玻璃面、backdrop-filter: blur(5px)、20px 圆角、10px 10px 10px rgba(0,0,0,.3) 阴影和绿黄状态色;新增 skg-board-shell、skg-board-rail、skg-glass-card、skg-glass-card--flat、skg-status-orb 等样式。侧栏改为跟随视口拉满工作台可用高度的悬停胶囊,桌面最小 600px,展开时在同一侧栏内承载素材输入抽屉。明暗主题已分开维护 shell、panel、glass、stat、action 和音频波形 token;暗色压低灰雾和面板底色,明亮模式改为暖白工作台,避免指标卡、按钮和波形继续残留黑底/白线;顶部指标卡增加紫、黄绿、琥珀、青绿、绿色光斑变量,接近原版多色玻璃卡效果。主/次按钮、指标卡和空状态继续走统一类,避免各板块散写不同玻璃效果。 |
- web/app/page.tsx | 当前默认首页:单对话框生成台。页面只保留顶部极轻量品牌和中央对话框,四个主按钮是文生视频、文生图、首帧生视频、首尾帧生视频;首帧 / 首尾帧模式才显示上传位,用户必须手写提示词后点击生成。页面启动时读取 getRuntimeHealth,按 image_options / video_options 显示模型下拉,按 image_size_options 显示文生图尺寸,按 video_size_options 和 video_duration_options 显示视频画幅和真实可用时长;当前 Doubao / Seedance 生产链路最多暴露 15 秒,不再把 30 秒作为单条可选项。每次生成都会创建新的轻量 Job,文生图调用 generateImage 并传图片模型和尺寸,视频调用 generateStoryboardVideo 并传视频模型、画幅和时长;首尾帧模式先用 createCreativeImageJob 保存首帧,再用 uploadReferenceFrame 保存尾帧并以 last_image 提交。首页视频提交后每 2.6 秒轮询 getJob,完成后结果卡直接显示可播放 controls,避免完成视频只是静态首帧看起来“没有效果”。图片/视频缩略图统一复用 MediaAssetTile,支持顶层 hover 预览和删除;旧 TK 复刻工作台组件仍保留在 web/components/ad-recreation-board.tsx,但不再作为默认首页渲染。 |
+ web/app/page.tsx | 当前默认首页:单对话框生成台。页面只保留顶部极轻量品牌和中央对话框,四个主按钮是文生视频、文生图、首帧生视频、首尾帧生视频;首帧 / 首尾帧模式才显示上传位,用户必须手写提示词后点击生成。页面启动时读取 getRuntimeHealth,按 image_options / video_options 显示模型下拉,按 image_size_options 显示文生图尺寸,按 video_size_options 和 video_duration_options 显示视频画幅和真实可用时长;当前 Doubao / Seedance 生产链路最多暴露 15 秒,不再把 30 秒作为单条可选项。每次生成都会创建新的轻量 Job,文生图调用 generateImage 并传图片模型和尺寸,视频调用 generateStoryboardVideo 并传视频模型、画幅和时长;首尾帧模式先用 createCreativeImageJob 保存首帧,再用 uploadReferenceFrame 保存尾帧并以 last_image 提交。首页视频提交后每 2.6 秒轮询 getJob,结果卡会把 queued 显示为“排队中 / 前方 N 个任务 / 你的上一个视频生成中”,把 in_progress 显示为生成进度,完成后直接显示可播放 controls,避免完成视频只是静态首帧看起来“没有效果”。图片/视频缩略图统一复用 MediaAssetTile,支持顶层 hover 预览和删除;旧 TK 复刻工作台组件仍保留在 web/components/ad-recreation-board.tsx,但不再作为默认首页渲染。 |
web/app/detail/page.tsx | 任务详情页:静态导出路由 /detail/?job=<id>,通过 query 读取 job id,调用 getJob 恢复同一任务。页面展示参考图、全部生成图、视频候选、营销图文方案和历史提示词,可继续调用 generateImage、generateStoryboardVideo、generateCreativeCopy,并支持删除图片/视频。该页继续依赖后端 owner 过滤,用户不能通过切换 URL 读取别人的任务。 |
web/app/agent/page.tsx | 新增一键出片终端页:只保留 TikTok 链接、产品图上传、实时 Agent Terminal 和最终成片播放器;通过 POST /agent-runs 创建受限后台状态机任务,通过 GET /agent-runs/{id} 轮询日志、进度、审片图和最终 mp4。该页不替代旧工作台深度编辑能力,只承接“用户只看成品”的快速出片主路径。 |
web/components/ad-recreation-board.tsx | 信息流广告复刻工作表:外壳按 Figma “Dashboard Glassmorphism”参考整体改为黑灰玻璃工作台,WorkbenchRail 默认收起为拉满工作台可用高度的 65px 胶囊工具条,只保留真实动作入口:素材任务、资源库和主题切换;鼠标移入或键盘聚焦侧栏时,skg-board-rail 切换 is-open 并从左侧展开 320px 素材输入抽屉,点击素材任务按钮可固定展开。顶部从登录页式 brand strip 改为轻量生产控制条,左侧显示 未来健康 · 营销内容工作台、主标题 营销内容工作台 和副标题 信息流广告复刻生产,右侧保留素材/当前/视频/文案段/背景音指标,并用紫、黄绿、琥珀、青绿、绿色光斑卡片增强原版玻璃拟态的颜色层次。主内容只保留源视频拆解工作区,素材输入的数据流、接口、模型调用和状态推导不变。工作台外层已取消 1800x1000 固定基准画布、ResizeObserver 档位计算和 CSS zoom 整页缩放,改为正常流式桌面容器:min-height: 100vh、width: 100%、max-width: 1920px,并保留 min-width: 1280px 作为最低操作宽度;核心列宽不再被整体缩放,文字、图标和边线由浏览器原生字号渲染,避免小数缩放导致发虚。buildWorkflowSteps 仍统一生成 01-09 流程顺序、状态和判定依据,WorkflowStepBadge / PipelineLane / 分镜列标题也继续共用同一套编号;但完整 WorkflowOrderBar、右侧素材/视频/音频/文案/参考帧需求 chips、文案依据下拉和“音频文案、抽帧参考、主体重构、产品素材池”四个状态条不再默认渲染在工作区顶部。侧边素材输入面板只负责链接/上传和任务切换,不再重复放横版原视频预览;主画布源视频工作区直接进入核心操作。讲话人、节奏和背景音分析仍写入 AudioScript,但不再作为“音频解析结果”卡片默认渲染;源视频工作区撤销右上“布局调节”临时面板,不再读取或写入 localStorage["skg-source-workspace-layout:v1"];当前固定为左侧原视频列 380px、9:16 视频高 500px、逐句时间轴最大高 360px、参考帧池 140px、主体空态 78px;转换层不再固定拉长,按内容自然高度显示,内容过多时最多到 560px 后在自身区域内滚动;上方是按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧,播放器下方是逐句时间轴,英文和中文都最多显示两行;右侧上方是无标题的波形与切点参考框,下方主体链路改为上方参考帧池 + 转换层、下方主体元素结果栏。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,并通过 skg-audio-waveform 读取明暗主题变量,避免明亮模式继续使用黑底/白色波形;顶部把低/中/高密度按钮和当前播放秒数、总时长、鼠标指针停点秒数直接放在波形上方。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频波形下方同框渲染无标题的 TimelineFilmstrip 临时画面胶片,前端按低/中/高密度从源视频 canvas 截取预览缩略图,并按 frame.time / duration 的百分比定位到和波形同一条时间轴上;波形与胶片之间不显示分隔横线,胶片轨道贴近波形,缩略图轻微上下错落并倾斜重叠排列,hover 时用同一张胶片卡在原位置生成固定顶层克隆,约 4.8 倍放大并自动限制在视口内,避免被工作区、滚动容器或相邻面板遮挡;单击胶片只跳转视频时间点,不写入任务数据,双击胶片或拖进参考帧池时才调用手动抽帧并正式加入 job.frames,已加入的胶片显示“已添加”;胶片预览按 job、视频、密度和时长缓存,未切换低/中/高时返回页面不重新扫视频。参考帧池的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化;缩略图按竖版完整比例显示不裁切,点选状态直接叠在参考帧池缩略图上,鼠标停留会通过固定浮层放大展示完整帧。转换层改为轻量对话式生图确认区并拿到主操作宽度:左侧参考帧可点 + 或直接拖入转换层,本地图片拖入会通过 uploadReferenceFrame 保存为参考帧;转换层上方是参考输入区,下方不再显示当前要求摘要、保留元素副本或对话记录计数,只保留带张数控件的“发送消息”输入 composer;模型确认类回复不再逐条展示,生成英文 prompt 后发送区主按钮直接切换为“确认生成 N 张”,点击后才调用主体套图生成。主体元素结果栏在转换层下方,空态只占紧凑提示;有结果时按每次生成的套图文件夹显示,左侧横向展示当前套图,右侧切换套图包,保留单张重生和删除;缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 replace_views=true 替换同一视角。前端对卡通重构传 subject_style=cartoon_subject,其他方向传 subject_style=source_actor;形象锁定或自主描述空文本可走 reconstruction_mode=same,其他参考创新走 similar 并把参考帧作为 /images/edits 的 image refs 一起提交。主体生成完成后会形成 subject_consensus_brief。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写。每条音频分镜默认是左侧三字段、右侧横向视频候选轨;高级区仍保留首尾帧 prompt、产品出现方式和旧 6 字段。ModelTrace 会在音频解析、产品识别/补图、主体重构视图包、脚本改写等入口旁直接展示模型名;生图入口会显示 gpt-image-2 / gemini-3-pro-image-preview 链路和短时熔断规则,点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 |
@@ -619,7 +619,7 @@
web/components/product-library-picker.tsx | SKG 内置白底产品图库选择器:搜索、品类筛选、预览尺寸,并把库内图片复制为当前 job 的 asset。 |
web/components/storyboard-bar.tsx | 顶部分镜编排条:展示选入编排的关键帧,并作为唯一分镜导航。 |
web/components/storyboard-workbench.tsx | 顶部分镜编排条下方的明细区:4 图槽、改造目标、时长、自动保存。 |
- web/lib/api.ts | 前端类型和 API client,是前后端数据契约镜像;RuntimeHealth / RuntimeModels 读取 GET /health,把 ASR、翻译、视觉、图像、视频等模型名作为前端模型标注的真源,并同步承接 RuntimeSizeOption、图片尺寸、视频画幅、视频时长和最大单条秒数。默认首页主要使用 createCreativeImageJob、uploadReferenceFrame、generateImage 和 generateStoryboardVideo;generateImage 请求体现在可传 size。generateCreativeCopy 仍保留给详情页和后续高级能力。资源库相关类型和 CRUD/use/copy-to-job 函数继续保留给旧工作台和后续资源中心。 |
+ web/lib/api.ts | 前端类型和 API client,是前后端数据契约镜像;RuntimeHealth / RuntimeModels 读取 GET /health,把 ASR、翻译、视觉、图像、视频等模型名作为前端模型标注的真源,并同步承接 RuntimeSizeOption、图片尺寸、视频画幅、视频时长和最大单条秒数。GeneratedVideo 额外承接 queue_position、queue_size 和 queue_message,用于首页和后续个人画布显示视频生成队列。默认首页主要使用 createCreativeImageJob、uploadReferenceFrame、generateImage 和 generateStoryboardVideo;generateImage 请求体现在可传 size。generateCreativeCopy 仍保留给详情页和后续高级能力。资源库相关类型和 CRUD/use/copy-to-job 函数继续保留给旧工作台和后续资源中心。 |
@@ -627,7 +627,7 @@
后端核心
- api/main.py | FastAPI 单文件后端:登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回;同时承载全局 prompt_library 和 asset_library 的磁盘索引、CRUD、删除保护和复制到 job API。轻量创作入口 POST /creative/jobs/image 把上传图片或空白底图写成一个只有 0 号关键帧的 Job,让首页直接复用生图/生视频接口;该接口兼容无 body / JSON 空对象 / 正常 multipart 上传,避免无首帧文生图或文生视频时空 multipart 被 FastAPI 在业务前置解析阶段拒绝;/health 返回 image_options、image_size_options、video_options、video_size_options、video_duration_options 和 video_max_duration_seconds;/frames/{idx}/generate 的 model 字段用于图片模型偏好,size 字段用于图片输出尺寸;/storyboard/video 继续使用 model 字段选择视频别名,并先校验画幅与时长能力边界。旧 AgentRun 一键出片状态机、TK 复刻接口和 POST /creative/copy 继续保留。 |
+ api/main.py | FastAPI 单文件后端:登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回;同时承载全局 prompt_library 和 asset_library 的磁盘索引、CRUD、删除保护和复制到 job API。轻量创作入口 POST /creative/jobs/image 把上传图片或空白底图写成一个只有 0 号关键帧的 Job,让首页直接复用生图/生视频接口;该接口兼容无 body / JSON 空对象 / 正常 multipart 上传,避免无首帧文生图或文生视频时空 multipart 被 FastAPI 在业务前置解析阶段拒绝;/health 返回 image_options、image_size_options、video_options、video_size_options、video_duration_options 和 video_max_duration_seconds;/frames/{idx}/generate 的 model 字段用于图片模型偏好,size 字段用于图片输出尺寸;/storyboard/video 继续使用 model 字段选择视频别名,并先校验画幅与时长能力边界,然后把 GeneratedVideo 写成 queued 占位并进入进程内视频队列。队列默认 VIDEO_QUEUE_MAX_CONCURRENT=2、VIDEO_QUEUE_MAX_CONCURRENT_PER_USER=1,同一用户连续提交不会占满全局并发;排队任务会回写 queue_position、queue_size、queue_message。旧 AgentRun 一键出片状态机、TK 复刻接口和 POST /creative/copy 继续保留。 |
api/product_library/skg-products | 内置 SKG 白底产品图库:manifest.json 记录从桌面产品图筛出的 gallery 白底图和桌面 4 张产品角度图,images/ 存 45 张参考图。 |
api/character_library/skg-characters | 内置相似主体形象库:从桌面 5 套策划形象导入,manifest.json 记录运动阳光男、都市型男、优雅白领女、运动辣妹、绅士大叔,每套含 7 张透明骨架参考图和一段 prompt_brief。相似主体生成时优先使用文字 brief 作为创意方向,避免把内置图作为强参考图复制。 |
asset_library/ | 全局素材库目录,和 jobs/ 平级,不写入任何 job state。四类目录为 subjects、products、scenes、videos;每个素材自带 manifest.json 和图片/视频文件,index.json 只是启动扫描重建出来的缓存。库素材选用到 job 时必须复制文件到 jobs/<jobId>/assets 或 storyboard-videos,禁止直接保存 library 引用。 |
@@ -1183,6 +1183,19 @@ ProductRefStateItem {
变更记录
这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。
+
+
+ 2026-05-25 · 视频生成进入个人公平队列
+ Backend
+ UI
+ Queue
+
+
+
问题:多人使用时,如果视频提交后直接开后台线程,单个用户连续提交多个视频可能占满生成通道,其他人也看不清自己的任务是在排队还是已经生成。
+
改动:api/main.py 新增进程内视频队列:全局默认同时 2 个视频生成、单用户同时 1 个视频生成;GeneratedVideo 增加 queue_position、queue_size、queue_message,排队、启动、完成、失败和删除都会回写状态。web/app/page.tsx 把首页视频结果卡的英文状态改成“排队中 / 前方 N 个任务 / 你的上一个视频生成中 / 生成中 N% / 可播放”。
+
影响:当前只做个人队列反馈,不做团队共享或管理员总览。队列是单 API 容器内存队列,不依赖 Redis;容器重启后未完成视频会标记为失败并提示重新生成。
+
+
2026-05-25 · 首页完成视频结果卡直接可播放
diff --git a/web/app/page.tsx b/web/app/page.tsx
index 82977ab..f457929 100644
--- a/web/app/page.tsx
+++ b/web/app/page.tsx
@@ -96,6 +96,17 @@ function videoSrc(job: Job, video: GeneratedVideo) {
return apiAssetUrl(video.url || `/jobs/${job.id}/storyboard-videos/${video.id}.mp4`)
}
+function videoStatusText(video: GeneratedVideo) {
+ if (video.status === "queued") {
+ return video.queue_message || (video.queue_position && video.queue_position > 1 ? `排队中 · 前方 ${video.queue_position - 1} 个任务` : "排队中 · 即将开始")
+ }
+ if (video.status === "in_progress") {
+ return video.queue_message ? `${video.queue_message} · ${Math.round(video.progress)}%` : `生成中 · ${Math.round(video.progress)}%`
+ }
+ if (video.status === "completed") return `${Math.round(video.duration)}s · 可播放`
+ return "失败 · 可重试"
+}
+
function isVideoMode(mode: CreationMode) {
return mode !== "text-image"
}
@@ -307,7 +318,7 @@ export default function Home() {
model: videoModel,
})
setJob(updated)
- toast.success("视频已提交")
+ toast.success("已加入生成队列")
} catch (e) {
const message = e instanceof Error ? e.message : "生视频失败"
setError(message)
@@ -523,21 +534,29 @@ export default function Home() {
{job ? 打开详情页 : null}
{latestVideo && job ? (
- deleteVideo(latestVideo)}
- />
+
+ {latestVideo.status === "queued" || latestVideo.status === "in_progress" ? (
+
+ {videoStatusText(latestVideo)}
+ {latestVideo.status === "queued" && latestVideo.queue_size ? 队列 {latestVideo.queue_position || 0}/{latestVideo.queue_size} : null}
+
+ ) : null}
+
deleteVideo(latestVideo)}
+ />
+
) : latestImage ? (