diff --git a/.gitignore b/.gitignore index dd31b51..3ef7aab 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules/ +.next/ +out/ dist/ build/ .env @@ -10,3 +12,11 @@ __pycache__/ .vscode/ .idea/ *.log + +# api +api/.venv/ +api/jobs/ + +# web +web/.next/ +web/out/ diff --git a/.memory/worklog.json b/.memory/worklog.json index 619acf6..ffffc2e 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -13,6 +13,13 @@ "message": "init: project scaffold", "hash": "56d435f", "files_changed": 7 + }, + { + "ts": "2026-05-12T15:42:02+08:00", + "type": "commit", + "message": "auto-save 2026-05-12 15:41 (+1, ~3)", + "hash": "bbd41fa", + "files_changed": 4 } ] } diff --git a/.project.json b/.project.json index 109e876..6fd2b01 100644 --- a/.project.json +++ b/.project.json @@ -8,7 +8,12 @@ "ports": [ { "port": 4290, - "label": "dev", + "label": "web-dev", + "fixed": true + }, + { + "port": 4291, + "label": "api-dev", "fixed": true } ], diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..9e2b44f --- /dev/null +++ b/api/.env.example @@ -0,0 +1,15 @@ +# Gemini API(优先用 Poe 中转,按用户偏好) +# Poe 网关示例:GEMINI_API_BASE=https://api.poe.com/v1 + key +# Google 直连示例:留空 GEMINI_API_BASE,用 google-generativeai SDK +GEMINI_API_KEY= +GEMINI_API_BASE= +GEMINI_MODEL=gemini-2.5-flash + +# 工作目录 +JOBS_DIR=./jobs + +# CORS +CORS_ORIGINS=http://localhost:4290 + +# 端口(启动用 uvicorn --port 4291 覆盖) +API_PORT=4291 diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 0000000..003c4ef --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +.env +jobs/ diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..c11ebdb --- /dev/null +++ b/api/README.md @@ -0,0 +1,33 @@ +# SKG TK 二创 API + +FastAPI 后端,跑 yt-dlp + ffmpeg + Gemini ASR/翻译 管线。 + +## 启动 + +```bash +cd api +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # 按需填 GEMINI_API_KEY +uvicorn main:app --port 4291 --reload +``` + +## 路由 + +- `GET /health` — 健康检查 + 配置状态 +- `POST /jobs` `{url}` — 创建 job,后台跑下载/拆轨/抽帧 +- `GET /jobs/{id}` — 当前状态 + 产物 +- `POST /jobs/{id}/transcribe` — 触发 Gemini ASR + 翻译 +- `GET /jobs/{id}/video.mp4` — 原视频 +- `GET /jobs/{id}/frames/{i}.jpg` — 第 i 张关键帧(0-9) + +## Mock 模式 + +未设 `GEMINI_API_KEY` 时,转录走本地 mock,便于 UI 联调。 + +## 依赖 + +- `ffmpeg` 系统二进制(拆轨 / 抽帧) +- `yt-dlp` 系统二进制(也可走 Python 包) +- `google-generativeai` Python(ASR + 翻译) diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..e34dbcc --- /dev/null +++ b/api/main.py @@ -0,0 +1,334 @@ +from __future__ import annotations + +import asyncio +import json +import os +import shutil +import subprocess +import uuid +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Literal + +from dotenv import load_dotenv +from fastapi import BackgroundTasks, FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from pydantic import BaseModel, Field + +load_dotenv() + +JOBS_DIR = Path(os.getenv("JOBS_DIR", "./jobs")).resolve() +JOBS_DIR.mkdir(parents=True, exist_ok=True) +CORS_ORIGINS = [o.strip() for o in os.getenv("CORS_ORIGINS", "http://localhost:4290").split(",") if o.strip()] +GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "").strip() +GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash") + +# Pipeline 状态:created → downloading → splitting → frames_extracted → transcribing → transcribed | failed +JobStatus = Literal[ + "created", "downloading", "splitting", "frames_extracted", + "transcribing", "transcribed", "failed", +] + + +class KeyFrame(BaseModel): + index: int + timestamp: float + url: str + + +class TranscriptSegment(BaseModel): + index: int + start: float + end: float + en: str + zh: str = "" + + +class Job(BaseModel): + id: str + url: str + status: JobStatus = "created" + progress: int = 0 + message: str = "" + video_url: str = "" + duration: float = 0.0 + width: int = 0 + height: int = 0 + frames: list[KeyFrame] = Field(default_factory=list) + transcript: list[TranscriptSegment] = Field(default_factory=list) + error: str = "" + + +JOBS: dict[str, Job] = {} + + +def job_dir(job_id: str) -> Path: + d = JOBS_DIR / job_id + d.mkdir(parents=True, exist_ok=True) + return d + + +def save_state(job: Job) -> None: + (job_dir(job.id) / "state.json").write_text(job.model_dump_json(indent=2)) + + +def update(job: Job, **kw) -> None: + for k, v in kw.items(): + setattr(job, k, v) + save_state(job) + + +@asynccontextmanager +async def lifespan(_: FastAPI): + # 启动时从磁盘恢复 jobs(简化版:只列目录) + for p in JOBS_DIR.iterdir(): + if p.is_dir() and (p / "state.json").exists(): + try: + JOBS[p.name] = Job.model_validate_json((p / "state.json").read_text()) + except Exception: + pass + yield + + +app = FastAPI(title="SKG TK 二创 API", lifespan=lifespan) +app.add_middleware( + CORSMiddleware, + allow_origins=CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# ---------- Pipeline 实现 ---------- + +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]}") + return res.stdout + + +def ffprobe_meta(mp4: Path) -> dict: + out = run([ + "ffprobe", "-v", "error", "-print_format", "json", "-show_streams", "-show_format", str(mp4), + ]) + return json.loads(out) + + +async def pipeline_download_split_frames(job_id: str) -> None: + """步骤 1+2+3:下载 + 拆音轨 + 抽取关键帧""" + 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") + + # 元数据 + meta = ffprobe_meta(mp4) + v_stream = next((s for s in meta["streams"] if s["codec_type"] == "video"), None) + duration = float(meta["format"]["duration"]) + update( + job, + video_url=f"/jobs/{job_id}/video.mp4", + duration=duration, + width=int(v_stream["width"]) if v_stream else 0, + height=int(v_stream["height"]) if v_stream else 0, + progress=20, + message=f"下载完成 · {duration:.1f}s", + ) + + # ---- 2. 拆音轨 + update(job, status="splitting", message="ffmpeg 拆分音轨…", progress=30) + wav = d / "audio.wav" + run([ + "ffmpeg", "-y", "-i", str(mp4), + "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", + str(wav), + ]) + + # ---- 3. 关键帧抽取(场景切换 + 均匀采样兜底,最多 10 张) + update(job, message="抽取关键帧…", progress=50) + frames_dir = d / "frames" + if frames_dir.exists(): + 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"), + ]) + scene_frames = sorted(frames_dir.glob("scene_*.jpg")) + + # 均匀采样兜底 / 补足 + if len(scene_frames) < 10: + sample_count = 10 - len(scene_frames) + step = duration / (sample_count + 1) + for i in range(sample_count): + t = step * (i + 1) + 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), + ]) + + # 统一排序、按时间戳读取、限制 10 张 + all_frames = sorted(frames_dir.glob("*.jpg"))[:10] + renamed: list[KeyFrame] = [] + for i, src in enumerate(all_frames): + dst = frames_dir / f"{i:03d}.jpg" + if src != dst: + src.rename(dst) + # 简化:用均匀分布估算时间戳(场景切换的精确时间需要解析 showinfo 输出,先省) + ts = duration * (i + 0.5) / max(len(all_frames), 1) + renamed.append(KeyFrame(index=i, timestamp=round(ts, 2), url=f"/jobs/{job_id}/frames/{i}.jpg")) + + update( + job, + status="frames_extracted", + frames=renamed, + progress=70, + message=f"已抽取 {len(renamed)} 张关键帧", + ) + + except Exception as e: + update(job, status="failed", error=str(e), message="管线失败") + + +# ---------- Gemini ASR + 翻译 ---------- + +async def pipeline_transcribe(job_id: str) -> None: + job = JOBS[job_id] + d = job_dir(job_id) + wav = d / "audio.wav" + try: + if not wav.exists(): + raise RuntimeError("audio.wav 不存在") + + update(job, status="transcribing", message="Gemini ASR 处理中…", progress=75) + + if not GEMINI_API_KEY: + # 无 key 模式:mock 数据,方便 UI 联调 + await asyncio.sleep(1.2) + mock_segments = [ + TranscriptSegment(index=0, start=0.0, end=3.5, + en="Welcome back to my channel, today we're testing something new.", + zh="欢迎回来我的频道,今天我们要测试一些新东西。"), + TranscriptSegment(index=1, start=3.5, end=7.2, + en="This device looks really sleek and the design is quite minimal.", + zh="这个设备看起来非常时尚,设计也相当简约。"), + TranscriptSegment(index=2, start=7.2, end=11.0, + en="Let me show you how it works in real life situations.", + zh="让我向你展示它在实际场景中如何工作。"), + ] + update(job, transcript=mock_segments, status="transcribed", progress=100, + message="转录完成(MOCK 模式 · 未设 GEMINI_API_KEY)") + return + + # 真模式:调 Gemini + import google.generativeai as genai + genai.configure(api_key=GEMINI_API_KEY) + model = genai.GenerativeModel(GEMINI_MODEL) + + audio_file = genai.upload_file(str(wav), mime_type="audio/wav") + prompt = ( + "Transcribe the English audio with sentence-level timestamps. " + "Then provide a Chinese translation for each segment. " + "Return strictly as JSON array, no prose, schema: " + '[{"start": float_seconds, "end": float_seconds, "en": "...", "zh": "..."}]' + ) + resp = await asyncio.to_thread( + model.generate_content, + [audio_file, prompt], + generation_config={"response_mime_type": "application/json"}, + ) + raw = resp.text or "[]" + data = json.loads(raw) + segs = [ + TranscriptSegment( + index=i, + start=float(s.get("start", 0)), + end=float(s.get("end", 0)), + en=str(s.get("en", "")), + zh=str(s.get("zh", "")), + ) + for i, s in enumerate(data) + ] + update(job, transcript=segs, status="transcribed", progress=100, + message=f"转录完成 · {len(segs)} 段") + + except Exception as e: + update(job, status="failed", error=str(e), message="转录失败") + + +# ---------- API 路由 ---------- + +class CreateJobReq(BaseModel): + url: str + + +@app.get("/health") +def health() -> dict: + return {"ok": True, "gemini_configured": bool(GEMINI_API_KEY), "model": GEMINI_MODEL} + + +@app.post("/jobs", response_model=Job) +async def create_job(req: CreateJobReq, bg: BackgroundTasks) -> Job: + if not req.url.strip(): + raise HTTPException(400, "url required") + job_id = uuid.uuid4().hex[:12] + job = Job(id=job_id, url=req.url.strip()) + 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) + if not job: + raise HTTPException(404, "job not found") + return job + + +@app.post("/jobs/{job_id}/transcribe", response_model=Job) +async def trigger_transcribe(job_id: str, bg: BackgroundTasks) -> Job: + job = JOBS.get(job_id) + if not job: + raise HTTPException(404, "job not found") + if job.status != "frames_extracted": + raise HTTPException(409, f"status must be frames_extracted, got {job.status}") + bg.add_task(pipeline_transcribe, job_id) + return job + + +@app.get("/jobs/{job_id}/video.mp4") +def get_video(job_id: str): + p = job_dir(job_id) / "source.mp4" + if not p.exists(): + raise HTTPException(404, "video not found") + return FileResponse(p, media_type="video/mp4") + + +@app.get("/jobs/{job_id}/frames/{idx}.jpg") +def get_frame(job_id: str, idx: int): + p = job_dir(job_id) / "frames" / f"{idx:03d}.jpg" + if not p.exists(): + raise HTTPException(404, "frame not found") + return FileResponse(p, media_type="image/jpeg") diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..1304c47 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.115.4 +uvicorn[standard]==0.32.0 +pydantic==2.9.2 +python-multipart==0.0.12 +python-dotenv==1.0.1 +yt-dlp==2026.3.17 +google-generativeai==0.8.3 +httpx==0.27.2 diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..f650315 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,27 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts \ No newline at end of file diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..69d1a93 --- /dev/null +++ b/web/README.md @@ -0,0 +1,105 @@ +# Art Gallery Slider + +A full-screen art gallery experience with GSAP-powered horizontal scrolling, dynamic color extraction, and animated ambient backgrounds. + +## Features + +- **Dynamic Ambient Backgrounds**: Extracts dominant colors from each artwork to create animated gradient backgrounds that shift as you navigate +- **Heavy Drag Interactions**: Smooth drag with resistance and momentum scrolling using Framer Motion +- **Trackpad/Magic Mouse Support**: Horizontal scroll gestures with accumulated delta thresholds +- **Keyboard Navigation**: Arrow keys, A/D, Home/End for full keyboard accessibility +- **Parallax Effects**: Cards shift during drag with parallax motion +- **Hover Animations**: Artwork lifts with slide-up info panels on hover +- **Glassmorphism UI**: Dark editorial aesthetic with blur effects and elegant typography + +## Tech Stack + +- Next.js 15 (App Router) +- React 19 +- Framer Motion (animations, drag gestures) +- GSAP (scroll-triggered animations) +- Tailwind CSS v4 +- TypeScript + +## Project Structure + +\`\`\` +├── app/ +│ ├── page.tsx # Main page +│ ├── layout.tsx # Root layout with fonts +│ └── globals.css # Tailwind config & design tokens +├── components/ +│ ├── art-gallery-slider.tsx # Main slider component +│ ├── artwork-card.tsx # Individual artwork cards +│ └── navigation-dots.tsx # Navigation indicator +├── hooks/ +│ ├── use-slider-navigation.ts # Keyboard & index control +│ ├── use-slider-drag.ts # Mouse/touch drag logic +│ ├── use-slider-wheel.ts # Trackpad/scroll handling +│ └── use-color-extraction.ts # Color management +├── lib/ +│ ├── color-extractor.ts # Canvas-based color extraction +│ └── constants.ts # Configuration values +├── data/ +│ └── artworks.ts # Artwork data +└── types/ + └── artwork.ts # TypeScript interfaces +\`\`\` + +## Prompt to Recreate + +\`\`\` +Build a full-screen art gallery slider with the following features: + +1. LAYOUT & DESIGN +- Full viewport height gallery with horizontal card-based slider +- Dark editorial aesthetic (#0a0a0a background) +- Glassmorphism navigation elements with backdrop blur +- Playfair Display for headings, Inter for body text +- Cards sized at 70vh height with 3:4 aspect ratio + +2. DYNAMIC AMBIENT BACKGROUNDS +- Extract 3 dominant colors from each artwork image using canvas +- Create animated radial gradient backgrounds using extracted colors +- Smooth 600ms crossfade transitions between backgrounds as slides change +- Colors should be distinct (filter similar colors) and not too light + +3. DRAG INTERACTIONS +- Heavy drag feel with 0.4 resistance factor (mouse movement dampened) +- Momentum-based settling with slow easing (0.1 factor) +- Parallax effect on cards during drag (slight horizontal offset based on drag delta) +- Snap to nearest slide on release + +4. TRACKPAD/MAGIC MOUSE SUPPORT +- Detect horizontal wheel events (deltaX) +- Accumulate scroll delta with 0.3 resistance +- Trigger slide change when accumulated delta exceeds 120px threshold +- Reset accumulator after 150ms of inactivity + +5. KEYBOARD NAVIGATION +- Left/Right arrows and A/D keys for prev/next +- Home/End keys to jump to first/last slide +- Visual hint text showing keyboard controls + +6. ARTWORK CARDS +- Image with object-cover fill +- Hover state: lift card with translateY(-8px) and scale(1.02) +- Info panel slides up from bottom on hover (glassmorphism background) +- Display: title, artist name, year +- Prevent text selection on artwork info + +7. NAVIGATION DOTS +- Fixed position at bottom center +- Dots use extracted primary color for active state +- Scale animation on active dot +- Click to navigate to specific slide + +8. CODE ARCHITECTURE +- Separate custom hooks for: navigation, drag, wheel, color extraction +- Centralized types, constants, and data files +- Clean component composition in main slider +\`\`\` + +## License + +MIT diff --git a/web/app/globals.css b/web/app/globals.css new file mode 100644 index 0000000..532f8c6 --- /dev/null +++ b/web/app/globals.css @@ -0,0 +1,131 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +/* Updated color tokens for dark gallery aesthetic */ +:root { + --background: oklch(0.08 0 0); + --foreground: oklch(0.95 0 0); + --card: oklch(0.12 0 0); + --card-foreground: oklch(0.95 0 0); + --popover: oklch(0.1 0 0); + --popover-foreground: oklch(0.95 0 0); + --primary: oklch(0.95 0 0); + --primary-foreground: oklch(0.1 0 0); + --secondary: oklch(0.2 0 0); + --secondary-foreground: oklch(0.95 0 0); + --muted: oklch(0.2 0 0); + --muted-foreground: oklch(0.6 0 0); + --accent: oklch(0.25 0 0); + --accent-foreground: oklch(0.95 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.25 0 0); + --input: oklch(0.2 0 0); + --ring: oklch(0.5 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.75rem; + --sidebar: oklch(0.1 0 0); + --sidebar-foreground: oklch(0.95 0 0); + --sidebar-primary: oklch(0.95 0 0); + --sidebar-primary-foreground: oklch(0.1 0 0); + --sidebar-accent: oklch(0.2 0 0); + --sidebar-accent-foreground: oklch(0.95 0 0); + --sidebar-border: oklch(0.25 0 0); + --sidebar-ring: oklch(0.5 0 0); +} + +/* Added Playfair Display font for serif headings */ +@theme inline { + --font-sans: "Geist", "Geist Fallback"; + --font-serif: "Playfair Display", Georgia, serif; + --font-mono: "Geist Mono", "Geist Mono Fallback"; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} + +/* Workbench: keep dark gallery aesthetic but allow scroll */ +html, body { + background: #0a0a0a; + min-height: 100vh; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} +::-webkit-scrollbar-thumb { + background: oklch(0.25 0 0); + border-radius: 4px; +} +::-webkit-scrollbar-track { + background: transparent; +} + +/* Ambient glow utility — 04 风格核心 */ +.ambient-glow { + position: absolute; + inset: 0; + pointer-events: none; + background: radial-gradient(circle at 30% 30%, oklch(0.35 0.1 250 / 0.25), transparent 60%), + radial-gradient(circle at 70% 70%, oklch(0.35 0.08 300 / 0.18), transparent 55%); + filter: blur(80px); + z-index: 0; +} + +/* Glass card — 04 风格 */ +.glass-card { + background: rgba(255, 255, 255, 0.04); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius); +} diff --git a/web/app/layout.tsx b/web/app/layout.tsx new file mode 100644 index 0000000..2ca2bb6 --- /dev/null +++ b/web/app/layout.tsx @@ -0,0 +1,28 @@ +import type React from "react" +import type { Metadata } from "next" +import { Geist, Geist_Mono, Playfair_Display } from "next/font/google" +import "./globals.css" + +const _geist = Geist({ subsets: ["latin"] }) +const _geistMono = Geist_Mono({ subsets: ["latin"] }) +const _playfairDisplay = Playfair_Display({ + subsets: ["latin"], + variable: "--font-playfair", +}) + +export const metadata: Metadata = { + title: "SKG TK 二创工作台", + description: "SKG AI 素材生产管线 · TK 链接 → 关键帧 + 双语转录 → 改写 / 生图 / 生视频", +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + {children} + + ) +} diff --git a/web/app/page.tsx b/web/app/page.tsx new file mode 100644 index 0000000..0032ff3 --- /dev/null +++ b/web/app/page.tsx @@ -0,0 +1,162 @@ +"use client" +import { useCallback, useEffect, useRef, useState } from "react" +import { Toaster, toast } from "sonner" +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" + +export default function Home() { + const [job, setJob] = useState(null) + const [submitting, setSubmitting] = useState(false) + const [selected, setSelected] = useState>(new Set()) + const videoRef = useRef(null) + const pollRef = useRef | null>(null) + const transcribeTriggeredRef = useRef(null) + + const handleSubmit = useCallback(async (url: string) => { + setSubmitting(true) + setSelected(new Set()) + transcribeTriggeredRef.current = null + try { + const created = await createJob(url) + 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 + if (job.status === "transcribed" || job.status === "failed") { + if (pollRef.current) clearInterval(pollRef.current) + return + } + pollRef.current = setInterval(async () => { + try { + const latest = await getJob(job.id) + setJob(latest) + } catch { + // silent + } + }, 1500) + return () => { + if (pollRef.current) clearInterval(pollRef.current) + } + }, [job?.id, job?.status]) + + // 抽帧完成后自动触发 ASR + useEffect(() => { + if (!job) return + if (job.status !== "frames_extracted") return + if (transcribeTriggeredRef.current === job.id) return + transcribeTriggeredRef.current = job.id + triggerTranscribe(job.id).catch((e) => toast.error("启动转录失败:" + e.message)) + }, [job?.id, job?.status]) + + const toggleFrame = (idx: number) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(idx)) next.delete(idx) + else if (next.size < 10) next.add(idx) + return next + }) + } + + const handleSeek = (sec: number) => { + if (videoRef.current) { + videoRef.current.currentTime = sec + videoRef.current.play().catch(() => {}) + } + } + + return ( +
+
+
+ {/* Header */} +
+
SKG · AI Material Pipeline
+

