diff --git a/.memory/worklog.json b/.memory/worklog.json index ffffc2e..93b2bae 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -20,6 +20,13 @@ "message": "auto-save 2026-05-12 15:41 (+1, ~3)", "hash": "bbd41fa", "files_changed": 4 + }, + { + "ts": "2026-05-12T15:51:42+08:00", + "type": "commit", + "message": "auto-save 2026-05-12 15:47 (+2, ~3)", + "hash": "2e45ad9", + "files_changed": 96 } ] } diff --git a/api/main.py b/api/main.py index e34dbcc..b3a0346 100644 --- a/api/main.py +++ b/api/main.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import Literal from dotenv import load_dotenv -from fastapi import BackgroundTasks, FastAPI, HTTPException +from fastapi import BackgroundTasks, FastAPI, File, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from pydantic import BaseModel, Field @@ -106,7 +106,9 @@ app.add_middleware( def run(cmd: list[str], cwd: Path | None = None) -> str: res = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) if res.returncode != 0: - raise RuntimeError(f"cmd failed: {' '.join(cmd[:3])}... · {res.stderr[:500]}") + # ffmpeg 把 banner 写 stderr,挑最后几行(真错误一般在末尾) + tail = "\n".join(res.stderr.splitlines()[-12:]) or res.stderr[-800:] + raise RuntimeError(f"cmd failed: {' '.join(cmd[:3])}... · {tail}") return res.stdout @@ -122,18 +124,21 @@ async def pipeline_download_split_frames(job_id: str) -> None: job = JOBS[job_id] d = job_dir(job_id) try: - # ---- 1. yt-dlp 下载 - update(job, status="downloading", message="yt-dlp 下载中…", progress=5) mp4 = d / "source.mp4" - run([ - "yt-dlp", "-f", "best[ext=mp4]/best", - "-o", str(mp4), - "--no-warnings", "--no-playlist", - "--retries", "3", - job.url, - ]) - if not mp4.exists(): - raise RuntimeError("下载完成但找不到 source.mp4") + # ---- 1. yt-dlp 下载(上传模式 mp4 已存在 → 跳过) + if mp4.exists(): + update(job, status="downloading", message="本地上传,跳过下载", progress=15) + else: + update(job, status="downloading", message="yt-dlp 下载中…", progress=5) + run([ + "yt-dlp", "-f", "best[ext=mp4]/best", + "-o", str(mp4), + "--no-warnings", "--no-playlist", + "--retries", "3", + job.url, + ]) + if not mp4.exists(): + raise RuntimeError("下载完成但找不到 source.mp4") # 元数据 meta = ffprobe_meta(mp4) @@ -165,14 +170,20 @@ async def pipeline_download_split_frames(job_id: str) -> None: shutil.rmtree(frames_dir) frames_dir.mkdir(parents=True) - # 先用场景切换检测 - run([ - "ffmpeg", "-y", "-i", str(mp4), - "-vf", "select='gt(scene,0.4)',showinfo", - "-vsync", "vfr", - "-frames:v", "30", - str(frames_dir / "scene_%03d.jpg"), - ]) + # 先用场景切换检测(失败时不阻塞,走均匀采样兜底) + try: + run([ + "ffmpeg", "-y", "-i", str(mp4), + "-vf", "select='gt(scene,0.4)'", + "-fps_mode", "vfr", + "-frames:v", "30", + "-pix_fmt", "yuvj420p", # mjpeg encoder 要 JPEG full-range + "-q:v", "3", + str(frames_dir / "scene_%03d.jpg"), + ]) + except Exception: + # 场景切换检测在某些纯合成 / 静态视频上会失败,让它静默走兜底 + pass scene_frames = sorted(frames_dir.glob("scene_*.jpg")) # 均匀采样兜底 / 补足 @@ -184,7 +195,9 @@ async def pipeline_download_split_frames(job_id: str) -> None: out = frames_dir / f"sample_{i:03d}.jpg" run([ "ffmpeg", "-y", "-ss", str(t), "-i", str(mp4), - "-frames:v", "1", "-q:v", "3", str(out), + "-frames:v", "1", + "-pix_fmt", "yuvj420p", + "-q:v", "3", str(out), ]) # 统一排序、按时间戳读取、限制 10 张 @@ -299,6 +312,32 @@ async def create_job(req: CreateJobReq, bg: BackgroundTasks) -> Job: return job +@app.post("/jobs/upload", response_model=Job) +async def create_job_from_upload(bg: BackgroundTasks, file: UploadFile = File(...)) -> Job: + if not file.filename: + raise HTTPException(400, "file required") + # 简化:只验后缀,不嗅探 magic bytes + ext = Path(file.filename).suffix.lower() + if ext not in {".mp4", ".mov", ".webm", ".mkv", ".m4v"}: + raise HTTPException(400, f"unsupported video format: {ext}") + + job_id = uuid.uuid4().hex[:12] + d = job_dir(job_id) + mp4 = d / "source.mp4" + # 直接落盘(流式写入,避免全量进内存) + with mp4.open("wb") as f: + while chunk := await file.read(1024 * 1024): + f.write(chunk) + if not mp4.exists() or mp4.stat().st_size == 0: + raise HTTPException(500, "upload failed") + + job = Job(id=job_id, url=f"upload://{file.filename}") + JOBS[job_id] = job + save_state(job) + bg.add_task(pipeline_download_split_frames, job_id) + return job + + @app.get("/jobs/{job_id}", response_model=Job) def get_job(job_id: str) -> Job: job = JOBS.get(job_id) diff --git a/web/app/page.tsx b/web/app/page.tsx index 0032ff3..192a706 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -5,7 +5,7 @@ import { UrlInput } from "@/components/url-input" import { JobStatusBar } from "@/components/job-status" import { KeyframeGallery } from "@/components/keyframe-gallery" import { TranscriptPanel } from "@/components/transcript-panel" -import { createJob, getJob, triggerTranscribe, videoUrl, type Job } from "@/lib/api" +import { createJob, getJob, triggerTranscribe, uploadJob, videoUrl, type Job } from "@/lib/api" export default function Home() { const [job, setJob] = useState(null) @@ -30,6 +30,22 @@ export default function Home() { } }, []) + const handleUpload = useCallback(async (file: File) => { + setSubmitting(true) + setSelected(new Set()) + transcribeTriggeredRef.current = null + try { + toast.info(`正在上传 ${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`) + const created = await uploadJob(file) + setJob(created) + toast.success(`已上传,任务 ${created.id.slice(0, 8)}`) + } catch (e) { + toast.error("上传失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setSubmitting(false) + } + }, []) + // 轮询 job 状态 useEffect(() => { if (!job) return @@ -93,7 +109,11 @@ export default function Home() { {/* URL 输入 */}
- +
{job && ( @@ -146,7 +166,7 @@ export default function Home() { {!job && (
-
↑ 粘贴一条 TikTok 链接开始
+
↑ 粘贴 TikTok 链接,或拖入 / 上传本地视频
)} diff --git a/web/components/url-input.tsx b/web/components/url-input.tsx index 173d70f..d4dc8d8 100644 --- a/web/components/url-input.tsx +++ b/web/components/url-input.tsx @@ -1,41 +1,94 @@ "use client" -import { useState } from "react" -import { Link2, Loader2 } from "lucide-react" +import { useRef, useState } from "react" +import { Link2, Loader2, Upload } from "lucide-react" interface Props { loading?: boolean - onSubmit: (url: string) => void + onSubmitUrl: (url: string) => void + onUploadFile: (file: File) => void } -export function UrlInput({ loading, onSubmit }: Props) { +export function UrlInput({ loading, onSubmitUrl, onUploadFile }: Props) { const [url, setUrl] = useState("") + const [dragOver, setDragOver] = useState(false) + const fileRef = useRef(null) + + const pickFile = () => fileRef.current?.click() + + const handleFiles = (files: FileList | null) => { + if (!files || files.length === 0) return + const f = files[0] + if (!f.type.startsWith("video/") && !/\.(mp4|mov|webm|mkv|m4v)$/i.test(f.name)) { + // sonner is in page; keep simple here + console.warn("not a video file:", f.name) + return + } + onUploadFile(f) + } return (
{ e.preventDefault() const trimmed = url.trim() - if (trimmed) onSubmit(trimmed) + if (trimmed) onSubmitUrl(trimmed) + }} + onDragOver={(e) => { + e.preventDefault() + if (!loading) setDragOver(true) + }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault() + setDragOver(false) + if (loading) return + handleFiles(e.dataTransfer.files) }} className="relative w-full" > -
+
setUrl(e.target.value)} - placeholder="粘贴 TikTok 链接,例如 https://www.tiktok.com/@user/video/..." + placeholder={dragOver ? "松开以上传视频" : "粘贴 TikTok 链接,或拖入 / 上传本地视频"} className="w-full bg-transparent text-white placeholder:text-white/30 outline-none text-[15px]" disabled={loading} /> + {/* 上传按钮 */} + + {/* 提交按钮 */} + handleFiles(e.target.files)} + /> +
+
+ 支持:TikTok 链接 · 拖入视频文件 · 点击上传 · 格式 mp4 / mov / webm / mkv / m4v
) diff --git a/web/lib/api.ts b/web/lib/api.ts index e7404cf..e816490 100644 --- a/web/lib/api.ts +++ b/web/lib/api.ts @@ -48,6 +48,20 @@ export async function createJob(tkUrl: string): Promise { return res.json() } +export async function uploadJob(file: File): Promise { + const fd = new FormData() + fd.append("file", file) + const res = await fetch(`${API_BASE}/jobs/upload`, { + method: "POST", + body: fd, + }) + if (!res.ok) { + const text = await res.text().catch(() => "") + throw new Error(`uploadJob ${res.status} ${text.slice(0, 200)}`) + } + return res.json() +} + export async function getJob(id: string): Promise { const res = await fetch(`${API_BASE}/jobs/${id}`) if (!res.ok) throw new Error(`getJob ${res.status}`)