auto-save 2026-05-12 15:57 (~5)

This commit is contained in:
2026-05-12 15:57:18 +08:00
parent 2e45ad9d16
commit 064083ef0a
5 changed files with 166 additions and 33 deletions

View File

@@ -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
}
]
}

View File

@@ -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)

View File

@@ -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<Job | null>(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 输入 */}
<section>
<UrlInput loading={submitting || (job !== null && job.status !== "transcribed" && job.status !== "failed")} onSubmit={handleSubmit} />
<UrlInput
loading={submitting || (job !== null && job.status !== "transcribed" && job.status !== "failed")}
onSubmitUrl={handleSubmit}
onUploadFile={handleUpload}
/>
</section>
{job && (
@@ -146,7 +166,7 @@ export default function Home() {
{!job && (
<section className="text-center py-16">
<div className="font-serif text-2xl text-white/30"> TikTok </div>
<div className="font-serif text-2xl text-white/30"> TikTok / </div>
</section>
)}

View File

@@ -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<HTMLInputElement>(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 (
<form
onSubmit={(e) => {
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"
>
<div className="glass-card flex items-center gap-3 px-5 py-4">
<div
className={`glass-card flex items-center gap-3 px-5 py-4 transition ${
dragOver ? "ring-2 ring-white/40 bg-white/[0.08]" : ""
}`}
>
<Link2 className="h-5 w-5 shrink-0 text-white/40" />
<input
value={url}
onChange={(e) => 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}
/>
{/* 上传按钮 */}
<button
type="button"
onClick={pickFile}
disabled={loading}
className="shrink-0 inline-flex items-center gap-1.5 rounded-md border border-white/15 bg-white/5 hover:bg-white/10 px-3 py-2 text-xs text-white/80 disabled:opacity-40 disabled:cursor-not-allowed transition"
title="上传本地视频"
>
<Upload className="h-3.5 w-3.5" />
</button>
{/* 提交按钮 */}
<button
type="submit"
disabled={loading || !url.trim()}
className="shrink-0 rounded-md bg-white text-black px-4 py-2 text-sm font-medium hover:bg-white/90 disabled:opacity-40 disabled:cursor-not-allowed transition flex items-center gap-2"
className="shrink-0 rounded-md bg-white text-black px-4 py-2 text-sm font-medium hover:bg-white/90 disabled:opacity-40 disabled:cursor-not-allowed transition inline-flex items-center gap-2"
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{loading ? "处理中" : "提交"}
</button>
<input
ref={fileRef}
type="file"
accept="video/mp4,video/quicktime,video/webm,video/x-matroska,.mp4,.mov,.webm,.mkv,.m4v"
className="hidden"
onChange={(e) => handleFiles(e.target.files)}
/>
</div>
<div className="mt-2 text-[11px] text-white/30 px-1">
TikTok · · · mp4 / mov / webm / mkv / m4v
</div>
</form>
)

View File

@@ -48,6 +48,20 @@ export async function createJob(tkUrl: string): Promise<Job> {
return res.json()
}
export async function uploadJob(file: File): Promise<Job> {
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<Job> {
const res = await fetch(`${API_BASE}/jobs/${id}`)
if (!res.ok) throw new Error(`getJob ${res.status}`)