+ TK 二创工作台 + / Verification Prototype +

+

+ 粘贴 TikTok 链接 → 自动抽取关键帧 + Gemini 双语转录 → 后续接入文案改写 / 生图 / 生视频。 +

+
+ + {/* URL 输入 */} +
+ +
+ + {job && ( + <> + {/* 状态条 */} +
+ +
+ + {/* 视频预览 + 关键帧 */} + {job.video_url && ( +
+
+
+
+
+
关键帧 · Keyframes
+
自动抽取,点击勾选最多 10 张作为生图参考
+
+ +
+
+ )} + + {/* 双语转录 */} + {(job.frames.length > 0 || job.transcript.length > 0) && ( +
+
+
双语转录 · Transcript
+
点击段落跳转视频时间点
+
+ +
+ )} + + )} + + {!job && ( +
+
↑ 粘贴一条 TikTok 链接开始
+
+ )} + + {/* Footer */} +
+
SKG TK 二创验证 · MVP 第一冲刺(步骤 1-4)
+
4290 · {process.env.NEXT_PUBLIC_API_BASE ?? "localhost:4291"}
+
+
+ +
+ ) +} diff --git a/web/components.json b/web/components.json new file mode 100644 index 0000000..4ee62ee --- /dev/null +++ b/web/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/web/components/job-status.tsx b/web/components/job-status.tsx new file mode 100644 index 0000000..69db84a --- /dev/null +++ b/web/components/job-status.tsx @@ -0,0 +1,56 @@ +"use client" +import { type Job, type JobStatus } from "@/lib/api" +import { CheckCircle2, Circle, Loader2, XCircle } from "lucide-react" + +const STAGES: { key: JobStatus; label: string }[] = [ + { key: "downloading", label: "下载视频" }, + { key: "splitting", label: "拆分音视频" }, + { key: "frames_extracted", label: "抽取关键帧" }, + { key: "transcribing", label: "Gemini 转录+翻译" }, + { key: "transcribed", label: "完成" }, +] + +const ORDER: JobStatus[] = ["created", "downloading", "splitting", "frames_extracted", "transcribing", "transcribed"] + +export function JobStatusBar({ job }: { job: Job }) { + const currentIdx = ORDER.indexOf(job.status) + return ( +
+
+
Job {job.id.slice(0, 8)}
+
+ {job.status === "failed" ? `失败: ${job.error ?? "未知错误"}` : (job.message ?? "")} +
+
+
+ {STAGES.map((s, i) => { + const stageIdx = ORDER.indexOf(s.key) + const done = currentIdx >= stageIdx && job.status !== "failed" + const active = currentIdx === stageIdx - 1 && job.status !== "failed" && job.status !== "transcribed" + const failed = job.status === "failed" && currentIdx + 1 === stageIdx + return ( +
+
+ {failed ? ( + + ) : done ? ( + + ) : active ? ( + + ) : ( + + )} + + {s.label} + +
+ {i < STAGES.length - 1 && ( +
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/web/components/keyframe-gallery.tsx b/web/components/keyframe-gallery.tsx new file mode 100644 index 0000000..26fc97c --- /dev/null +++ b/web/components/keyframe-gallery.tsx @@ -0,0 +1,92 @@ +"use client" +import { useState } from "react" +import { motion, AnimatePresence } from "framer-motion" +import { Check, Plus } from "lucide-react" +import { type KeyFrame } from "@/lib/api" + +interface Props { + frames: KeyFrame[] + selected: Set + onToggle: (frameIndex: number) => void + maxSelect?: number +} + +function formatTimestamp(t: number) { + const m = Math.floor(t / 60) + const s = Math.floor(t % 60) + return `${m}:${s.toString().padStart(2, "0")}` +} + +export function KeyframeGallery({ frames, selected, onToggle, maxSelect = 10 }: Props) { + const [hoverIdx, setHoverIdx] = useState(null) + + if (frames.length === 0) { + return ( +
+ 关键帧将在下载完成后出现 +
+ ) + } + + return ( +
+
+ 已选 {selected.size} / {maxSelect} +
+
+ {frames.map((f) => { + const isSelected = selected.has(f.index) + const isHover = hoverIdx === f.index + const disabled = !isSelected && selected.size >= maxSelect + return ( + !disabled && onToggle(f.index)} + onMouseEnter={() => setHoverIdx(f.index)} + onMouseLeave={() => setHoverIdx(null)} + disabled={disabled} + animate={{ + scale: isSelected ? 1 : isHover ? 0.98 : 0.92, + opacity: isSelected ? 1 : disabled ? 0.3 : isHover ? 0.95 : 0.7, + }} + transition={{ duration: 0.3, ease: [0.32, 0.72, 0, 1] }} + className="relative shrink-0 snap-start rounded-lg overflow-hidden border border-white/10 disabled:cursor-not-allowed" + style={{ width: 200, height: 120 }} + > + {`frame + {/* 选中态:ambient glow */} + + {isSelected && ( + + )} + + {/* 时间戳 */} +
+ {formatTimestamp(f.timestamp)} +
+ {/* 选中标记 */} +
+ {isSelected ? : } +
+
+ ) + })} +
+
+ ) +} diff --git a/web/components/theme-provider.tsx b/web/components/theme-provider.tsx new file mode 100644 index 0000000..55c2f6e --- /dev/null +++ b/web/components/theme-provider.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as React from 'react' +import { + ThemeProvider as NextThemesProvider, + type ThemeProviderProps, +} from 'next-themes' + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children} +} diff --git a/web/components/transcript-panel.tsx b/web/components/transcript-panel.tsx new file mode 100644 index 0000000..8f24fbb --- /dev/null +++ b/web/components/transcript-panel.tsx @@ -0,0 +1,57 @@ +"use client" +import { type TranscriptSegment } from "@/lib/api" + +interface Props { + segments: TranscriptSegment[] + loading?: boolean + onSeek?: (sec: number) => void +} + +function formatTs(t: number) { + const m = Math.floor(t / 60) + const s = Math.floor(t % 60) + return `${m}:${s.toString().padStart(2, "0")}` +} + +export function TranscriptPanel({ segments, loading, onSeek }: Props) { + if (segments.length === 0) { + return ( +
+ {loading ? "Gemini 转录中…" : "转录将在抽帧后自动开始"} +
+ ) + } + + return ( +
+
+
+
English (Gemini ASR)
+
+
+
中文翻译 (Gemini)
+
+
+
+ {segments.map((seg) => ( +
onSeek?.(seg.start)} + > +
+
+ {formatTs(seg.start)} → {formatTs(seg.end)} +
+
{seg.en}
+
+
+
 
+
{seg.zh || 翻译中…}
+
+
+ ))} +
+
+ ) +} diff --git a/web/components/ui/accordion.tsx b/web/components/ui/accordion.tsx new file mode 100644 index 0000000..e538a33 --- /dev/null +++ b/web/components/ui/accordion.tsx @@ -0,0 +1,66 @@ +'use client' + +import * as React from 'react' +import * as AccordionPrimitive from '@radix-ui/react-accordion' +import { ChevronDownIcon } from 'lucide-react' + +import { cn } from '@/lib/utils' + +function Accordion({ + ...props +}: React.ComponentProps) { + return +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + svg]:rotate-180', + className, + )} + {...props} + > + {children} + + + + ) +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
{children}
+
+ ) +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/web/components/ui/alert-dialog.tsx b/web/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..9704452 --- /dev/null +++ b/web/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +'use client' + +import * as React from 'react' +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' + +import { cn } from '@/lib/utils' +import { buttonVariants } from '@/components/ui/button' + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/web/components/ui/alert.tsx b/web/components/ui/alert.tsx new file mode 100644 index 0000000..e6751ab --- /dev/null +++ b/web/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current', + { + variants: { + variant: { + default: 'bg-card text-card-foreground', + destructive: + 'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/web/components/ui/aspect-ratio.tsx b/web/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..40bb120 --- /dev/null +++ b/web/components/ui/aspect-ratio.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as AspectRatioPrimitive from '@radix-ui/react-aspect-ratio' + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return +} + +export { AspectRatio } diff --git a/web/components/ui/avatar.tsx b/web/components/ui/avatar.tsx new file mode 100644 index 0000000..aa98465 --- /dev/null +++ b/web/components/ui/avatar.tsx @@ -0,0 +1,53 @@ +'use client' + +import * as React from 'react' +import * as AvatarPrimitive from '@radix-ui/react-avatar' + +import { cn } from '@/lib/utils' + +function Avatar({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/web/components/ui/badge.tsx b/web/components/ui/badge.tsx new file mode 100644 index 0000000..fc4126b --- /dev/null +++ b/web/components/ui/badge.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/lib/utils' + +const badgeVariants = cva( + 'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90', + secondary: + 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', + destructive: + 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +function Badge({ + className, + variant, + asChild = false, + ...props +}: React.ComponentProps<'span'> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot : 'span' + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/web/components/ui/breadcrumb.tsx b/web/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..1750ff2 --- /dev/null +++ b/web/components/ui/breadcrumb.tsx @@ -0,0 +1,109 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { ChevronRight, MoreHorizontal } from 'lucide-react' + +import { cn } from '@/lib/utils' + +function Breadcrumb({ ...props }: React.ComponentProps<'nav'>) { + return