feat: support tiktok download cookies

This commit is contained in:
2026-05-18 16:35:29 +08:00
parent 028718df0b
commit 4280624810
9 changed files with 92 additions and 6 deletions

View File

@@ -17,6 +17,7 @@
| 任务 | 当前模型 / 通道 | 备注 |
|---|---|---|
| TK 下载 | `yt-dlp` + 可选 cookies | 公开视频裸下载;受限视频可配 `YTDLP_COOKIES_FILE``YTDLP_COOKIES_FROM_BROWSER`,也可直接上传 MP4。 |
| 远端 ASR | `ASR_MODEL=whisper-1` | 失败后进本机 ASR再进多模态兜底。 |
| 本机 ASR | `LOCAL_ASR_MODEL=mlx-community/whisper-tiny` | 默认二级兜底,优先产出真实逐句时间轴。 |
| ASR 兜底 / 音频分析 | `ASR_FALLBACK_MODEL=gemini-2.5-flash` | 多模态音频兜底;后端会拒绝假字幕、重复文本和覆盖率过低结果。 |
@@ -94,8 +95,10 @@ POST /jobs/{id}/frames/{idx}/storyboard/video
9. 文档是顶层业务归类:每个 TK 链接或上传视频默认一个 `document``job` 归属到 `document_id`DB 存元数据和文件索引,视频 / 图片 / 音频文件不进 DB。
10. 后端长任务不要用 `--reload`
11. 关键帧 `index` 是稳定 ID不等于数组下标前端取帧用 `frames.find(x => x.index === idx)`
12. TikTok cookies 属于账号登录态,只能放本机 / 服务器私有环境;不要提交 cookies 文件或账号密码。
## 最近变更
- 2026-05-18TK 链接下载新增 `YTDLP_COOKIES_FILE` / `YTDLP_COOKIES_FROM_BROWSER` 支持;受限视频失败时前端提示上传 MP4 或配置后端 cookies 登录态。
- 2026-05-18清理个人语音通道残留`/health`、前端类型、环境模板和文档不再暴露相关字段或配置。
- 2026-05-18新增后端数据库层SQLite 默认落在 `APP_DB_URL` / `DATABASE_URL``JOBS_DIR/app.db``/documents` 返回文档归类列表,`/health.database` 返回 DB 状态。
- 2026-05-18`VISION_MODEL``REWRITE_MODEL``AUDIO_REWRITE_MODEL` 切到 GPT 默认模型 `gpt-4o`,并加旧 Gemini 环境变量归一化保护。

View File

@@ -65,6 +65,7 @@
- `IMAGE_BASE_URL` / `IMAGE_API_KEY` / `IMAGE_MODEL`OpenAI 兼容生图网关;当前所有生图入口一律强制使用 `gpt-image-2`,不做其他图片模型 fallback
- `GPT_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODEL` / `SUBJECT_ASSET_IMAGE_MODELS`:保留兼容旧环境变量名,但服务端会强制主体 6 视图和所有其他生图入口都只使用 `gpt-image-2`
- `AI_HTTP_PROXY` / `IMAGE_HTTP_PROXY`:可选的 AI 网关出站代理;本地 launchd 后台进程不一定继承 shell 的 `http_proxy/https_proxy`,如生图报 DNS / ConnectError可在本地 `api/.env` 配置后重启后端。`/health` 只回传是否配置代理,不回传代理地址。
- `YTDLP_COOKIES_FILE` / `YTDLP_COOKIES_FROM_BROWSER`:可选 TikTok 下载登录态;优先使用 cookies 文件,其次读取本机浏览器 cookies。cookies 文件属于敏感登录态,只能放本机或服务器私有路径,不允许入库。
- `VOICE_PROVIDER`:配音通道,服务端固定使用 `azure_openai`
- `AZURE_OPENAI_BASE_URL` / `AZURE_OPENAI_API_KEY`:微软 Azure OpenAI 协议配音网关;本地未单独配置 Key 时回退复用 `LLM_API_KEY`
- `AZURE_TTS_MODEL` / `AZURE_TTS_VOICE_ID` / `AZURE_TTS_VOICE_POOL` / `AZURE_TTS_PATH` / `AZURE_TTS_PATHS`Azure OpenAI TTS 模型、默认音色、音色池和 OpenAI 协议语音路径;后端会按 `AZURE_TTS_PATHS` 依次尝试,便于区分路径不对和整条语音服务不可用

View File

@@ -29,6 +29,8 @@ SUBJECT_ASSET_IMAGE_MODEL=gpt-image-2
SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2
# 可选:本地网络需要代理访问 ai.skg.com 时配置launchd 不一定继承 shell 代理变量。
AI_HTTP_PROXY=
YTDLP_COOKIES_FILE=
YTDLP_COOKIES_FROM_BROWSER=
VIDEO_MODEL=seedance
VIDEO_MODEL_SEEDANCE=seedance-2-fast
VIDEO_MODEL_KLING=kling-omni

View File

@@ -92,6 +92,8 @@ PRODUCT_ASSET_MIN_LONG_SIDE = max(512, int(os.getenv("PRODUCT_ASSET_MIN_LONG_SID
PRODUCT_ASSET_MIN_SHORT_SIDE = max(320, int(os.getenv("PRODUCT_ASSET_MIN_SHORT_SIDE", "600")))
PRODUCT_ASSET_JPEG_QUALITY = max(80, min(95, int(os.getenv("PRODUCT_ASSET_JPEG_QUALITY", "92"))))
VIDEO_MODEL = os.getenv("VIDEO_MODEL", "seedance").strip() or "seedance"
YTDLP_COOKIES_FILE = os.getenv("YTDLP_COOKIES_FILE", "").strip()
YTDLP_COOKIES_FROM_BROWSER = os.getenv("YTDLP_COOKIES_FROM_BROWSER", "").strip()
AUDIO_PRODUCT_BRIEF = os.getenv(
"AUDIO_PRODUCT_BRIEF",
"SKG 智能按摩产品,主打日常肩颈、腰背、眼部、膝盖或足部放松;广告表达要高级、干净、可信,不做医疗疗效承诺。",
@@ -1031,6 +1033,35 @@ def run(cmd: list[str], cwd: Path | None = None) -> str:
return res.stdout
def ytdlp_cookie_args() -> list[str]:
if YTDLP_COOKIES_FILE:
cookies = Path(YTDLP_COOKIES_FILE).expanduser()
if not cookies.exists():
raise RuntimeError("TikTok cookies 文件不可用,请检查 YTDLP_COOKIES_FILE 配置。")
return ["--cookies", str(cookies)]
if YTDLP_COOKIES_FROM_BROWSER:
return ["--cookies-from-browser", YTDLP_COOKIES_FROM_BROWSER]
return []
def normalize_download_error(error: Exception) -> str:
raw = str(error)
lower = raw.lower()
auth_required = (
"log in for access" in lower
or "login" in lower and "cookies" in lower
or "cookies-from-browser" in lower
or "sign in" in lower and "tiktok" in lower
)
if auth_required:
return (
"TikTok 下载需要登录态。请上传视频文件,或在后端配置 "
"YTDLP_COOKIES_FILE / YTDLP_COOKIES_FROM_BROWSER 后重试。"
f"原始错误:{raw}"
)
return raw
# ---- 启发式选帧工具 ----
import imagehash
import numpy as np
@@ -1696,13 +1727,15 @@ def pipeline_download(job_id: str) -> None:
update(job, status="downloading", message="本地上传 · 跳过下载", progress=15)
else:
update(job, status="downloading", message="yt-dlp 下载中…", progress=5)
run([
cmd = [
"yt-dlp", "-f", "best[ext=mp4]/best",
"-o", str(mp4),
"--no-warnings", "--no-playlist",
"--retries", "3",
*ytdlp_cookie_args(),
job.url,
])
]
run(cmd)
if not mp4.exists():
raise RuntimeError("下载完成但找不到 source.mp4")
@@ -1725,7 +1758,7 @@ def pipeline_download(job_id: str) -> None:
)
except Exception as e:
message = "视频元数据解析失败" if stage == "metadata" else "下载失败"
update(job, status="failed", error=str(e), message=message)
update(job, status="failed", error=normalize_download_error(e), message=message)
def pipeline_analyze(

View File

@@ -36,6 +36,10 @@ SUBJECT_ASSET_IMAGE_MODELS=gpt-image-2
# Optional outbound proxy for AI gateway calls. Leave blank on normal VPS networking.
AI_HTTP_PROXY=
# Optional TikTok download login state for yt-dlp. Keep cookies files private.
YTDLP_COOKIES_FILE=
YTDLP_COOKIES_FROM_BROWSER=
# Audio rewrite and Azure OpenAI TTS
AUDIO_REWRITE_MODEL=gpt-4o
AUDIO_PRODUCT_BRIEF="SKG smart massage products for daily neck, shoulder, back, eye, knee, and foot relaxation. Keep claims premium, clean, credible, and non-medical."

View File

@@ -572,7 +572,7 @@
<p>当前产品方向已收窄为“信息流广告快速复刻”:主界面左侧是素材输入列,右侧是信息流复刻工作表。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频文案路提取原音频文案/字幕,分析讲话人、语速节奏、背景音乐/环境声/音效,并为后续新口播和分镜文案提供时间轴;视频视觉路同步抽取参考帧,参考帧只用于人工选择主体并生成相似主体白底视图。产品图上传后独立形成产品资产包:自动识别视角、左右/上下/内外侧、结构点、比例和风险,并补缺角度。最终分镜规划按逐句时间轴把文案、相似主体资产和产品资产汇合;当前暂停直接调视频模型,先逐条生成并审核首帧/尾帧,确认后再决定哪些分镜进入视频候选。</p>
<div class="pipeline">
<div class="step"><div class="num">1</div><h3>导入素材</h3><p>粘贴 TK / 信息流视频链接或上传本地视频;“开始”只把任务放入第一步队列。</p></div>
<div class="step"><div class="num">2</div><h3>下载源视频</h3><p>后端用 yt-dlp 或本地上传文件落 <code>source.mp4</code>,记录时长、尺寸和视频只读地址。</p></div>
<div class="step"><div class="num">2</div><h3>下载源视频</h3><p>后端用 yt-dlp 或本地上传文件落 <code>source.mp4</code>,记录时长、尺寸和视频只读地址TikTok 受限视频可通过 <code>YTDLP_COOKIES_FILE</code><code>YTDLP_COOKIES_FROM_BROWSER</code> 提供登录态</p></div>
<div class="step"><div class="num">3</div><h3>并行素材分析</h3><p>下载完成后前端同时触发 <code>triggerTranscribe</code><code>analyzeJob</code>:音频路生成字幕/节奏/背景音,视觉路自动抽 6 张人物随机参考帧。</p></div>
<div class="step"><div class="num">4</div><h3>资产包准备</h3><p>用户可删除/补选参考帧并生成相似主体视图;参考帧到这里为止,后续首尾帧和视频不再把原关键帧当画面真源。产品图上传后自动识别视角、结构和风险,并补缺角度,形成产品资产包。</p></div>
<div class="step"><div class="num">5</div><h3>首尾帧闸门</h3><p>按逐句时间轴生成竖向分镜行;每行先规划镜头类型、人物描述、是否需要人物/产品、首帧、尾帧和产品出现方式,再按需求从相似主体 6/10 视图里选择合适人物视角,并和产品素材一起生成首尾帧,先看图确认,不直接批量提交视频。</p></div>
@@ -611,7 +611,7 @@
<h3>后端核心</h3>
<table>
<tbody>
<tr><td><code>api/main.py</code></td><td>FastAPI 单文件后端登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回并在保存 <code>state.json</code> 时同步数据库元数据。</td></tr>
<tr><td><code>api/main.py</code></td><td>FastAPI 单文件后端登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回并在保存 <code>state.json</code> 时同步数据库元数据。TK 下载使用 <code>yt-dlp</code>,支持 <code>YTDLP_COOKIES_FILE</code> / <code>YTDLP_COOKIES_FROM_BROWSER</code> 给受限视频提供登录态。</td></tr>
<tr><td><code>api/database.py</code></td><td>后端数据库层:当前内置 SQLite维护 <code>documents</code><code>jobs</code><code>media_assets</code> 三类元数据;文档是顶层归类,一条 TK 链接或一次上传默认一个 document媒体文件仍保留在 <code>jobs/&lt;jobId&gt;/</code></td></tr>
<tr><td><code>api/product_library/skg-products</code></td><td>内置 SKG 白底产品图库:<code>manifest.json</code> 记录从桌面产品图筛出的 gallery 白底图和桌面 4 张产品角度图,<code>images/</code> 存 45 张参考图。</td></tr>
<tr><td><code>api/character_library/skg-characters</code></td><td>内置相似主体形象库:从桌面 5 套策划形象导入,<code>manifest.json</code> 记录运动阳光男、都市型男、优雅白领女、运动辣妹、绅士大叔,每套含 7 张透明骨架参考图,用于相似主体高清视图包的创意方向选择。</td></tr>
@@ -912,7 +912,7 @@ ProductRefStateItem {
<tr><td>运行配置 / 模型标注</td><td><code>GET /health</code></td><td><code>getRuntimeHealth</code><code>ModelTrace</code></td><td>返回 <code>models</code>ASR、本机 ASR、ASR fallback、翻译、GPT 改写、GPT 画面理解、产品视角识别 <code>product_view</code>、GPT 图像模型、主体 6 视图 GPT 图像模型、Azure OpenAI TTS、视频别名和 Seedance 服务商。当前 <code>REWRITE_MODEL</code><code>AUDIO_REWRITE_MODEL</code><code>VISION_MODEL</code> 默认使用 <code>gpt-4o</code>;如果旧环境变量仍写 <code>gemini-*</code>,后端会归一化回 <code>GPT_TEXT_MODEL</code> / <code>REWRITE_MODEL</code>。语音只走 Azure OpenAI TTS<code>models.voice_tts_paths</code> 会回传当前尝试的语音路径,方便区分路径错误和语音服务不可用。<code>database</code> 回传 DB 是否启用、URL、schema 版本和 document/job/asset 计数。前端所有当前主路径里会调用模型的按钮旁显示模型名,点击弹出小窗口查看模型链路和输入输出逻辑;不返回 API Key 或敏感凭证。</td></tr>
<tr><td>文档列表</td><td><code>GET /documents</code></td><td>后续文档侧栏 / 素材库入口</td><td>从数据库读取 document 归类列表,包含 source_kind、workflow_mode、primary_job_id、storage_prefix、job_count、asset_count 和更新时间。当前前端还未接主入口,后端已可作为多视频/多上传文档管理的索引。</td></tr>
<tr><td>历史列表</td><td><code>GET /jobs</code></td><td><code>listJobs</code></td><td>所有 job 精简列表id/document_id/source_kind/workflow_mode/url/status/thumbnail/mtime…按 state.json mtime 倒序。前端 URL 无 <code>?job=</code> 时拉它回填全部历史;带 <code>limit</code> 可截断。</td></tr>
<tr><td>创建任务</td><td><code>POST /jobs</code></td><td><code>createJob</code></td><td>提交 TK 链接,后台开始下载;后端自动建立 <code>document_id=job_id</code><code>source_kind=tiktok_link</code><code>workflow_mode=feed_recreation</code> 的 document并在状态保存时同步 DB。</td></tr>
<tr><td>创建任务</td><td><code>POST /jobs</code></td><td><code>createJob</code></td><td>提交 TK 链接,后台开始下载;后端自动建立 <code>document_id=job_id</code><code>source_kind=tiktok_link</code><code>workflow_mode=feed_recreation</code> 的 document并在状态保存时同步 DB。下载阶段优先使用 <code>YTDLP_COOKIES_FILE</code>,其次使用 <code>YTDLP_COOKIES_FROM_BROWSER</code>TikTok 要求登录态时会把错误归一化为“上传 MP4 或配置后端 cookies”。</td></tr>
<tr><td>上传视频</td><td><code>POST /jobs/upload</code></td><td><code>uploadJob</code></td><td>保存 source.mp4然后同样进入下载完成状态后端自动建立 <code>source_kind=upload</code><code>workflow_mode=uploaded_reference</code> 的 document。当前上传后也加入第一步队列下载完成后自动解析音频。</td></tr>
<tr><td>删除输入视频</td><td><code>DELETE /jobs/{id}</code></td><td><code>deleteJob</code></td><td>从任务队列、URL 和磁盘 <code>jobs/&lt;id&gt;</code> 目录移除整个 job包括源视频、关键帧、元素提取图和生成视频。</td></tr>
<tr><td>解析视频</td><td><code>POST /jobs/{id}/analyze?frames=&amp;target=&amp;mode=&amp;quality=</code></td><td><code>analyzeJob</code></td><td>后续阶段保留的抽帧能力。默认 <code>frames=6</code><code>target</code> 支持人物随机、透明骨架人、综合、清晰主体、转场变化、表情瞬间、动作峰值。当前“开始分析”和源视频旁的“自动抽帧 6 张”都会显式用 <code>target=random_subject</code><code>quality=accurate</code><code>mode=replace</code> 生成清晰人物候选里的随机参考帧池。</td></tr>
@@ -1044,6 +1044,18 @@ ProductRefStateItem {
<h2>变更记录</h2>
<p>这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。</p>
<div class="changelog">
<article class="change">
<header>
<h3>2026-05-18 · TK 受限视频下载支持 cookies 登录态</h3>
<span class="tag violet">API</span>
<span class="tag rose">UI</span>
</header>
<div class="body">
<p><strong>问题:</strong>部分 TikTok 链接会返回“Log in for access”后端已经接到任务并创建 job但裸 <code>yt-dlp</code> 下载没有登录态,任务只能失败。</p>
<p><strong>改动:</strong><code>api/main.py</code> 新增 <code>YTDLP_COOKIES_FILE</code><code>YTDLP_COOKIES_FROM_BROWSER</code>,下载时优先传 <code>--cookies</code>,其次传 <code>--cookies-from-browser</code>。TikTok 登录态错误会归一化成“上传 MP4 或配置后端 cookies 后重试”。<code>web/lib/api.ts</code> 新增失败文案格式化,<code>web/app/page.tsx</code> 在任务失败时弹 toast<code>web/components/ad-recreation-board.tsx</code> 在素材卡显示受限下载提示。</p>
<p><strong>影响:</strong>不要在前端接 TikTok 账号密码或登录弹窗cookies 文件属于敏感登录态,只能放本机或服务器私有路径。普通公开视频仍裸下载,受限视频可上传 MP4 兜底。</p>
</div>
</article>
<article class="change">
<header>
<h3>2026-05-18 · 删除个人语音通道残留</h3>

View File

@@ -19,6 +19,7 @@ import { AdRecreationBoard } from "@/components/ad-recreation-board"
import {
addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage,
deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, triggerTranscribe, describeFrame, updateStoryboard, copyProductLibraryAsset,
formatJobError,
type Job, type ImageRef, type KeyFrame, type ProductFusionShot, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget,
} from "@/lib/api"
import { TRANSPARENT_HUMAN_NEGATIVE_PROMPT, TRANSPARENT_HUMAN_VIDEO_PROMPT } from "@/lib/workflow-target"
@@ -864,6 +865,9 @@ export default function Home() {
if (job?.status === "downloaded" && prevStatusRef.current !== "downloaded") {
toast.info("视频已下载,音频解析会自动开始;也可以在右侧手动重试", { duration: 6000 })
}
if (job?.status === "failed" && prevStatusRef.current !== "failed") {
toast.error(formatJobError(job.error) || "任务失败", { duration: 10000 })
}
prevStatusRef.current = job?.status ?? null
const TERMINAL: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"]

View File

@@ -32,6 +32,7 @@ import {
cutoutElement,
deleteSubjectAsset,
effectiveFrameUrl,
formatJobError,
generateSceneAsset,
generateProductAngleAsset,
generateSubjectAssets,
@@ -3859,6 +3860,7 @@ function MaterialCard({
onDelete?: () => void
}) {
const tone = statusTone(job)
const errorText = formatJobError(job.error)
return (
<button
type="button"
@@ -3880,6 +3882,12 @@ function MaterialCard({
<Metric label="文案" value={job.audio_script?.source_text || job.transcript.length ? "ready" : "-"} compact />
<Metric label="段落" value={`${job.transcript.length}`} compact />
</div>
{job.status === "failed" && errorText && (
<div className="mt-2 flex gap-1.5 rounded-md border border-rose-300/18 bg-rose-500/[0.08] px-2 py-1.5 text-[11px] leading-snug text-rose-100/82">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span className="line-clamp-3">{errorText}</span>
</div>
)}
{onDelete && (
<span
role="button"

View File

@@ -626,6 +626,25 @@ export function apiAssetUrl(path?: string | null): string {
return `${API_BASE}${path.startsWith("/") ? "" : "/"}${path}`
}
export function isRestrictedDownloadError(error?: string | null): boolean {
const text = (error ?? "").toLowerCase()
return (
text.includes("tiktok 下载需要登录态") ||
text.includes("log in for access") ||
text.includes("cookies-from-browser") ||
text.includes("ytdlp_cookies_file") ||
(text.includes("tiktok") && text.includes("cookies"))
)
}
export function formatJobError(error?: string | null): string {
if (!error) return ""
if (isRestrictedDownloadError(error)) {
return "这个 TikTok 视频需要登录态。请上传 MP4或让后端配置 YTDLP_COOKIES_FROM_BROWSER / YTDLP_COOKIES_FILE 后重试。"
}
return error
}
export async function getHealth(): Promise<BackendHealth> {
const res = await fetch(`${API_BASE}/health`)
if (!res.ok) throw new Error(`health ${res.status}`)