diff --git a/.gitignore b/.gitignore index f5b6fce4..c6b4a537 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ docs/superpowers/ ..bfg-report/ .superpowers/ __pycache__/ +.vite/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..28149dd9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +注意,在调用写文件的工具书写长计划时,可能因为内容太长导致调用错误,可以尝试分块书写计划。 diff --git a/scripts/test-cli.sh b/scripts/test-cli.sh old mode 100755 new mode 100644 diff --git a/skills/.skills_store_lock.json b/skills/.skills_store_lock.json new file mode 100644 index 00000000..8c75022e --- /dev/null +++ b/skills/.skills_store_lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "voice": { + "name": "Voice", + "zip_url": "https://lightmake.site/api/v1/download?slug=voice", + "source": "skillhub", + "version": "1.0.1" + } + } +} diff --git a/skills/asset-generation/SKILL.md b/skills/asset-generation/SKILL.md index f3107960..f77caa87 100644 --- a/skills/asset-generation/SKILL.md +++ b/skills/asset-generation/SKILL.md @@ -101,7 +101,7 @@ description: Generate images and videos for Douyin (抖音) and Xiaohongshu (小 开始生成前,先收集所有上下文信息: ```bash -# 1. 获取作品详情和方案 +# 1. 获取作品详情和方案(检查 usePortrait 字段) curl http://localhost:3271/api/works/{workId} # 2. 查看共享素材(参考图、角色参考、音乐等) @@ -114,6 +114,32 @@ curl http://localhost:3271/api/works/{workId}/assets python3 skills/asset-generation/scripts/check_providers.py ``` +### 画像关联模式 + +检查作品详情中的 `usePortrait` 字段。如果为 `true`,**所有生成必须使用用户画像中的参考图**: + +| 画像分类 | 用途 | 生成时操作 | +|---------|------|----------| +| **characters(形象参照)** | 人脸/体型/穿搭一致性 | **每次生成含人物的图片/视频都必须传入** `--ref-image http://localhost:3271/api/shared-assets/characters/<文件名>` | +| **scenes(场景参照)** | 家里/健身房/办公室等环境风格 | 在 prompt 中描述场景细节,并传入 `--ref-image` 确保环境一致 | +| **branding(品牌调性)** | 色调/字体/视觉风格 | 在 prompt 中明确要求匹配品牌风格 | +| **templates(画面模板)** | 构图/排版参照 | 参照模板的构图方式生成 | + +**关键规则:** +- 人物图片必须每张都传入 characters 参考图,不能只在第一张用 +- prompt 中必须用文字描述参考图中人物的具体特征(性别、体型、发型、肤色、穿搭风格) +- 所有图片之间必须保持风格/色调/人物外观的一致性 +- 如果有多张 characters 参考图,选择角度最匹配当前需求的那张 + +```bash +# 示例:使用画像参考图生成 +python3 skills/asset-generation/scripts/openrouter_generate.py \ + --prompt "阳光健身男性在晨光中做引体向上,侧面特写,电影质感" \ + --ref-image http://localhost:3271/api/shared-assets/characters/face-ref.jpg \ + --ref-image http://localhost:3271/api/shared-assets/scenes/gym.jpg \ + --ar 9:16 --output {workDir}/assets/images/shot-01.png +``` + --- ## 生成脚本 @@ -310,6 +336,7 @@ dreamina list_task --gen_status=success **判断提交是否成功:** 不要只看 shell 退出码,必须检查 JSON 输出中的 `submit_id` 和 `gen_status`。`gen_status=querying` 或 `success` 才算成功;`fail` 时查看 `fail_reason`。 + #### 4. `jimeng_generate.py` — 即梦 API(**视频生成备用 + 备用图片**) 需要 `JIMENG_ACCESS_KEY` + `JIMENG_SECRET_KEY`。**仅在 Dreamina CLI 不可用时使用。** @@ -332,6 +359,42 @@ python3 skills/asset-generation/scripts/jimeng_generate.py video \ --prompt "动作描述" --first-frame frame.png --output clip.mp4 ``` +#### 4.5 即梦对嘴型(Lip-Sync)— 让视频人物说指定的话 + +需要 `JIMENG_ACCESS_KEY` + `JIMENG_SECRET_KEY`。使用火山引擎 `realman_change_lips` API,对一个**已生成的视频**进行口型同步,让其中的人物对着指定音频说话。 + +**适用场景:** +- 口播类视频:先用 AI 生成人物视频片段,再用 TTS 生成配音,最后对嘴型合成 +- 需要人物"说话"但又不是真人拍摄的内容 + +**通过 API 调用:** + +```bash +curl -X POST http://localhost:3271/api/generate/lip-sync \ + -H "Content-Type: application/json" \ + -d '{ + "workId": "work-xxx", + "videoUrl": "https://..../source_video.mp4", + "audioUrl": "https://..../narration.mp3", + "filename": "lipsync_talking.mp4" + }' +``` + +**输入限制:** +- 视频:3-350 秒,360p-1080p,MP4/MOV,H.264,24-60fps,≤500MB +- 音频:1-240 秒 +- 人脸角度:左右 ≤30°,上下 ≤15°,倾斜 ≤20° +- 输出:MP4,25fps,时长跟随音频长度 + +**口播视频推荐流程:** +1. 用 Dreamina/即梦生成人物视频片段(正面中景,表情自然) +2. 用 edge-tts 生成旁白音频 +3. 将视频和音频上传获取 URL(或使用已有 URL) +4. 调用 lip-sync API 合成对嘴型视频 +5. 在 content-assembly 阶段叠加字幕和 BGM + +> **⚠️ 注意:** 视频和音频参数需要传入可访问的 URL。如果素材是本地文件,需要先通过 `/api/works/{workId}/assets` 上传获取 URL。 + #### 5. `music_generate.py` — Lyria 音乐生成(**BGM/配乐**) 需要 `OPENROUTER_API_KEY`。模型 `google/lyria-3-pro-preview`,生成 ~2 分钟完整音乐。 @@ -369,19 +432,68 @@ python3 skills/asset-generation/scripts/music_generate.py \ > 详细的 prompt 工程技巧和情绪-风格映射请阅读 `modules/music-generation.md` -**选择策略:** -1. **视频生成** → **优先 Dreamina CLI**(`dreamina` 命令,Seedance 2.0 模型,功能最全最强) -2. **视频备用** → Dreamina CLI 未登录时,回退到 `jimeng_generate.py`(需要 API Key) -3. **图片生成** → 优先 `openrouter_generate.py`(Gemini 3.1 Flash,画质最好,参数最丰富) -4. **图片备用** → Dreamina CLI `dreamina text2image`(Seedream 5.0,最高 4K)或 `jimeng_generate.py image` -5. **音乐生成** → 使用 `music_generate.py`(Lyria Pro,~2分钟完整曲目) -6. **图文排版** → 使用 `poster_render.py`(HTML/CSS 模板渲染,文字清晰可控) -7. 先运行 `check_providers.py` 确认可用服务(包括 Dreamina CLI 登录态检查) - -> **视频生成决策树:** -> Dreamina CLI 已登录?→ 用 `dreamina` 命令(首选) -> Dreamina CLI 未登录 + JIMENG API Key 可用?→ 用 `jimeng_generate.py`(回退) -> 都不可用?→ 提示用户执行 `dreamina login` 或配置 API Key +**选择策略(严格按此顺序):** +1. **视频生成** → **必须使用 Dreamina CLI**(`dreamina image2video` / `dreamina multiframe2video`,Seedance 2.0 模型)。**禁止使用 jimeng_generate.py 或 Jimeng API key,禁止降级到 ffmpeg Ken Burns** +2. **口播说话视频** → 使用 `sadtalker_generate.py`(用户照片 + 旁白音频 → 口型同步说话视频) +3. **图片生成** → 优先 `openrouter_generate.py`(Gemini 3.1 Flash),备选 `dreamina text2image` +4. **音乐生成** → 使用 `music_generate.py`(Lyria Pro) +5. **图文排版** → 使用 `poster_render.py`(HTML/CSS 模板渲染) + +> **⚠️ 素材阶段禁止添加背景音乐:** 生成视频片段(clips)时,**不要**混入 BGM 或任何音频。每个 clip 应该是纯视频(或静音)。背景音乐只在 content-assembly 最终合成阶段统一添加,这样才能保证音乐节奏与整体剪辑匹配。 + +> **Dreamina CLI 已安装并登录(积分充足)。直接使用 `dreamina` 命令,不需要 API key。** +> 详细用法请阅读 `modules/dreamina-mastery.md` + +#### 7. `sadtalker_generate.py` — 口型同步说话视频(**口播视频必用**) + +用用户的照片 + 旁白音频生成口型同步的说话视频。适用于 dating advice、观点输出、情感分析等口播类短视频。 + +**安装 SadTalker(一次性):** +```bash +git clone https://github.com/OpenTalker/SadTalker.git ~/SadTalker +cd ~/SadTalker && pip install -r requirements.txt +bash scripts/download_models.sh +``` + +**口播视频完整工作流:** + +```bash +# 1. 用 edge-tts 生成旁白音频 +edge-tts --text "旁白文本内容" --voice zh-CN-YunxiNeural \ + --write-media {workDir}/assets/clips/narration.mp3 + +# 2. 用 SadTalker 生成说话视频(用户照片 + 旁白音频 → 说话视频) +python3 skills/asset-generation/scripts/sadtalker_generate.py \ + --image {workDir}/assets/frames/face.png \ + --audio {workDir}/assets/clips/narration.mp3 \ + --output {workDir}/assets/clips/talking.mp4 \ + --still --enhancer gfpgan + +# 3. 用 Dreamina 生成 B-roll 辅助画面(穿插在口播之间) +dreamina image2video \ + --image {workDir}/assets/frames/broll-01.png \ + --prompt="场景描述" \ + --duration=5 --model_version=seedance2.0 --poll=120 +``` + +**参数说明:** + +| 参数 | 说明 | 默认值 | +|------|------|--------| +| `--image` | 用户人脸照片(正面,清晰)| 必填 | +| `--audio` | 旁白音频文件 | 必填 | +| `--output` | 输出视频路径 | 必填 | +| `--still` | 减少头部晃动(口播推荐开启)| 默认开启 | +| `--enhancer gfpgan` | 人脸增强(提升清晰度)| 默认 gfpgan | +| `--size 256/512` | 人脸裁剪尺寸 | 256 | +| `--expression-scale` | 表情强度 0.0-2.0 | 1.0 | +| `--device` | cpu/cuda/mps(Mac 用 cpu)| 自动检测 | + +**人脸照片要求:** +- 正面照,眼睛看镜头 +- 嘴巴自然闭合(不要张嘴) +- 光线均匀,背景简洁 +- 优先使用用户上传的 `characters/` 目录中的照片 #### 6. `font_manager.py` — 字体管理器(共享组件) diff --git a/skills/asset-generation/genres/comedy.md b/skills/asset-generation/genres/comedy.md index e24d15b1..9b38c2fe 100644 --- a/skills/asset-generation/genres/comedy.md +++ b/skills/asset-generation/genres/comedy.md @@ -15,13 +15,13 @@ | **特写** | 表情包时刻——眼神、嘴角、微表情是笑点的放大器 | | **快速推镜到特写** | 强调"刚才发生了什么?"的震惊感 | -### 构图服务于笑点 +### 构图 - **正中构图**:角色面对镜头正中央,制造"一本正经"的感觉 - **刻意留空**:画面一边空着,暗示"有什么要来了" -- **打破第四面墙**:角色突然看向镜头,观众从旁观者变成参与者 +- **打破第四面墙**:角色突然看向镜头 -### 搞笑类色调 +### 色调 | 场景类型 | 色调建议 | 原因 | |---------|---------|------| @@ -29,13 +29,6 @@ | 模仿正式场景 | 电影感色调(青橙调色) | 用电影质感拍沙雕内容 = 反差 | | 怀旧/模仿 | 复古滤镜、老电视效果 | 致敬和模仿的氛围 | -### 生成提示词调整 - -搞笑类素材的提示词需要特别注意: -- **表情要精确**:`surprised expression with wide eyes and slightly open mouth` 而不是 `funny face` -- **场景反差要明确**:同时描述"正经"的环境和"不正经"的元素 -- **避免过度夸张**:生成模型容易把"搞笑"变成"扭曲",保持角色形象自然 - --- ## 抽象类画面规则 @@ -44,21 +37,21 @@ **每个视觉元素都必须是"正宗"的那个世界的产物**: -- 如果画面要"高级"→ 就要真的高级:构图、光线、色调都是电影级 -- 如果画面要"土味"→ 就要真的土:画质、场景、滤镜都要到位 -- 如果是两个视觉元素的碰撞 → 各自都要纯正,不能两边都将就 +- 如果画面要"高级"→ 构图、光线、色调都是电影级 +- 如果画面要"土味"→ 画质、场景、滤镜都要到位 +- 两个视觉元素碰撞 → 各自都要纯正,不能两边都将就 -### 抽象类色调策略 +### 色调策略 | 抽象类型 | 色调 | 原因 | |---------|------|------| | 过度认真型 | 电影级调色(青橙/高对比) | 用大片质感拍小事 = 反差最大化 | -| 过度随意型 | 自然/随意 | 随意的画面 + 随意的态度 = 内容的重大性被消解 | -| 感官错配型 | 服从画面端的极端 | 画面赛博就要真的赛博,画面田园就要真的田园 | -| 形式解构型 | 模仿被解构形式的色调 | 模仿美食视频就用美食视频的暖色调 | -| 故意劣质 | 过度饱和/故意低画质 | 画面本身就是梗的一部分 | +| 过度随意型 | 自然/随意 | 随意的画面 + 随意的态度 | +| 感官错配型 | 服从画面端的极端 | 画面赛博就要真的赛博 | +| 形式解构型 | 模仿被解构形式的色调 | 模仿美食视频就用暖色调 | +| 故意劣质 | 过度饱和/故意低画质 | 画面本身就是梗 | -### 生成提示词调整 +### 提示词调整 生成搞笑/抽象类素材时,提示词需要特别注意: @@ -66,27 +59,3 @@ 2. **抽象类强调"认真感"**:角色表情要 serious/composed/focused,绝不能 smiling 或 laughing 3. **环境反差要在提示词中体现**:如果是"将军在星巴克",将军的服装和星巴克的环境都要在提示词中详细描述 4. **色调在提示词层面就要控制**:不要依赖后期调色,在生成时就指定色调方向 - -抽象类素材的提示词关键在于**纯度**: -- **高级端**:使用电影级提示词 `cinematic lighting, professional color grading, anamorphic lens, film grain` -- **土味端**:使用低质量提示词 `phone camera quality, harsh fluorescent lighting, cluttered background` -- **不要在提示词中混合两种风格**:每个素材只属于一个"世界",错位在组合时产生,而不是在单个画面中产生 - -### 角色/道具/服装要求 - -抽象内容对道具和服装的要求特别高: -- **服装必须到位**:如果角色是"古装将军",盔甲、头盔、配剑一个不能少 -- **场景必须到位**:如果场景是"星巴克",要有真实的咖啡店元素 -- **在提示词中明确描述每个元素的细节**,不要用模糊的词汇 - ---- - -## 三个必要条件(质量底线) - -生成每个素材后,用以下标准检查: - -| 条件 | 说明 | 破功的反例 | -|------|------|-----------| -| **有明确的错位** | 至少两个属性来自不同世界 | 只是"有点奇怪"不够 | -| **各属性内部纯正** | 每个属性单独看都是正宗的 | 重金属要是真的重金属,儿歌要是真的儿歌 | -| **创作者态度认真** | 没有"我知道这很搞笑"的暗示 | 角色表情不应该是"在演搞笑" | diff --git a/skills/asset-generation/scripts/check_providers.py b/skills/asset-generation/scripts/check_providers.py old mode 100755 new mode 100644 diff --git a/skills/asset-generation/scripts/jimeng_generate.py b/skills/asset-generation/scripts/jimeng_generate.py old mode 100755 new mode 100644 diff --git a/skills/asset-generation/scripts/lyria_music.py b/skills/asset-generation/scripts/lyria_music.py new file mode 100644 index 00000000..c0f06f00 --- /dev/null +++ b/skills/asset-generation/scripts/lyria_music.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +Lyria 背景配乐生成工具 +通过 OpenRouter API 调用 Google Lyria 模型生成背景音乐。 + +用法: + python3 lyria_music.py --prompt "upbeat lo-fi hip hop, chill study vibes" --output bgm.mp3 + python3 lyria_music.py --prompt "cinematic orchestral, epic and inspiring" --output bgm.mp3 --model google/lyria-3-pro-preview + +模型: + google/lyria-3-clip-preview — 30秒片段 (默认,适合短视频) + google/lyria-3-pro-preview — 完整歌曲 (1-2分钟) + +环境变量(从 .env 读取): + OPENROUTER_API_KEY OpenRouter API 密钥 +""" + +import argparse +import base64 +import json +import os +import sys +from pathlib import Path + +import requests + +# ── 配置 ────────────────────────────────────────────────────────────── + +OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" +DEFAULT_MODEL = "google/lyria-3-clip-preview" + + +# ── .env 读取 ──────────────────────────────────────────────────────── + + +def load_env() -> dict[str, str]: + current = Path(__file__).resolve().parent + for _ in range(10): + candidate = current / ".env" + if candidate.exists(): + env_vars = {} + with open(candidate) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + if "=" in line: + key, _, value = line.partition("=") + env_vars[key.strip()] = value.strip() + return env_vars + current = current.parent + return {} + + +def get_api_key() -> str: + key = os.environ.get("OPENROUTER_API_KEY", "") + if not key: + env_vars = load_env() + key = env_vars.get("OPENROUTER_API_KEY", "") + if not key: + print("[ERROR] OPENROUTER_API_KEY not configured", file=sys.stderr) + sys.exit(1) + return key + + +# ── 音乐生成 ───────────────────────────────────────────────────────── + + +def generate_music( + api_key: str, + prompt: str, + output_path: str, + model: str = DEFAULT_MODEL, +) -> dict: + """调用 OpenRouter Lyria 生成背景音乐""" + print(f"[*] Model: {model}", file=sys.stderr) + print(f"[*] Generating music...", file=sys.stderr) + + resp = requests.post( + OPENROUTER_URL, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "HTTP-Referer": "http://localhost:3271", + }, + json={ + "model": model, + "modalities": ["text", "audio"], + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": prompt}], + } + ], + }, + timeout=180, + ) + + if not resp.ok: + raise RuntimeError(f"API error {resp.status_code}: {resp.text[:500]}") + + data = resp.json() + message = data.get("choices", [{}])[0].get("message", {}) + content = message.get("content") + + # Extract audio data from response + audio_data = None + + if isinstance(content, list): + for part in content: + # Check inline_data (Gemini audio format) + if part.get("type") == "inline_data" or "inline_data" in part: + inline = part.get("inline_data", part) + if inline.get("data"): + audio_data = base64.b64decode(inline["data"]) + break + # Check audio_url format + if part.get("type") == "audio_url": + url = (part.get("audio_url") or {}).get("url", "") + if url.startswith("data:"): + # data:audio/mp3;base64,... + b64 = url.split(",", 1)[1] if "," in url else "" + if b64: + audio_data = base64.b64decode(b64) + break + # Check for base64 audio in text + if part.get("type") == "audio" and part.get("source", {}).get("data"): + audio_data = base64.b64decode(part["source"]["data"]) + break + + # Fallback: check message.audio + if not audio_data: + audio = message.get("audio") + if isinstance(audio, dict) and audio.get("data"): + audio_data = base64.b64decode(audio["data"]) + + if not audio_data: + raise RuntimeError(f"No audio data found in response. Keys: {list(message.keys())}") + + out = Path(output_path) + out.parent.mkdir(parents=True, exist_ok=True) + out.write_bytes(audio_data) + + size_kb = out.stat().st_size / 1024 + print(f"[*] Music saved: {out} ({size_kb:.1f} KB)", file=sys.stderr) + + return { + "success": True, + "output": str(out.resolve()), + "size_kb": round(size_kb, 1), + "model": model, + } + + +# ── 主入口 ────────────────────────────────────────────────────────── + + +def main(): + parser = argparse.ArgumentParser(description="Lyria background music generator") + parser.add_argument("--prompt", required=True, help="Music description/style") + parser.add_argument("--output", required=True, help="Output file path (.mp3)") + parser.add_argument("--model", default=DEFAULT_MODEL, help=f"Model (default: {DEFAULT_MODEL})") + args = parser.parse_args() + + api_key = get_api_key() + + try: + result = generate_music(api_key, args.prompt, args.output, args.model) + print(json.dumps(result, ensure_ascii=False, indent=2)) + except Exception as e: + error = {"success": False, "error": str(e)} + print(json.dumps(error, ensure_ascii=False, indent=2)) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/skills/asset-generation/scripts/openrouter_generate.py b/skills/asset-generation/scripts/openrouter_generate.py old mode 100755 new mode 100644 diff --git a/skills/asset-generation/scripts/sadtalker_generate.py b/skills/asset-generation/scripts/sadtalker_generate.py new file mode 100644 index 00000000..066ac432 --- /dev/null +++ b/skills/asset-generation/scripts/sadtalker_generate.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +""" +SadTalker wrapper — generates lip-sync talking-head video from a face image + audio. + +Usage: + python3 sadtalker_generate.py \ + --image face.png \ + --audio narration.mp3 \ + --output output/talking.mp4 \ + [--still] [--enhancer gfpgan] [--size 256|512] [--device cpu|cuda|mps] + +Requirements: + - SadTalker cloned at ~/SadTalker (or set SADTALKER_DIR env var) + - pip install -r ~/SadTalker/requirements.txt + - Checkpoints downloaded: cd ~/SadTalker && bash scripts/download_models.sh +""" + +import argparse +import subprocess +import sys +import os +import shutil +import glob + + +def find_sadtalker_dir(): + """Locate SadTalker installation.""" + env_dir = os.environ.get("SADTALKER_DIR") + if env_dir and os.path.isdir(env_dir): + return env_dir + candidates = [ + os.path.expanduser("~/SadTalker"), + os.path.expanduser("~/sadtalker"), + os.path.join(os.path.dirname(__file__), "..", "..", "..", "SadTalker"), + ] + for c in candidates: + if os.path.isdir(c) and os.path.isfile(os.path.join(c, "inference.py")): + return os.path.abspath(c) + return None + + +def main(): + parser = argparse.ArgumentParser(description="Generate lip-sync video with SadTalker") + parser.add_argument("--image", required=True, help="Source face image (png/jpg)") + parser.add_argument("--audio", required=True, help="Driven audio file (mp3/wav)") + parser.add_argument("--output", required=True, help="Output video path (mp4)") + parser.add_argument("--still", action="store_true", default=True, + help="Reduce head motion (default: on)") + parser.add_argument("--no-still", dest="still", action="store_false", + help="Allow natural head motion") + parser.add_argument("--enhancer", default="gfpgan", choices=["gfpgan", "none"], + help="Face enhancer (default: gfpgan)") + parser.add_argument("--size", type=int, default=256, choices=[256, 512], + help="Face crop size (default: 256)") + parser.add_argument("--expression-scale", type=float, default=1.0, + help="Expression intensity (default: 1.0)") + parser.add_argument("--pose-style", type=int, default=0, + help="Head pose template 0-45 (default: 0)") + parser.add_argument("--device", default=None, + help="Device: cpu/cuda/mps (auto-detect if omitted)") + args = parser.parse_args() + + # Find SadTalker + sd_dir = find_sadtalker_dir() + if not sd_dir: + print("ERROR: SadTalker not found. Install it first:", file=sys.stderr) + print(" git clone https://github.com/OpenTalker/SadTalker.git ~/SadTalker", file=sys.stderr) + print(" cd ~/SadTalker && pip install -r requirements.txt", file=sys.stderr) + print(" bash scripts/download_models.sh", file=sys.stderr) + sys.exit(1) + + # Resolve paths + image_path = os.path.abspath(args.image) + audio_path = os.path.abspath(args.audio) + output_path = os.path.abspath(args.output) + tmp_dir = os.path.join(os.path.dirname(output_path), "_sadtalker_tmp") + os.makedirs(tmp_dir, exist_ok=True) + os.makedirs(os.path.dirname(output_path), exist_ok=True) + + # Validate inputs + if not os.path.isfile(image_path): + print(f"ERROR: Image not found: {image_path}", file=sys.stderr) + sys.exit(1) + if not os.path.isfile(audio_path): + print(f"ERROR: Audio not found: {audio_path}", file=sys.stderr) + sys.exit(1) + + # Auto-detect device + device = args.device + if not device: + import platform + if platform.system() == "Darwin": + device = "cpu" # MPS is unreliable for SadTalker + else: + device = "cuda" + + # Use SadTalker's own venv Python if available + venv_python = os.path.join(sd_dir, ".venv", "bin", "python") + python_exe = venv_python if os.path.isfile(venv_python) else sys.executable + + # Build command + cmd = [ + python_exe, "inference.py", + "--driven_audio", audio_path, + "--source_image", image_path, + "--result_dir", tmp_dir, + "--preprocess", "crop", + "--size", str(args.size), + "--expression_scale", str(args.expression_scale), + "--pose_style", str(args.pose_style), + ] + if device == "cpu": + cmd.append("--cpu") + if args.still: + cmd.append("--still") + if args.enhancer != "none": + cmd.extend(["--enhancer", args.enhancer]) + + print(f"Running SadTalker...") + print(f" Image: {image_path}") + print(f" Audio: {audio_path}") + print(f" Device: {device}") + print(f" Still: {args.still}") + print(f" Size: {args.size}") + + # Run SadTalker + result = subprocess.run(cmd, cwd=sd_dir, capture_output=True, text=True) + if result.returncode != 0: + print(f"SadTalker failed (exit {result.returncode}):", file=sys.stderr) + print(result.stderr, file=sys.stderr) + sys.exit(1) + + # Find output video (SadTalker auto-names it) + mp4_files = sorted(glob.glob(os.path.join(tmp_dir, "**", "*.mp4"), recursive=True), + key=os.path.getmtime, reverse=True) + if not mp4_files: + print("ERROR: SadTalker produced no output video", file=sys.stderr) + print("stdout:", result.stdout, file=sys.stderr) + sys.exit(1) + + # Move to desired output path + shutil.move(mp4_files[0], output_path) + + # Cleanup temp + shutil.rmtree(tmp_dir, ignore_errors=True) + + print(f"Done: {output_path}") + # Print JSON for pipeline consumption + import json + print(json.dumps({"success": True, "output": output_path})) + + +if __name__ == "__main__": + main() diff --git a/skills/content-assembly/SKILL.md b/skills/content-assembly/SKILL.md index 1a813af7..3c1fd7f3 100644 --- a/skills/content-assembly/SKILL.md +++ b/skills/content-assembly/SKILL.md @@ -171,8 +171,6 @@ ffmpeg -i clip-01.mp4 -vf "scale=1080:1920:force_original_aspect_ratio=decrease, - 编码:`-c:v libx264 -preset medium -crf 23` - 音频:`-c:a aac -ar 44100` -> **可选:视频增强处理** — 如果AI生成的素材存在帧率低、分辨率不足或画面微抖等问题,参考 `modules/video-enhancement.md` 进行增强处理。推荐处理链:RIFE帧插值 → Real-ESRGAN超分 → vid.stab稳定。 - #### 第1.5步:剪除静音/停顿片段(有人声的素材必做) 当视频中有人物说话时,**必须剪掉所有无声停顿片段**,只保留人物在说话的部分。这能大幅提升节奏感和完播率。 @@ -389,10 +387,19 @@ ASSEOF ffmpeg -i concat.mp4 -vf "ass=subs.ass" -c:v libx264 -crf 23 -c:a copy -y subtitled.mp4 ``` -> **进阶字幕样式** — 需要花字、动画字幕或自动语音转字幕等高级功能,参考 `modules/subtitle-aesthetics.md`。 - #### 第4步:添加背景音乐 +**BGM 获取优先级(必须按此顺序):** + +1. **首选:Lyria AI 生成** — 根据内容情绪和用户画像生成匹配的原创 BGM + ```bash + python3 skills/asset-generation/scripts/music_generate.py \ + --prompt "cinematic, powerful drums, motivational, 120 BPM, gym workout energy" \ + --output <作品目录>/assets/music/bgm.mp3 + ``` +2. **次选:用户共享素材中的音乐** — `curl http://localhost:3271/api/shared-assets` 检查 music 分类 +3. **最后:搜索下载** — 仅当以上两种都不可用时,用 yt-dlp 搜索 + **音乐获取规则:指定知名歌曲时** 当用户指定了一首具体歌曲名时,必须遵循以下规则: @@ -433,19 +440,63 @@ ffmpeg -i subtitled.mp4 -i music.mp3 \ - 淡入时长:1-2 秒 - 淡出时长:结尾 2-3 秒 -#### 第4.5步:调色(可选但推荐) +#### 第4.5步:口播视频对嘴型(口播类内容必做) + +如果内容方案是口播/对镜说话类型(分镜中标注了 `口播(一镜到底)`),在最终输出前必须执行对嘴型处理。 + +**流程:** + +1. **生成旁白音频**(如果还没有生成): +```bash +edge-tts --text "完整的口播脚本内容" --voice zh-CN-YunxiNeural --write-media narration.mp3 +``` -为成品视频添加统一的调色风格,大幅提升观感: +2. **上传素材获取 URL**(lip-sync API 需要可访问的 URL): +```bash +# 确认人物视频片段和旁白音频的 URL +# 如果是本地文件,通过 /api/works/{workId}/assets 获取可访问的 URL +``` +3. **调用即梦 lip-sync API 对嘴型**: ```bash -# 应用LUT调色 -ffmpeg -i final-no-grade.mp4 -vf "lut3d=cinematic.cube" -c:a copy -y final.mp4 +curl -X POST http://localhost:3271/api/generate/lip-sync \ + -H "Content-Type: application/json" \ + -d '{ + "workId": "work-xxx", + "videoUrl": "人物视频片段的URL", + "audioUrl": "旁白音频的URL", + "filename": "lipsync_talking.mp4" + }' +``` + +4. **用对嘴型后的视频替换原始人物片段**,然后继续叠加字幕和 BGM。 + +**对嘴型后的字幕叠加:** + +口播视频的字幕必须在底部逐句显示,跟随语音节奏同步。使用 ASS 字幕格式精确控制每句话的出现时间: + +```bash +# 生成逐句字幕文件(根据旁白脚本的断句和音频时长手动对齐) +cat > subs.ass << 'EOF' +[Script Info] +ScriptType: v4.00+ +PlayResX: 1080 +PlayResY: 1920 + +[V4+ Styles] +Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding +Style: Default,Arial,52,&H00FFFFFF,&H000000FF,&H00000000,&H80000000,1,0,0,0,100,100,0,0,1,3,1,2,40,40,120,1 + +[Events] +Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text +Dialogue: 0,0:00:00.00,0:00:03.50,Default,,0,0,0,,第一句话的内容 +Dialogue: 0,0:00:03.50,0:00:07.00,Default,,0,0,0,,第二句话的内容 +EOF -# 或使用基础参数调色(暖调示例) -ffmpeg -i final-no-grade.mp4 -vf "eq=brightness=0.03:contrast=1.1:saturation=1.15:gamma=1.02" -c:a copy -y final.mp4 +ffmpeg -i lipsync_talking.mp4 -vf "ass=subs.ass" -c:v libx264 -crf 23 -c:a copy -y subtitled.mp4 ``` -> 详细调色指南和内容类型专属参数,参考 `modules/color-grading.md`。 +> **⚠️ 注意:** 对嘴型是口播类视频的最后一个素材处理步骤。之后直接进入 BGM 混音和最终输出。非口播类视频跳过此步。 #### 第5步:最终输出 @@ -457,6 +508,40 @@ mkdir -p output/ cp final.mp4 output/final.mp4 ``` +#### 第6步:自动选择最佳封面帧(短视频必做) + +封面是用户第一眼看到的画面,直接决定点击率。**必须从成片中自动挑选最有爆点、最好看、最吸引人的一帧作为封面图。** + +**选帧策略(按优先级):** + +1. **情绪高潮帧** — 人物表情最强烈的瞬间(惊讶、大笑、感动等) +2. **视觉冲击帧** — 色彩最鲜艳、构图最饱满、光影最有张力的画面 +3. **悬念/好奇帧** — 能引发"这是什么?"好奇心的画面,让人想点进去看 +4. **避开** — 转场模糊帧、纯黑/纯白帧、文字遮挡过多的帧、水印残留帧 + +**执行方法:** + +```bash +# 1. 每隔0.5秒抽一帧,生成候选帧 +mkdir -p /tmp/cover_candidates +ffmpeg -i output/final.mp4 -vf "fps=2,scale=1080:1920:force_original_aspect_ratio=decrease" -q:v 2 -y /tmp/cover_candidates/frame_%04d.jpg + +# 2. 查看候选帧总数 +ls /tmp/cover_candidates/ | wc -l +``` + +然后从候选帧中选出**最具吸引力的一帧**(综合考虑情绪、色彩、构图),复制为封面: + +```bash +# 选定最佳帧(替换 XXXX 为选中的帧编号) +cp /tmp/cover_candidates/frame_XXXX.jpg output/cover.jpg + +# 清理临时文件 +rm -rf /tmp/cover_candidates +``` + +> **选帧判断依据:** 打开候选帧图片,快速浏览,想象自己在刷信息流——哪一帧会让你停下来?那就是最佳封面。优先选人脸表情清晰、画面饱满、色彩对比强的帧。 + --- ## 工作流程:图文排版 @@ -646,130 +731,8 @@ ffmpeg -i audio1.mp3 -i audio2.mp3 \ --- -## 垂类专项指南 - -执行前检查 `genres/` 目录。如果当前作品的内容类型(如搞笑、美食、教育等)有对应的 `genres/.md` 文件,**必须读取并遵循其中的专项规则**——特别是剪辑节奏、BGM 策略和音效使用方面,垂类文件的规则优先级高于本文件的通用规则。 - -## 扩展能力模块 - -本 skill 自带以下模块和脚本,**必须优先使用这些工具,不要自己写内联代码替代**: - -### 可用模块 - -| 模块 | 文档路径 | 用途 | -|------|---------|------| -| 热门音乐搜索 | `modules/music-search.md` | 从 YouTube/B站搜索下载 BGM,按情绪/BPM 匹配 | -| 卡点剪辑 | `modules/beat-sync.md` | 节拍检测 + 视频与音乐节拍对齐 | -| 调色指南 | `modules/color-grading.md` | LUT调色、内容类型调色参数、AI调色工具 | -| 字幕美学 | `modules/subtitle-aesthetics.md` | 字幕规范、花字样式、ASS高级样式、自动字幕流水线 | -| 专业字幕 | `modules/pro-captions.md` | 逐词高亮 karaoke 字幕、自动语音识别、5种预设样式 | -| 视频增强 | `modules/video-enhancement.md` | 帧插值(RIFE)、超分(Real-ESRGAN)、视频稳定(vid.stab) | - -### 可用脚本 - -| 脚本 | 路径 | 用途 | 用法示例 | -|------|------|------|---------| -| 节拍检测 | `scripts/beat-sync/detect_beats.py` | 分析音乐节拍、BPM、强拍 | `python3 skills/content-assembly/scripts/beat-sync/detect_beats.py bgm.mp3 -o beats.json` | -| 一键卡点 | `scripts/beat-sync/beat_sync_edit.py` | 自动按节拍切割视频+混入BGM | `python3 skills/content-assembly/scripts/beat-sync/beat_sync_edit.py --video source.mp4 --music bgm.mp3 --output final.mp4 --style dramatic` | -| 专业字幕 | `scripts/caption_generate.py` | 逐词高亮 karaoke ASS 字幕生成 | `python3 skills/content-assembly/scripts/caption_generate.py --input video.mp4 --output subs.ass --style douyin-highlight` | - -**⚠️ 重要:当需要节拍分析或卡点剪辑时,必须调用上述脚本,禁止自己写 librosa/numpy 内联代码。** - -### 使用流程 - -1. 需要 BGM → 先读 `modules/music-search.md`,用 yt-dlp 搜索下载 -2. 需要分析节拍 → 运行 `detect_beats.py` 获取 beats.json -3. 需要卡点剪辑 → 运行 `beat_sync_edit.py` 一键完成 -4. 需要手动精调 → 参考 `modules/beat-sync.md` 中的手动流程 - ---- - -## 专业字幕生成(caption_generate.py) - -逐词高亮(karaoke)字幕生成脚本,支持自动语音识别和外部时间戳两种模式,内置 5 种平台预设样式。 - -> **完整方法论** — 详见 `modules/pro-captions.md`,包含何时加字幕的判断、样式选择决策树、与 beat-sync 配合等。 - -### 参数列表 - -| 参数 | 类型 | 说明 | 默认值 | -|------|------|------|--------| -| `--input` | str | 视频/音频路径(auto 模式,与 `--timestamps` 互斥) | — | -| `--timestamps` | str | 时间戳 JSON 路径(手动模式,与 `--input` 互斥) | — | -| `--output` | str(必填) | 输出 ASS 文件路径 | — | -| `--style` | str | 预设样式:`douyin-highlight`/`douyin-bold`/`xhs-soft`/`funny`/`minimal` | `douyin-highlight` | -| `--language` | str | 语言代码(auto 模式) | `zh` | -| `--model` | str | Whisper 模型(auto 模式) | `medium` | -| `--font` | str | 覆盖预设字体 ID | 由样式决定 | -| `--font-size` | int | 覆盖预设字号 | 由样式决定 | -| `--highlight-color` | str | 高亮颜色 hex,如 `#FFFF00` | 由样式决定 | -| `--base-color` | str | 基础颜色 hex,如 `#FFFFFF` | 由样式决定 | -| `--stroke-width` | int | 描边宽度 | 由样式决定 | -| `--position` | str | `center`/`top`/`bottom` | 由样式决定 | -| `--max-words` | int | 每行最大词数 | `8` | -| `--lead-time` | int | 字幕提前出现毫秒数 | `80` | - -### 使用示例 - -```bash -# Auto 模式:自动识别视频中的语音 -python3 skills/content-assembly/scripts/caption_generate.py \ - --input video.mp4 --output subtitles.ass \ - --style douyin-highlight --language zh - -# Timestamps 模式:从 JSON 文件读取词级时间戳 -python3 skills/content-assembly/scripts/caption_generate.py \ - --timestamps captions.json --output subtitles.ass \ - --style xhs-soft - -# 自定义颜色 -python3 skills/content-assembly/scripts/caption_generate.py \ - --input video.mp4 --output subtitles.ass \ - --style douyin-highlight --highlight-color "#FF6699" --font-size 56 - -# 烧录到视频 -ffmpeg -i video.mp4 -vf "ass=subtitles.ass" -c:v libx264 -crf 18 -c:a copy output.mp4 -``` - -### 输出格式(stdout JSON) - -```json -{ - "success": true, - "output": "/abs/path/subtitles.ass", - "segments": 12, - "words": 87, - "duration_sec": 45.2, - "style": "douyin-highlight", - "mode": "auto", - "model": "medium" -} -``` - -### 依赖 - -- **auto 模式**:`pip install stable-ts`(含 Whisper + torch) -- **timestamps 模式**:无额外依赖 -- **烧录**:系统 ffmpeg -- **字体**:通过 `font_manager.py` 自动下载 - ---- - ## 错误处理 -### 音频丢失防护(极重要) - -**每一步 ffmpeg 处理后,都必须验证输出文件包含音频流:** -```bash -ffprobe -v error -show_entries stream=codec_type -of csv=p=0 output.mp4 | grep audio -``` -如果没有 `audio` 行,说明音频丢失了。常见原因: -- 使用 `-map 0:v` 时忘记同时 `-map 0:a`(或用 `-map 0` 映射所有流) -- 使用 `-vf` 视频滤镜时没有用 `-c:a copy` 保留音频 -- 多步骤处理时中间文件丢了音频(letterbox、字幕叠加等步骤特别容易出问题) - -**安全做法:** 在 pad/crop/overlay 等纯视频操作中始终加 `-c:a copy` 或 `-map 0:a`。 - ### 常见 ffmpeg 错误 **"No such file or directory":** @@ -843,13 +806,12 @@ curl -X PUT http://localhost:3271/api/works/{workId} \ |------|------|------|---------| | 节拍检测 | `scripts/beat-sync/detect_beats.py` | 分析音乐节拍、BPM、强拍 | `python3 skills/content-assembly/scripts/beat-sync/detect_beats.py bgm.mp3 -o beats.json` | | 一键卡点 | `scripts/beat-sync/beat_sync_edit.py` | 自动按节拍切割视频+混入BGM | `python3 skills/content-assembly/scripts/beat-sync/beat_sync_edit.py --video source.mp4 --music bgm.mp3 --output final.mp4 --style dramatic` | -| 专业字幕 | `scripts/caption_generate.py` | 逐词高亮 karaoke ASS 字幕生成 | `python3 skills/content-assembly/scripts/caption_generate.py --input video.mp4 --output subs.ass --style douyin-highlight` | **重要:当需要节拍分析或卡点剪辑时,必须调用上述脚本,禁止自己写 librosa/numpy 内联代码。** ### 使用流程 -1. 需要 BGM → 先读 `modules/music-search.md`,用 yt-dlp 搜索下载 +1. 需要 BGM → **优先使用 Lyria AI 生成**:运行 `python3 skills/asset-generation/scripts/music_generate.py --prompt "描述音乐风格" --output <输出路径>/bgm.mp3`(需要 OPENROUTER_API_KEY)。仅当 Lyria 不可用时,才读 `modules/music-search.md` 用 yt-dlp 搜索下载 2. 需要分析节拍 → 运行 `detect_beats.py` 获取 beats.json 3. 需要卡点剪辑 → 运行 `beat_sync_edit.py` 一键完成 4. 需要手动精调 → 参考 `modules/beat-sync.md` 中的手动流程 diff --git a/skills/content-assembly/genres/comedy.md b/skills/content-assembly/genres/comedy.md index 3105d485..17f1f401 100644 --- a/skills/content-assembly/genres/comedy.md +++ b/skills/content-assembly/genres/comedy.md @@ -11,14 +11,6 @@ - **点睛阶段**:定格或慢动作 + 配乐变化 - **跳切(Jump Cut)**:标配剪辑手法——去掉犹豫和废话,只留最精华的反应 -### 反转点的剪辑处理 - -反转瞬间的剪辑需要精确到帧: -1. 反转前:正常速度或稍慢(建立期待) -2. 反转瞬间:可以用 0.3-0.5 秒静音制造"暴风雨前的宁静" -3. 反转后:给观众 0.5-1 秒"反应时间"(表情特写或留白) -4. 点睛:如果有,快速切入 - --- ## 搞笑类 BGM 与音效 @@ -26,18 +18,16 @@ ### BGM 四种战术用法 **1. 情绪铺垫-反转** - ``` [铺垫] 史诗感BGM / 煽情钢琴曲 / 紧张悬疑音乐 [反转瞬间] 突然静音 0.5秒 → 切换搞笑音效/沙雕BGM ``` **2. 卡点强化** - ``` [动作1] 卡在第1拍 [动作2] 卡在第2拍 -[反转动作] 卡在重音拍 → 停顿 → 观众反应时间 +[反转动作] 卡在重音拍 → 停顿 ``` **3. 反差配乐** @@ -77,17 +67,9 @@ 抽象内容的剪辑和搞笑类有显著区别: - **长镜头往往更有力**:让荒诞场景持续存在,不剪掉,观众的困惑会累积 -- **不需要"反转剪辑点"**:抽象内容从头到尾都是错位的,不像搞笑类有明确的转折 +- **不需要"反转剪辑点"**:抽象内容从头到尾都是错位的 - **重复/循环**:同一个荒诞动作循环出现,强化"他是认真的"感觉 -### 与搞笑类剪辑的核心区别 - -| | 搞笑类 | 抽象类 | -|---|---|---| -| 节奏变化 | 大——铺垫慢、反转快 | 小——保持统一节奏 | -| 镜头切换 | 频繁——跳切是标配 | 少——长镜头更有力 | -| 静音使用 | 反转前制造悬念 | 最荒诞瞬间保持沉默 | - --- ## 抽象类 BGM 规则 @@ -98,37 +80,28 @@ BGM 在抽象内容中不是配角,而是构成抽象的核心元素之一。 **1. BGM 作为错位的一端** -BGM 和画面来自两个不同的世界,它们的碰撞本身就是全部笑点。 - | 画面 | BGM | 错位类型 | |------|-----|---------| | 赛博朋克/暗黑 | 儿歌/可爱 | 硬 vs 软 | | 欧美街头 | 凤凰传奇/广场舞 | 洋 vs 土 | | 高奢时装秀 | 唢呐/二人转 | 雅 vs 俗 | | 田园牧歌 | 死亡金属 | 静 vs 燥 | -| 战争/史诗 | 轻快流行 | 重 vs 轻 | -**关键**:BGM 也要"纯正"——选一首真的很可爱的儿歌,不要选"有点可爱"的音乐。 +**关键**:BGM 也要"纯正"。 **2. BGM 强化"过度认真"** -用和事件规模完全不匹配的史诗/庄严音乐,放大"用大片方式拍小事"的荒诞感。 - -示例:交响乐配"走路去冰箱拿水"的画面。 +用和事件规模完全不匹配的史诗/庄严音乐。 **3. BGM 强化"过度随意"** -用极度轻快/无所谓的音乐,消解事件的严重性。 - -示例:世界末日的画面配一首轻快的口哨曲。 +用极度轻快/无所谓的音乐消解事件的严重性。 ### 音效策略 抽象内容对音效的依赖低于搞笑类。特有用法: -**沉默**:在最荒诞的瞬间,不加任何音效。没有"噔",没有急刹车,什么都没有。因为抽象内容的态度是"这很正常"——如果加了搞笑音效,等于在告诉观众"这里该笑了",破坏了"一本正经"的氛围。 - -**字幕同理:抽象类内容在没有人说话时绝对不加字幕。** 反差本身就是全部信息,字幕等于在"解释笑点"。唯一例外是字幕本身构成错位的一端。 +**沉默**:在最荒诞的瞬间,不加任何音效。因为抽象内容的态度是"这很正常"——加搞笑音效等于在告诉观众"这里该笑了",破坏了"一本正经"的氛围。 --- @@ -150,51 +123,3 @@ BGM 和画面来自两个不同的世界,它们的碰撞本身就是全部笑 - 讽刺型:`"确认过眼神,是甲方看了会沉默的方案"` - 引导型:`"看到最后我直接笑出了猪叫"` - 代入型:`"我当时真的觉得自己很帅"` - ---- - -## ffmpeg 实操参考 - -### 搞笑类常用操作 - -#### 反转点静音插入 - -```bash -# 在第8秒处插入0.5秒静音(反转前的"暴风雨前宁静") -ffmpeg -i input.mp4 -af "volume=enable='between(t,7.8,8.3)':volume=0" -c:v copy -y output.mp4 -``` - -#### BGM 切换(铺垫→反转) - -```bash -# 前8秒用煽情音乐,8秒后切换搞笑BGM -ffmpeg -i video.mp4 -i serious.mp3 -i funny.mp3 \ - -filter_complex "[1:a]atrim=0:8,asetpts=PTS-STARTPTS,volume=0.3[a1];[2:a]atrim=0:20,asetpts=PTS-STARTPTS,adelay=8000|8000,volume=0.4[a2];[0:a][a1][a2]amix=inputs=3:duration=first[a]" \ - -map 0:v -map "[a]" -c:v copy -c:a aac -y output.mp4 -``` - -#### 定格效果(点睛) - -```bash -# 在第12秒处定格1秒 -ffmpeg -i input.mp4 -ss 12 -frames:v 1 -y freeze.png -ffmpeg -i input.mp4 -loop 1 -i freeze.png \ - -filter_complex "[0:v]trim=0:12,setpts=PTS-STARTPTS[before];[1:v]trim=0:1,setpts=PTS-STARTPTS,scale=1080:1920[freeze];[0:v]trim=13,setpts=PTS-STARTPTS[after];[before][freeze][after]concat=n=3:v=1:a=0[v]" \ - -map "[v]" -c:v libx264 -crf 23 -y output.mp4 -``` - -### 抽象类常用操作 - -#### 循环效果 - -```bash -# 将一个片段循环3次 -ffmpeg -stream_loop 2 -i clip.mp4 -c copy -y looped.mp4 -``` - -#### 速度统一(保持抽象的"一本正经"节奏) - -```bash -# 全片匀速,不做任何变速处理——抽象内容的节奏应该是统一的 -ffmpeg -i input.mp4 -r 30 -c:v libx264 -crf 23 -c:a aac -y output.mp4 -``` diff --git a/skills/content-planning/SKILL.md b/skills/content-planning/SKILL.md index 81d5b587..5316c460 100644 --- a/skills/content-planning/SKILL.md +++ b/skills/content-planning/SKILL.md @@ -64,8 +64,6 @@ curl http://localhost:3271/api/memory/profile ## 短视频构图原则 -> **进阶:** 除以下基础构图规则外,`modules/visual-aesthetics.md` 第三章提供了完整的13种构图模式(SAMP-Net分类)及其适用场景、Prompt关键词和竖屏/方形构图差异对照表。需要精细构图指导时请加载该模块。 - ### 视觉构图规则 **三分法(Rule of Thirds):** @@ -178,8 +176,6 @@ curl http://localhost:3271/api/memory/profile **一致性原则:** 每条内容选定一套色彩方案,并在所有镜头/图片中保持统一。这能创造视觉连贯性和品牌辨识度。 -> **进阶:** 如需更详细的配色模型(互补色/类似色/三角色等)、2026平台配色趋势、以及按内容类型选色的完整矩阵,请加载 `modules/visual-aesthetics.md` 第二章"色彩理论进阶"。 - --- ## 视觉一致性的提示词写作 @@ -297,7 +293,6 @@ Young Chinese woman with long black hair in white linen shirt, smiling while arr - **背景色:** [莫兰迪色系/深色系——参考色彩心理表选择与情绪匹配的颜色] - **装饰元素(可选):** [引号符号 " / 小图标 / 分隔线——保持极简] - **尺寸:** [小红书 3:4 / 抖音 9:16——参见平台参考文件获取精确规格] -- **封面设计原则:** 主标题占图片宽度40-50%,位于上1/3;主体占50-60%画面;留白10-15%。参考小红书6大爆款封面规律(质感为王、强反差对比、图文结合等)。详见 `modules/visual-aesthetics.md` 第一章。 > 注意:封面不是海报,不需要生成图片。封面文案要像朋友圈标题一样直白,让人一眼看懂主题。 @@ -319,20 +314,65 @@ Young Chinese woman with long black hair in white linen shirt, smiling while arr --- +## 用户画像分析(强制前置步骤) + +在开始任何内容策划之前,**必须**先分析用户上传的画像素材,建立对用户个人特质的理解。这是所有后续创意决策的基础。 + +```bash +# 获取用户画像素材 +curl http://localhost:3271/api/shared-assets +``` + +### 分析维度 + +从用户上传的照片、视频和品牌素材中,提取以下关键信息: + +| 维度 | 分析要点 | 对创作的影响 | +|------|---------|-------------| +| **性别与体型** | 男性/女性/中性、体型特征 | 决定角色描述、镜头风格、服装细节 | +| **个人风格** | 穿搭风格、审美偏好(阳刚/柔美/知性/街头/精致…) | 决定整体视觉调性、色彩方案、场景选择 | +| **想要传达的魅力** | 用户想塑造什么样的人设?(自律/优雅/力量/温暖/精英…) | 决定叙事角度、标题风格、情绪基调 | +| **生活场景** | 从素材推断的生活环境和兴趣 | 决定场景设计、道具选择 | + +### 画像对创作决策的影响 + +**标题风格:** 标题必须与用户想传达的个人魅力一致。 +- 阳刚/自律型男性 → 标题应有力量感、节制感,不用 "ordinary/normal" 等弱化词汇,而用暗示自律和实力的表达 +- 精致/优雅女性 → 标题应有品味感、细腻感 +- 知性/学术型 → 标题应有深度、含蓄感 + +**音乐选择:** 音乐必须强化用户的个人魅力特质。 +- 阳刚/健身型男性 → 男声流行、节奏有力的音乐、majestic/cinematic 风格,**绝对不用** lo-fi、轻柔、cute 风格 +- 精致女性 → 优雅轻快、法式/都市风 +- 酷/街头风 → 节奏感强的 hip-hop、trap beat +- 知性/文艺 → 独立音乐、轻古典 + +**视觉风格:** 画面色调、构图和场景必须凸显用户特质。 +- 阳刚型 → 冷色调、高对比、干净利落的构图、大地色/深色系 +- 柔美型 → 暖色调、柔光、莫兰迪色系 +- 精英型 → 极简、大量留白、质感细节特写 + +**⚠️ 常见错误(必须避免):** +- 给健身型男性用 lo-fi 音乐或 "just a normal day" 风格的标题 +- 给精致女性用粗犷的视觉风格 +- 忽略用户素材中明确传达的风格信号 +- 用通用模板而不根据用户画像调整 + ## 策划流程 1. **获取上下文:** 读取作品详情、调研报告(如已完成调研步骤)、共享素材和用户记忆档案。 -2. **阅读平台参考资料:** 根据目标平台,从 `references/` 目录中阅读对应的参考文件。 -3. **确认方向:** 简要总结计划的内容方向,请用户确认后再创建完整方案。 -4. **创建方案:** 按照上述格式输出完整方案,融入参考文件中的平台特定细节。 -5. **与用户对审:** 展示方案并征求反馈。反复修改直到用户确认。 -6. **保存方案:** 更新作品的流水线状态。 +2. **分析用户画像(强制):** 查看用户上传的素材,提取性别、风格、魅力特质,明确本次创作要凸显的个人特质。将分析结论写在方案顶部。 +3. **阅读平台参考资料:** 根据目标平台,从 `references/` 目录中阅读对应的参考文件。 +4. **确认方向:** 简要总结计划的内容方向(包括音乐风格、视觉调性如何匹配用户画像),请用户确认后再创建完整方案。 +5. **创建方案:** 按照上述格式输出完整方案,融入参考文件中的平台特定细节。确保标题、音乐、视觉风格都与用户画像一致。 +6. **与用户对审:** 展示方案并征求反馈。反复修改直到用户确认。 +7. **保存方案:** 更新作品的流水线状态。 ```bash # 获取作品信息 curl http://localhost:3271/api/works/{workId} -# 检查共享素材(角色参考、音乐等) +# 检查用户画像素材(强制 — 分析用户性别、风格、想要传达的魅力) curl http://localhost:3271/api/shared-assets # 检查用户风格偏好 @@ -344,27 +384,66 @@ curl -X PUT http://localhost:3271/api/works/{workId} \ -d '{"pipeline": {"plan": {"status": "done"}}}' ``` -## 垂类专项指南 +## 短视频风格选择(极重要) -执行前检查 `genres/` 目录。如果当前作品的内容类型(如搞笑、美食、教育等)有对应的 `genres/.md` 文件,**必须读取并遵循其中的专项规则**——它们覆盖本文件中的通用指导。特别是结构公式、Hook 设计和台词规则,垂类文件的规则优先级高于本文件的通用规则。 +### 默认风格:口播/对镜说话(Talking Head) -## 扩展能力模块 +**除非用户明确要求电影感/短片风格,所有口播建议类短视频(dating advice、生活建议、情感分析、观点输出等)一律使用口播对镜说话风格。** 不要 overdoing——不要做成短片、不要搞复杂的多场景叙事、不要用电影化的镜头语言。 -检查 `modules/` 目录,根据当前任务需要加载相关能力模块。 +**口播风格的核心原则:** + +1. **手机拍摄感** — 镜头要让人感觉是用手机拍的,不是专业摄像机。可以是自拍视角带有轻微晃动感,也可以是把手机放在面前固定拍摄。画面不要太完美、太稳定,那种"随手一拍"的真实感才对。 +2. **一镜到底** — 教练/讲师在同一个镜头下说完整个内容,不切换场景。固定一个生活化的场景(卧室、客厅、咖啡厅、车里),从头说到尾。绝对不要频繁切场景——这不是短片,这是一个人在跟你说话。 +3. **教练气场** — 表情、语气、肢体语言必须符合教练/老师的身份。要足够自信,有很强的主导性,像是在教你东西而不是在跟你聊天。不要唯唯诺诺,不要犹豫,每句话都要有力量感。 +4. **底部逐句字幕** — 视频底部跟随每句话显示字幕,让静音观看也能完整获取信息。字幕要跟语音节奏同步,一句一显示。 +5. **BGM 低调** — 轻音乐垫底即可,不要抢过说话的声音。 + +**口播视频的技术实现(两阶段分离):** + +口播视频的关键点:**素材生成阶段(asset-generation)不做对嘴型**,只生成人物的静态/动态视频片段。**对嘴型在最终组装阶段(content-assembly)统一处理。** + +工作流如下: +1. **策划阶段:** 写旁白脚本(完整的一段口播内容,不分段) +2. **素材生成阶段:** 用 Dreamina/即梦生成一段人物视频片段——正面中景或自拍视角,手机拍摄感,人物表情自然自信,场景固定不变。**此阶段不使用对嘴型。** +3. **组装阶段:** + - `edge-tts` 生成旁白音频 + - 调用即梦 lip-sync API(`/api/generate/lip-sync`):人物视频 + 旁白音频 → 对嘴型说话视频 + - ffmpeg 叠加逐句字幕(底部)+ BGM → 最终成品 + +**口播风格的分镜应该这样写:** + +口播视频只有一个镜头,不需要多镜头分镜。分镜表应极简: + +``` +| 镜号 | 类型 | 时长 | 旁白/字幕 | 说明 | +| 01 | 口播(一镜到底) | 全长 | [完整口播脚本] | 手机拍摄感,正面中景/自拍视角,教练自信地对镜说话。素材生成阶段只生成人物视频,组装阶段再用即梦 lip-sync 对嘴型 | +``` + +**分镜中标注 `口播(一镜到底)`** 表示这是一个完整的对镜说话镜头,在素材生成阶段只需要生成一段人物视频片段(不说话),对嘴型在 content-assembly 阶段处理。 + +> **⚠️ 重要:** 口播视频的 prompt 必须强调手机拍摄感。示例关键词:`handheld camera, slight camera shake, phone selfie angle, casual lighting, not professional studio`。避免出现专业打光、稳定器画面、多角度切换。 + +### 什么时候用电影感/短片风格 + +只有以下情况才使用多场景、复杂运镜的短片风格: +- 用户明确说"我想做一个短片""电影感""cinematic" +- 内容类型是纯视觉展示(如旅行 vlog、产品展示、氛围视频) +- 没有"一个人说话"的口播元素 -当前可用模块: -- **`modules/visual-aesthetics.md`** — 视觉美学进阶:封面设计系统、色彩理论进阶、13种构图模式、2026审美趋势。当需要精细的视觉方向指导时加载。 +**判断标准:如果内容的核心是"一个人在输出观点/建议/分析",就用口播风格。如果核心是"展示画面和氛围",才用短片风格。** --- ## 关键约束 -1. **场景描述必须足够精确,能直接作为图片生成的 API 提示词。** 包含具体的视觉细节——不要写"美丽的场景"这样含糊的描述。 -2. **分镜中的所有镜头必须共享相同的风格关键词**,以确保生成结果的视觉一致性。 -3. **角色描述必须在每个出现的镜头中完整重复**——不要使用生成模型无法解析的简写引用。 -4. **时长必须合理**——短内容每个镜头 3-5 秒,复杂场景最多 10 秒。 -5. **总时长应与所选内容类型的节奏模板匹配。** -6. **封面图是文字卡片,不是 AI 生成的图片**——用纯色背景 + 大号粗体文字,1-3 行短句直接传达主题。 +1. **用户画像优先:** 标题、音乐、视觉风格必须与用户上传素材所传达的性别特质和个人魅力一致。不能用通用模板套用——每个用户的内容都应该是为 TA 量身定制的。 +2. **场景描述必须足够精确,能直接作为图片生成的 API 提示词。** 包含具体的视觉细节——不要写"美丽的场景"这样含糊的描述。 +3. **分镜中的所有镜头必须共享相同的风格关键词**,以确保生成结果的视觉一致性。 +4. **角色描述必须在每个出现的镜头中完整重复**——不要使用生成模型无法解析的简写引用。 +5. **时长必须合理**——短内容每个镜头 3-5 秒,复杂场景最多 10 秒。 +6. **总时长应与所选内容类型的节奏模板匹配。** +7. **封面图是文字卡片,不是 AI 生成的图片**——用纯色背景 + 大号粗体文字,1-3 行短句直接传达主题。 +8. **口播建议类内容不要 overdoing** — 不做短片,不搞复杂多场景叙事,就是一个人对着镜头说话,日常、亲切、像朋友聊天。 ## 垂类专项指南 diff --git a/skills/content-planning/genres/comedy.md b/skills/content-planning/genres/comedy.md index 6dd0a145..c4e255e6 100644 --- a/skills/content-planning/genres/comedy.md +++ b/skills/content-planning/genres/comedy.md @@ -16,14 +16,6 @@ - **关键**:反转必须在逻辑上说得通,但在情感上出乎意料 - **时长**:15-30秒最佳 -示例: -``` -[0-3s] 男生西装革履走进高级餐厅(建立"高端约会"预期) -[3-8s] 优雅地翻开菜单,用法语点菜(强化预期) -[8-12s] 服务员端上来一碗泡面(反转) -[12-15s] 男生熟练地加火腿肠(点睛) -``` - ### 2. 递进荒诞型(适合系列化) ``` @@ -33,34 +25,19 @@ - **核心**:每一层都比上一层荒诞,但每一步都有内在逻辑 - **关键**:递进节奏要快,不能在任何一层停留太久 -示例: -``` -"不同价位的理发体验" -[0-5s] 20元:正常理发 -[5-10s] 200元:精致造型+按摩 -[10-18s] 2000元:在直升机上理发 -[18-25s] 2万元:理发师是你爸,边剪边骂你 -``` - ### 3. 错位/身份反差型 ``` 角色A进入不属于他的场景B → 用A的逻辑处理B的问题 ``` -- **核心**:两个世界的规则碰撞产生笑点 -- **关键**:角色必须"认真地"用错误的方式做事 - -示例:程序员去菜市场买菜,用代码逻辑砍价——"这个白菜有 bug,叶子发黄了,我要打个 hotfix 折扣" - ### 4. 重复-打破型 ``` 模式A重复3次 → 第4次打破模式 ``` -- **核心**:喜剧的"三次法则"(Rule of Three)——前两次建立模式,第三次打破 -- **关键**:前面的重复要快,不能让观众等太久 +- **核心**:喜剧的"三次法则"(Rule of Three) ### 5. 平行对比型 @@ -68,114 +45,45 @@ [情境A的反应] vs [情境B的反应](同一个人,反差巨大) ``` -- **核心**:暴露人的双标和真实反应 -- **关键**:对比越极端越好笑,要戳中"大家都这样但不好意思承认"的点 - -示例:"在朋友面前 vs 在喜欢的人面前"、"工作日的我 vs 周五下午的我" - ### 6. 紧张-崩塌型 ``` 严肃/紧张/恐怖的氛围持续升温 → 突然以最不严肃的方式崩掉 ``` -- **核心**:释放理论——积累的紧张感越强,释放时的笑声越大 -- **关键**:铺垫阶段必须让观众真的有点紧张/期待 - -示例:恐怖片氛围,黑暗走廊,脚步声越来越近——门打开,是舍友问"外卖到了你下来拿一下" - ### 7. 微观共鸣型(观察式喜剧) ``` -一个极其微小但普遍存在的私密体验 → 精确还原 → "你是不是在我家装了摄像头?" -``` - -- **核心**:笑点不来自反转,而来自**精确**——观众从未见人说出来过的体验被完美还原 -- **关键**:细节必须足够小、足够具体——"玩手机的时候手机砸脸上"而不是"玩手机很久" -- **表演**:必须精确还原那个瞬间的动作、表情、微妙的犹豫,差一点都不对 -- **时长**:10-30秒,不需要复杂结构,一个场景就够 - -示例: -``` -- 躺着玩手机 → 手机砸脸 → 假装没事继续玩 → 换个姿势 → 又砸了 -- 洗完澡打开门之前先把手伸出去试一下外面冷不冷 -- 上楼梯最后一步以为还有一级结果踩空那个"顿" -- 发消息打了一长段话,看了看,全删了,回复"哦好" -- 躺在床上翻了15分钟外卖APP,最后点了常吃的那家 -- 出门三秒回来检查门到底锁没锁 +一个极其微小但普遍存在的私密体验 → 精确还原 ``` -**与其他类型的区别**:其他搞笑结构需要"制造"笑点(写反转、设铺垫),微观共鸣型只需要"发现"笑点——在日常生活中找到那些人人都有但没人说的瞬间。 +- 不需要复杂结构,一个场景就够 +- 时长:10-30秒 --- ## 抽象类的七种结构模式 ### 1. 感官错配型 - -**公式:元素A的视觉 + 元素B的听觉(或任意两个感官通道的错配)** - -**关键**:两端都要"纯正"。画面要真的很朋克,音乐要真的很甜。 +元素A的视觉 + 元素B的听觉。两端都要"纯正"。 ### 2. 过度认真型 +微不足道的小事 + 史诗级的处理方式。演员必须一本正经。 -**公式:微不足道的小事 + 史诗级的处理方式** - -- 用拍电影的方式拍"倒杯水" -- 用商战思维分析"室友谁洗碗" -- 用学术答辩的方式讲"今天中午吃什么" - -**关键**:演员必须一本正经,一点都不能笑。越认真越荒诞。 - -### 3. 过度随意型(过度认真的镜像) - -**公式:重大的事 + 极度轻描淡写的态度** - -- 世界末日了,主角:"哦,那先把外卖吃了" -- 公司倒闭了,老板在群里发一个👍 - -**关键**:反应的"不匹配程度"= 笑点的强度。 +### 3. 过度随意型 +重大的事 + 极度轻描淡写的态度。 ### 4. 语境位移型 - -**公式:角色A + 不属于A的场景B → 用A的规则生活在B里** - -- 甲方在菜市场验收白菜:"这个需求不对,颜色不是我要的绿" -- 妈妈用开家长会的语气给猫做年度考核 - -**关键**:角色不能"意识到"自己不属于这里。 +角色A + 不属于A的场景B → 用A的规则生活在B里。角色不能"意识到"自己不属于这里。 ### 5. 形式解构型 - -**公式:观众熟悉的视频套路 + 完全歪掉的执行** - -| 被解构的形式 | 抽象化处理 | -|------------|-----------| -| 变装视频 | 越变越丑/越变越离谱 | -| 美食教程 | 做出暗黑料理,但流程极其专业 | -| 健身教学 | 教的动作完全没用但讲解很认真 | -| Vlog | "今天带大家看看我家" → 毛坯房 | -| 开箱视频 | 开的是空盒子,但反应极度兴奋 | - -**关键**:必须先让观众以为是"正经版"。 +观众熟悉的视频套路 + 完全歪掉的执行。必须先让观众以为是"正经版"。 ### 6. 真实解构型 - -**公式:日常场景 + 字幕/旁白揭示"真实含义"** - -- 同事说"改天一起吃饭" → 字幕翻译:"此生不会再见" -- 用动物纪录片旁白解说地铁早高峰 - -**关键**:荒诞感来自"原来现实本身就很荒诞"。 +日常场景 + 字幕/旁白揭示"真实含义"。 ### 7. 逻辑自洽的平行宇宙型 - -**公式:建立一套错误但完整的世界观 → 在这套规则里严格运行** - -- "如果上班需要通关"——打卡机变成boss,工位有怪物 -- "如果社交有血条"——说错话扣血,尬聊直接死亡 - -**关键**:世界观要自洽——规则是错的,但在错的规则下一切都要合理。 +建立一套错误但完整的世界观 → 在这套规则里严格运行。 --- @@ -189,24 +97,18 @@ | **反常识开场白** | 台词打破认知 | "今天教大家如何在30秒内被开除" | | **结果前置** | 先看到离谱的结果 | 先看到满脸奶油的人,再倒叙 | | **紧张感制造** | 假装严肃/危机 | 用新闻联播的语气播报"舍友又偷吃我零食" | -| **身份错位** | 角色出现在不该在的地方 | 穿盔甲的人在地铁上看手机 | -| **场景标题(共鸣型专用)** | 文字描述场景,观众带着"我是不是也这样"的预期进入 | "当你躺在床上玩手机的时候" | +| **场景标题(共鸣型专用)** | 文字描述场景 | "当你躺在床上玩手机的时候" | -**禁忌:** -- 不要用"大家好今天给大家分享"——搞笑内容没有"前言" -- 不要前3秒还在铺环境——直接进入冲突或荒诞(共鸣型除外) -- 不要用文字 Hook 代替画面 Hook——搞笑靠的是视觉和声音的即时冲击(共鸣型除外) +**禁忌:** 不要用"大家好今天给大家分享";不要前3秒还在铺环境。 ### 抽象类 Hook -不需要铺垫,**第一秒就应该让观众觉得"什么情况?"** - -| Hook 方式 | 原理 | 示例 | -|-----------|------|------| -| **画面即错位** | 第一帧就不对劲 | 穿全套盔甲的人在地铁刷手机 | -| **声音即错位** | 第一个音符就不属于这个画面 | 田园画面 + 死亡金属前奏 | -| **行为即错位** | 角色第一个动作就不合理 | 对着一杯水做战前动员 | -| **文字即错位** | 字幕和画面形成反差 | 画面:普通走路。字幕:"特种兵渗透中" | +| Hook 方式 | 原理 | +|-----------|------| +| **画面即错位** | 第一帧就不对劲 | +| **声音即错位** | 第一个音符就不属于这个画面 | +| **行为即错位** | 角色第一个动作就不合理 | +| **文字即错位** | 字幕和画面形成反差 | **区别**:搞笑类 Hook 制造好奇心,抽象类 Hook 直接制造困惑。 @@ -216,44 +118,27 @@ ### 搞笑类 -1. **短句为王**:每句台词不超过10个字,节奏感比信息量重要 -2. **口语化**:写"人话"——"我靠这也行?" 比 "这真是出乎意料" 好笑100倍 -3. **留白**:最好笑的瞬间往往不需要台词——一个表情、一个停顿就够了 -4. **重复的力量**:同一句话在不同语境下重复说,含义变化本身就是笑点 -5. **潜台词 > 明台词**:角色不直说,观众自己领悟到的笑点更持久 - -**文案节奏公式:** - -``` -短句 + 短句 + 短句 + ——长句(反转/点睛) -``` +1. **短句为王**:每句不超过10个字 +2. **口语化**:"我靠这也行?" 比 "这真是出乎意料" 好笑100倍 +3. **留白**:最好笑的瞬间往往不需要台词 +4. **文案节奏公式**:短句 + 短句 + 短句 + ——长句(反转/点睛) -示例:`"起床。洗脸。出门。——发现今天周六。"` +**字幕铁律:没有人说话的时候,不加任何字幕。** -**旁白/字幕策略:** - -| 用法 | 效果 | 示例 | -|------|------|------| -| **吐槽式旁白** | "上帝视角"笑点 | 画面:男生自信搭讪。字幕:他不知道裤子拉链开了 | -| **反差字幕** | 画面和文字说的是两回事 | 画面:哭泣。字幕:演技满分 | -| **翻译字幕** | 把客套话"翻译"成真实含义 | "没事我不饿" → "快再问我一次" | -| **倒计时/标注** | 预告即将发生的事 | "距离社死还有3秒" | - -**字幕铁律:没有人说话的时候,不加任何字幕。** 字幕只在以下情况出现: -- 人物在说话 → 字幕同步台词 -- 字幕本身就是笑点(如吐槽式旁白、翻译字幕)→ 字幕承担叙事功能 - -绝不要用字幕"解释画面正在发生什么"——如果画面需要字幕解释才能看懂,说明画面本身不够好。 +| 字幕用法 | 效果 | +|---------|------| +| 吐槽式旁白 | "上帝视角"笑点 | +| 反差字幕 | 画面和文字说的是两回事 | +| 翻译字幕 | 把客套话"翻译"成真实含义 | +| 倒计时/标注 | 预告即将发生的事 | ### 抽象类 | 台词模式 | 说明 | |---------|------| -| 正经话说荒诞事 | 用完全正式/专业的语言描述荒诞的内容。语气越正式,内容越荒诞,反差越强 | -| 无台词——纯氛围 | 很多最强的抽象内容完全没有台词。错位通过画面、动作和音乐传达 | -| 角色内部自洽 | 角色说的话在他自己的世界里完全合理,但在观众看来很荒诞 | - -示例:古装将军在星巴克——"本将军要一杯...那个...焦糖玛奇朵。大杯。赏。" +| 正经话说荒诞事 | 用完全正式/专业的语言描述荒诞的内容 | +| 无台词——纯氛围 | 错位通过画面、动作和音乐传达 | +| 角色内部自洽 | 角色说的话在他自己的世界里完全合理 | **抽象类无人说话时绝对不加字幕**——反差本身就是全部信息,字幕=解释笑点=破功。 @@ -262,56 +147,43 @@ ## 自检清单 ### 通用检查 -- [ ] 前3秒能抓住注意力?(搞笑:好奇心;抽象:困惑感) -- [ ] 能让目标观众笑出来(不只是"有意思")? +- [ ] 前3秒能抓住注意力? +- [ ] 能让目标观众笑出来? - [ ] 有"想转给朋友"的冲动? -- [ ] 没有引起不适的内容(歧视、低俗、伤害性内容)? +- [ ] 没有引起不适的内容? - [ ] 总时长合理?(搞笑 15-45秒,抽象 10-30秒) -### 搞笑类额外检查 +### 搞笑类额外 - [ ] 有明确的预期建立阶段? - [ ] 有至少一个有力的反转? -- [ ] 反转在逻辑上说得通(不是硬编的)? - [ ] 铺垫不超过总时长的 60%? -- [ ] 反转后有"反应时间"(0.5-1秒留白或表情)? - [ ] 反转点有声音标记? -### 抽象类额外检查 -- [ ] 错位足够极端?(两个属性是否真的来自不同世界) -- [ ] 每个属性的纯度够高?(各自单独看是否"正宗") -- [ ] 创作者态度始终认真?(全程没有破功) +### 抽象类额外 +- [ ] 错位足够极端? +- [ ] 每个属性的纯度够高? +- [ ] 创作者态度始终认真? - [ ] 无人说话的片段没有加字幕? --- ## 避坑指南 -### 搞笑类常见错误 - -| 错误 | 为什么不行 | 正确做法 | -|------|-----------|---------| -| 铺垫太长 | 观众在反转前就划走了 | 铺垫控制在总时长 40-60% | -| 反转太弱 | "就这?"——惊讶感不够 | 反转要超出预期 | -| 笑点需要解释 | 需要解释的笑点不是好笑点 | 如果要加字幕解释才好笑,重写 | -| 全程高能 | 没有节奏感,观众疲劳 | 要有呼吸感——紧→松→更紧→爆发 | -| 表演过度 | 刻意搞笑比不搞笑更尴尬 | "一本正经地做荒诞的事"才是最高级的搞笑 | - -### 抽象类常见错误 - -| 错误 | 为什么不行 | 正确做法 | -|------|-----------|---------| -| 错位不够极端 | 只是"有点奇怪" | 两端都要拉到极致 | -| 纯度不够 | 不是反差而是粗糙 | 每个元素单独看都要是正宗的 | -| 自己先笑了 | 彻底破功 | 全程保持认真 | -| 加了搞笑音效 | 等于在说"这里该笑了" | 用沉默或与错位方向一致的音效 | -| 试图解释/吐槽 | 抽象的力量在于"不解释" | 让组合本身说话 | - -### 通用底线 +### 搞笑类 +| 错误 | 正确做法 | +|------|---------| +| 铺垫太长 | 铺垫控制在总时长 40-60% | +| 反转太弱 | 反转要超出预期 | +| 笑点需要解释 | 如果要加字幕解释才好笑,重写 | +| 表演过度 | "一本正经地做荒诞的事"才是最高级的搞笑 | +### 抽象类 | 错误 | 正确做法 | |------|---------| -| 伤害性内容 | 搞笑/抽象的对象应该是情境、逻辑、制度,不是具体的人 | -| 硬蹭热梗 | 用当下活跃的梗,或创造新梗 | +| 错位不够极端 | 两端都要拉到极致 | +| 纯度不够 | 每个元素单独看都要是正宗的 | +| 自己先笑了 | 全程保持认真 | +| 加了搞笑音效 | 用沉默或与错位方向一致的音效 | --- diff --git a/skills/content-planning/modules/emotional-hooks.md b/skills/content-planning/modules/emotional-hooks.md index d7c60dd4..08b8a3a5 100644 --- a/skills/content-planning/modules/emotional-hooks.md +++ b/skills/content-planning/modules/emotional-hooks.md @@ -112,6 +112,13 @@ - 羡慕类内容的收藏率极高(观众收藏 = "我以后也要这样") - 最佳策略:展示结果 + 暗示路径(让观众觉得自己也能做到) +**音乐必须匹配用户画像的个人魅力(短视频强制):** +- 阳刚/健身型男性 → 男声流行、cinematic/majestic 旋律、有力量感的节奏(绝不用 lo-fi、cute、轻柔风格) +- 精致/优雅女性 → 法式轻快、都市风、优雅轻盈 +- 酷/街头风 → hip-hop beat、trap、有态度的节奏 +- 文艺/知性 → 独立民谣、轻古典、有质感的器乐 +- **音乐选错会直接破坏用户想传达的魅力形象,这是致命错误。** + --- ## 羡慕类图文——策划阶段指令(强制三选一) @@ -155,7 +162,10 @@ - **时间**:工作日白天在做休闲的事(暗示时间自由) - **行为**:不赶时间、不挤、不凑合(暗示选择自由) 2. **规划 1-5 张图的内容**——每张图一个日常场景,整组图**风格、清晰度、画风完全一致**。描述每张图的搜图关键词,关键词中嵌入阶层信号的具体细节。 -3. **标题**用最平淡的语气描述一个令人羡慕的状态("周三下午的日常"而不是"有钱人的生活")。 +3. **标题**用最平淡的语气描述一个令人羡慕的状态,但**标题的风格必须与用户画像的个人魅力一致**。同样是"日常记录",不同人设差别巨大: + - 阳刚自律型男性 → "5:30 的闹钟。每一天。" / "Day 47." (有力量感、节制感) + - 精致女性 → "周三下午,一个人的花艺课" (有品味感、松弛感) + - **绝对不要**给有明确性别特质的用户使用中性/通用标题(如 "just a normal wednesday"、"ordinary day") 4. **文案**轻描淡写,语气是"这很正常"——越平静越让人破防。 5. **标签** 5-6 个实时热门话题。 @@ -186,3 +196,4 @@ 4. **观众的行为预期是什么?** (转发讨伐?收藏备用?评论站队?笑着转给朋友?) 如果一条内容方案无法明确回答以上4个问题,说明情绪设计不够清晰,需要重新构思。 +我 \ No newline at end of file diff --git a/skills/trend-research/SKILL.md b/skills/trend-research/SKILL.md index 7c952dbd..e53eb70a 100644 --- a/skills/trend-research/SKILL.md +++ b/skills/trend-research/SKILL.md @@ -237,17 +237,6 @@ curl http://localhost:3271/api/trends/xiaohongshu 6. **考虑用户背景。** 查看共享资产和记忆模块,了解用户过往的内容风格、优势和偏好:`curl http://localhost:3271/api/memory/profile` 和 `curl http://localhost:3271/api/shared-assets`。 7. **保持时效性。** 搜索查询中始终包含当前年月,确保获取最新数据。 -## 垂类专项指南 - -执行前检查 `genres/` 目录。如果当前作品的内容类型(如搞笑、美食、教育等)有对应的 `genres/.md` 文件,**必须读取并遵循其中的专项规则**——它们覆盖本文件中的通用指导。 - -## 扩展能力模块 - -检查 `modules/` 目录,根据当前任务需要加载相关能力模块。例如: -- 需要采集达人数据时 → 读取 `modules/creator-analytics.md` - ---- - ## 服务端集成 为特定作品做研究时,获取上下文信息: diff --git a/skills/trend-research/genres/comedy.md b/skills/trend-research/genres/comedy.md index eac53dcf..ad341f83 100644 --- a/skills/trend-research/genres/comedy.md +++ b/skills/trend-research/genres/comedy.md @@ -4,19 +4,6 @@ --- -## 两种品类的核心区别 - -在调研阶段就必须明确品类,因为它们的爆款逻辑完全不同: - -| | 搞笑类(Comedy) | 抽象类(Abstract) | -|---|---|---| -| **底层原理** | 预期→反转→释放 | 错位 × 纯度 × 认真 | -| **笑点来源** | 故事走向出乎意料 | 两个不该在一起的东西在一起了 | -| **对脚本的依赖** | 高——需要精确的铺垫和反转设计 | 低——核心是元素选择和氛围营造 | -| **典型时长** | 15-45秒 | 10-30秒 | - ---- - ## 爆款模式识别 搞笑/抽象内容的爆款由以下情绪驱动: @@ -27,14 +14,6 @@ | **快乐** | 释放感、荒诞感、优越感 | 转发("你看这个太搞笑了") | | **共鸣** | 私密体验被精确说中 | 转发("这不就是我吗")+ 评论 | -### 学术支撑 - -分析爆款内容时可用以下理论框架: -- **不协调理论**:预期与现实的错位产生笑(反转型的核心) -- **优越感理论**:看到角色犯蠢/社死 → "还好不是我" → 笑 -- **释放理论**:紧张/尴尬积累 → 突然释放 → 笑 -- **良性冲突理论(安全阀)**:冲突必须是无害的,过了头就不好笑而是令人不适 - ### 搞笑类爆款公式 - **反转型:** 强预期建立 + 狠反转打破 + 情绪释放。预期建立得越强、越合理,反转的冲击力越大。 @@ -50,7 +29,9 @@ - **纯度**:每个属性单独看都必须是"正宗的"——半吊子就没有反差 - **认真**:创作者表现得完全不觉得有问题——一旦自己先笑了,立刻破功 -**核心区别**:搞笑类靠"故事走向"出乎意料,抽象类靠"元素组合"不该存在。搞笑类需要时间铺垫,抽象类第一秒就能命中。 +### 核心区别 + +搞笑类靠"故事走向"出乎意料,抽象类靠"元素组合"不该存在。搞笑类需要时间铺垫,抽象类第一秒就能命中。 --- @@ -64,20 +45,7 @@ 4. **梗的时效性**:当前流行的梗、音乐是否可以融入?过气梗要回避。 5. **安全边界**:搞笑/抽象的对象应该是情境、逻辑、制度,不是具体的人。 -在通用评估框架基础上,搞笑/抽象内容需额外评估: - -| 评估维度 | 搞笑类关注点 | 抽象类关注点 | -|---------|------------|------------| -| **可执行性** | 反转是否能用当前制作能力实现 | 错位的两端是否都能做到"纯正" | -| **新鲜度** | 反转套路是否已被用烂 | 错位组合是否已有人做过 | -| **传播性** | 是否有"想转给朋友"的冲动 | 是否有"什么情况"的困惑力 | -| **安全性** | 是否冒犯了特定群体 | 是否会被误解为恶意内容 | - ---- - -## 错位维度参考(抽象类选题灵感) - -调研时用此表寻找热门的错位组合模式: +### 错位维度参考(抽象类选题灵感) | 维度A | 维度B | 示例 | |-------|-------|------| @@ -86,17 +54,9 @@ | 身份(古装将军) | 场景(星巴克) | 将军用文言文点拿铁 | | 技术(电影级运镜) | 题材(食堂打饭) | 用《碟中谍》的方式拍打饭 | | 语气(新闻联播播报腔) | 内容(室友偷零食) | 用央视语气播报宿舍琐事 | -| 情绪(极度开心) | 情境(被裁员) | 收到裁员通知后开香槟庆祝 | -| 成本(百万级制作) | 目的(剥鸡蛋) | 动用无人机航拍教你剥蛋 | | 专业度(学术答辩) | 对象(中午吃啥) | 用论文格式论证该吃黄焖鸡 | -| 年代(古代) | 年代(现代) | 诸葛亮用PPT做隆中对 | -| 物种(猫) | 行为(上班考核) | 给猫做年度绩效考核 | -| 规模(军事级部署) | 事件(抢会议室) | 用沙盘推演规划抢会议室 | -| 画风(赛博朋克) | 音乐(凤凰传奇) | 赛博画面配广场舞曲 | -| 服装(高定礼服) | 场景(菜市场) | 穿晚礼服去买菜 | -| 速度(慢动作) | 内容(普通走路) | 用子弹时间拍过马路 | - -**这个表可以无限延伸。核心动作是:选定一个日常事物 → 找到它身上的两个属性 → 把它们拉向两个不同世界的极端。** 调研时关注平台上当前热门的错位组合方式。 + +**这个表可以无限延伸。核心动作是:选定一个日常事物 → 找到它身上的两个属性 → 把它们拉向两个不同世界的极端。** --- diff --git a/skills/trend-research/modules/creator-analytics.md b/skills/trend-research/modules/creator-analytics.md index 737ffcd4..561f96e4 100644 --- a/skills/trend-research/modules/creator-analytics.md +++ b/skills/trend-research/modules/creator-analytics.md @@ -77,7 +77,6 @@ The script outputs JSON to **stdout**. Errors go to **stderr** with exit code 1. "account": { "sec_user_id": "MS4wLjAB...", "nickname": "创作者昵称", - "signature": "个人简介", "follower_count": 125000, "following_count": 320, "total_favorited": 5600000, @@ -134,5 +133,3 @@ scripts/creator-analytics/ ├── __init__.py # BaseCollector abstract class └── douyin.py # Douyin collector (f2 + browser_cookie3) ``` - -To add a new platform: create `platforms/.py` implementing `BaseCollector`, and register it in `collect.py`. diff --git a/skills/trend-research/scripts/douyin_hot_search.py b/skills/trend-research/scripts/douyin_hot_search.py old mode 100755 new mode 100644 diff --git a/skills/trend-research/scripts/newsnow_trends.py b/skills/trend-research/scripts/newsnow_trends.py old mode 100755 new mode 100644 diff --git a/skills/voice/CHANGELOG.md b/skills/voice/CHANGELOG.md new file mode 100644 index 00000000..b4e66d88 --- /dev/null +++ b/skills/voice/CHANGELOG.md @@ -0,0 +1,21 @@ +# Voice Skill - Changelog + +## v1.1.0 - 2026-02-03 +### 新增功能 +- 新增 `speak` 动作:直接播放语音,无需先生成文件 +- 新增 `play` 动作:播放现有音频文件 +- 新增 `voices` 动作:列出可用语音 + +### 改进 +- 优化语音播放体验,支持直接播放 +- 改进临时文件管理,更及时清理 +- 默认使用中文语音 `zh-CN-XiaoxiaoNeural` +- 减少临时文件保留时间至1小时(默认) + +### 修复 +- 解决了语音生成与播放不连贯的问题 +- 优化错误处理和用户反馈 + +### 文档更新 +- 更新 SKILL.md 说明文档 +- 添加新功能使用示例 \ No newline at end of file diff --git a/skills/voice/README.md b/skills/voice/README.md new file mode 100644 index 00000000..8051a6e2 --- /dev/null +++ b/skills/voice/README.md @@ -0,0 +1,62 @@ +# Enhanced Voice Skill for OpenClaw + +An improved text-to-speech skill that provides seamless voice generation and playback using Microsoft Edge's TTS engine. + +## Features + +- **Direct Speech Playback**: Speak text immediately without storing to file +- **Multiple Voice Support**: Supports various languages and voice types +- **Easy Integration**: Simple API for text-to-speech conversion +- **Automatic Cleanup**: Temporary files are automatically managed +- **Cross-platform**: Works on macOS, Windows, and Linux + +## Installation + +```bash +pip3 install edge-tts +``` + +## Usage + +### Direct Speech (Recommended) +```javascript +await skill.execute({ + action: 'speak', + text: 'Hello, this is played directly!' +}); +``` + +### Generate and Store Audio +```javascript +await skill.execute({ + action: 'tts', + text: 'Hello, this will be saved to a file.', + playImmediately: true // Optionally play immediately +}); +``` + +### Available Voices +```javascript +const voices = await skill.execute({ action: 'voices' }); +``` + +## Actions + +- `speak`: Convert text to speech and play directly +- `tts`: Convert text to speech and save to file +- `play`: Play an existing audio file +- `voices`: List available voices +- `cleanup`: Remove old temporary files +- `install`: Install dependencies + +## Configuration + +- `voice`: Voice type (default: 'zh-CN-XiaoxiaoNeural') +- `rate`: Speech rate (default: '+0%') +- `volume`: Volume level (default: '+0%') +- `pitch`: Pitch adjustment (default: '+0Hz') + +## Requirements + +- Python 3.x with edge-tts package +- System audio player (afplay on macOS, powershell on Windows, aplay on Linux) \ No newline at end of file diff --git a/skills/voice/SKILL.md b/skills/voice/SKILL.md new file mode 100644 index 00000000..73106ca2 --- /dev/null +++ b/skills/voice/SKILL.md @@ -0,0 +1,149 @@ +# Voice Skill + +The Voice skill provides enhanced text-to-speech functionality using edge-tts, allowing you to convert text to spoken audio with multiple playback options. + +## Features + +- Text-to-speech conversion using Microsoft Edge's TTS engine +- Support for various voice options and audio settings +- Direct playback of generated audio +- Automatic cleanup of temporary audio files +- Integration with the MEDIA system for audio playback + +## Installation + +Before using this skill, you need to install the required dependency: + +```bash +pip3 install edge-tts +``` + +Or use the skill's install action: + +```javascript +await skill.execute({ action: 'install' }); +``` + +## Usage + +### Direct Speaking (Recommended) + +Speak text directly without storing to file: + +```javascript +const result = await skill.execute({ + action: 'speak', // New improved action + text: 'Hello, how are you today?' +}); +// Audio is played directly and temporary file is cleaned up automatically +``` + +### Text-to-Speech with File Generation + +Convert text to speech with default settings: + +```javascript +const result = await skill.execute({ + action: 'tts', + text: 'Hello, how are you today?' +}); +// Returns a MEDIA link to the audio file +``` + +With direct playback: + +```javascript +const result = await skill.execute({ + action: 'tts', + text: 'Hello, how are you today?', + playImmediately: true // Plays the audio immediately after generation +}); +``` + +With custom options: + +```javascript +const result = await skill.execute({ + action: 'tts', + text: 'This is a sample of voice customization.', + options: { + voice: 'zh-CN-XiaoxiaoNeural', + rate: '+10%', + volume: '-5%', + pitch: '+10Hz' + } +}); +``` + +### Play Existing Audio File + +Play an existing audio file: + +```javascript +const result = await skill.execute({ + action: 'play', + filePath: '/path/to/audio/file.mp3' +}); +``` + +### List Available Voices + +Get a list of available voices: + +```javascript +const result = await skill.execute({ + action: 'voices' +}); +``` + +### Cleanup Temporary Files + +Clean up temporary audio files older than 1 hour (default): + +```javascript +const result = await skill.execute({ + action: 'cleanup' +}); +``` + +Or specify a custom age threshold: + +```javascript +const result = await skill.execute({ + action: 'cleanup', + options: { + hoursOld: 2 // Clean files older than 2 hours + } +}); +``` + +## Options + +The following options are available for text-to-speech: + +- `voice`: The voice to use (default: 'zh-CN-XiaoxiaoNeural') +- `rate`: Speech rate adjustment (default: '+0%') +- `volume`: Volume adjustment (default: '+0%') +- `pitch`: Pitch adjustment (default: '+0Hz') + +## Supported Voices + +Edge-TTS supports many voices in different languages: +- Chinese: zh-CN-XiaoxiaoNeural, zh-CN-YunxiNeural, zh-CN-YunyangNeural +- English (US): en-US-Standard-C, en-US-Standard-D, en-US-Wavenet-F +- English (UK): en-GB-Standard-A, en-GB-Wavenet-A +- Japanese: ja-JP-NanamiNeural +- Korean: ko-KR-SunHiNeural +- And many more... + +## File Management + +- Audio files are temporarily stored in the `temp` directory +- Files are automatically cleaned up after 1 hour (default) +- Direct speaking option cleans up files after 5 seconds + +## Requirements + +- Python 3.x +- pip package manager +- edge-tts library (install via `pip3 install edge-tts`) \ No newline at end of file diff --git a/skills/voice/_meta.json b/skills/voice/_meta.json new file mode 100644 index 00000000..bded169d --- /dev/null +++ b/skills/voice/_meta.json @@ -0,0 +1,6 @@ +{ + "ownerId": "kn7b7px97033b9mhvnbxhregfn804p4e", + "slug": "voice", + "version": "1.0.1", + "publishedAt": 1770133962546 +} \ No newline at end of file diff --git a/skills/voice/example.js b/skills/voice/example.js new file mode 100644 index 00000000..22e06f71 --- /dev/null +++ b/skills/voice/example.js @@ -0,0 +1,50 @@ +// Example usage of the Voice skill + +async function example() { + // Import the Voice skill + const VoiceSkill = require('./index.js'); + const skill = new VoiceSkill(); + + try { + // Basic text-to-speech + console.log('Creating basic speech...'); + let result = await skill.execute({ + action: 'tts', + text: 'Hello, this is a demonstration of the voice skill.' + }); + console.log(result.message); + console.log('Media link:', result.media); + + // Advanced text-to-speech with options + console.log('\nCreating speech with custom options...'); + result = await skill.execute({ + action: 'tts', + text: 'This speech has custom speed and volume settings.', + options: { + voice: 'en-US-Wavenet-F', + rate: '+20%', + volume: '+10%' + } + }); + console.log(result.message); + console.log('Media link:', result.media); + + // Clean up temporary files + console.log('\nCleaning up temporary files...'); + result = await skill.execute({ + action: 'cleanup', + options: { + hoursOld: 1 // Only clean files older than 1 hour + } + }); + console.log(result.message); + + } catch (error) { + console.error('Error during execution:', error.message); + } +} + +// Uncomment the next line to run the example +// example(); + +module.exports = VoiceSkill; \ No newline at end of file diff --git a/skills/voice/index.js b/skills/voice/index.js new file mode 100644 index 00000000..74faa6c6 --- /dev/null +++ b/skills/voice/index.js @@ -0,0 +1,265 @@ +const fs = require('fs'); +const path = require('path'); +const { exec, spawn } = require('child_process'); +const util = require('util'); + +const execAsync = util.promisify(exec); + +class ImprovedVoiceSkill { + constructor() { + this.name = 'voice'; + this.description = 'Enhanced text-to-speech functionality using edge-tts with direct playback'; + this.dependencies = ['edge-tts']; + } + + /** + * Converts text to speech using edge-tts and optionally plays it directly + * @param {string} text - The text to convert to speech + * @param {Object} options - Options for the TTS + * @param {boolean} playImmediately - Whether to play the audio immediately after generation + * @returns {Promise} Result object with success status, message and media link + */ + async textToSpeech(text, options = {}, playImmediately = false) { + // Set default options + const { + voice = 'zh-CN-XiaoxiaoNeural', // Default to Chinese voice + output = null, + rate = '+0%', + volume = '+0%', + pitch = '+0Hz' + } = options; + + // Validate input + if (!text || typeof text !== 'string') { + throw new Error('Text is required and must be a string'); + } + + // Create a temporary file for the output + const tempDir = path.join(__dirname, '..', '..', 'temp'); + if (!fs.existsSync(tempDir)) { + fs.mkdirSync(tempDir, { recursive: true }); + } + + const outputFileName = output || path.join(tempDir, `tts_${Date.now()}.mp3`); + + // Build the edge-tts command + const cmd = [ + 'edge-tts', + '--text', `"${text.replace(/"/g, '\\"')}"`, + '--write-media', outputFileName, + '--voice', voice, + '--rate', rate, + '--volume', volume, + '--pitch', pitch + ].join(' '); + + try { + // Execute the edge-tts command + await execAsync(cmd); + + // Verify the file was created + if (!fs.existsSync(outputFileName)) { + throw new Error(`Failed to create audio file at ${outputFileName}`); + } + + // Play immediately if requested + let playResult = null; + if (playImmediately) { + playResult = await this.playAudio(outputFileName); + } + + return { + success: true, + message: `Text-to-speech completed successfully${playImmediately ? ' and played' : ''}`, + media: `MEDIA: ${outputFileName}`, + filePath: outputFileName, + played: playImmediately, + playResult: playResult + }; + } catch (error) { + throw new Error(`Text-to-speech failed: ${error.message}`); + } + } + + /** + * Plays an audio file directly using system player + * @param {string} filePath - Path to the audio file to play + * @returns {Promise} Play result + */ + async playAudio(filePath) { + return new Promise((resolve, reject) => { + if (!fs.existsSync(filePath)) { + reject(new Error(`Audio file does not exist: ${filePath}`)); + return; + } + + // Determine the appropriate player based on OS + let player; + let playerArgs; + + if (process.platform === 'darwin') { // macOS + player = 'afplay'; + playerArgs = [filePath]; + } else if (process.platform === 'win32') { // Windows + player = 'powershell'; + playerArgs = ['-c', `(New-Object Media.SoundPlayer "${filePath}").PlaySync();`]; + } else { // Linux and others + player = 'aplay'; + playerArgs = [filePath]; + } + + const playProcess = spawn(player, playerArgs); + + playProcess.on('close', (code) => { + if (code === 0) { + resolve({ success: true, message: `Audio played successfully` }); + } else { + reject(new Error(`Audio playback failed with code: ${code}`)); + } + }); + + playProcess.on('error', (err) => { + reject(new Error(`Audio playback error: ${err.message}`)); + }); + }); + } + + /** + * Cleans up temporary audio files older than specified time + * @param {number} hoursOld - Files older than this many hours will be cleaned up + * @returns {Promise} Number of files removed + */ + async cleanupTempFiles(hoursOld = 1) { // Reduce default cleanup time to 1 hour + const tempDir = path.join(__dirname, '..', '..', 'temp'); + if (!fs.existsSync(tempDir)) { + return 0; + } + + const cutoffTime = Date.now() - (hoursOld * 60 * 60 * 1000); + const files = fs.readdirSync(tempDir); + let removedCount = 0; + + for (const file of files) { + const filePath = path.join(tempDir, file); + const stat = fs.statSync(filePath); + + if (stat.isFile() && file.match(/\.(mp3|wav|ogg)$/) && stat.mtime.getTime() < cutoffTime) { + try { + fs.unlinkSync(filePath); + removedCount++; + } catch (err) { + console.warn(`Could not remove file ${filePath}: ${err.message}`); + } + } + } + + return removedCount; + } + + /** + * Speaks text directly without saving to file + * @param {string} text - The text to speak + * @param {Object} options - Options for the TTS + * @returns {Promise} Result object + */ + async speakDirect(text, options = {}) { + // For direct speaking, we'll create a temporary file and play it immediately + const result = await this.textToSpeech(text, options, true); + + // Schedule cleanup of the temp file after a short time + setTimeout(() => { + if (fs.existsSync(result.filePath)) { + fs.unlink(result.filePath, (err) => { + if (err) console.error(`Error removing temp file: ${err.message}`); + }); + } + }, 5000); // Clean up after 5 seconds + + return result; + } + + /** + * Installs the required dependencies + * @returns {Promise} + */ + async installDependencies() { + try { + await execAsync('pip3 install edge-tts'); + console.log('Successfully installed edge-tts'); + } catch (error) { + console.error('Failed to install edge-tts:', error.message); + throw error; + } + } + + /** + * Main execution function for the skill + * @param {Object} params - Parameters for the skill + * @returns {Promise} Result of the operation + */ + async execute(params) { + const { action, text, options, playImmediately = false } = params; + + switch (action) { + case 'tts': + if (!text) { + throw new Error('Text is required for text-to-speech'); + } + + const result = await this.textToSpeech(text, options, playImmediately); + return result; + + case 'speak': + if (!text) { + throw new Error('Text is required for speaking'); + } + + const speakResult = await this.speakDirect(text, options); + return speakResult; + + case 'play': + if (!params.filePath) { + throw new Error('File path is required for playing audio'); + } + + const playResult = await this.playAudio(params.filePath); + return { + success: true, + message: 'Audio played successfully', + playResult + }; + + case 'cleanup': + const hoursOld = options?.hoursOld || 1; // Default to 1 hour + const cleaned = await this.cleanupTempFiles(hoursOld); + return { + success: true, + message: `Cleaned up ${cleaned} temporary audio files` + }; + + case 'install': + await this.installDependencies(); + return { + success: true, + message: 'Dependencies installed successfully' + }; + + case 'voices': + // Return a list of available voices + return { + success: true, + message: 'Available voices', + voices: [ + 'zh-CN-XiaoxiaoNeural', 'zh-CN-YunxiNeural', 'zh-CN-YunyangNeural', + 'en-US-Standard-C', 'en-US-Standard-D', 'en-US-Wavenet-F', + 'ja-JP-NanamiNeural', 'ko-KR-SunHiNeural' + ] + }; + + default: + throw new Error(`Unknown action: ${action}. Available actions: tts, speak, play, cleanup, install, voices`); + } + } +} + +module.exports = ImprovedVoiceSkill; \ No newline at end of file diff --git a/skills/voice/package.json b/skills/voice/package.json new file mode 100644 index 00000000..200844e6 --- /dev/null +++ b/skills/voice/package.json @@ -0,0 +1,23 @@ +{ + "name": "openclaw-voice-skill", + "version": "1.1.0", + "description": "Enhanced voice skill for OpenClaw providing improved text-to-speech functionality with direct playback", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "openclaw", + "skill", + "voice", + "tts", + "text-to-speech", + "audio", + "playback" + ], + "author": "zhaov1976", + "license": "MIT", + "dependencies": { + "edge-tts": "^1.0.0" + } +} \ No newline at end of file diff --git a/skills/voice/skill.json b/skills/voice/skill.json new file mode 100644 index 00000000..9a8b37c2 --- /dev/null +++ b/skills/voice/skill.json @@ -0,0 +1,27 @@ +{ + "id": "voice", + "name": "Voice", + "version": "1.0.0", + "description": "Text-to-speech functionality using edge-tts", + "author": "OpenClaw User", + "license": "MIT", + "tags": [ + "tts", + "voice", + "audio", + "text-to-speech", + "edge-tts" + ], + "dependencies": { + "pip": [ + "edge-tts" + ] + }, + "files": [ + "index.js", + "SKILL.md", + "package.json", + "example.js" + ], + "entryPoint": "index.js" +} \ No newline at end of file diff --git a/src/providers/base.ts b/src/providers/base.ts index de7d46bc..9bd50d90 100644 --- a/src/providers/base.ts +++ b/src/providers/base.ts @@ -24,6 +24,13 @@ export interface VideoOpts { filename: string } +export interface LipSyncOpts { + videoUrl: string // URL of the source video + audioUrl: string // URL of the audio to lip-sync to + workId: string + filename: string +} + export interface GenerateResult { success: boolean assetPath?: string @@ -36,6 +43,8 @@ export interface GenerateProvider { name: string supportsImage: boolean supportsVideo: boolean + supportsLipSync?: boolean generateImage(opts: ImageOpts): Promise generateVideo(opts: VideoOpts): Promise + lipSync?(opts: LipSyncOpts): Promise } diff --git a/src/providers/dreamina.ts b/src/providers/dreamina.ts index 08953ba0..f390305c 100644 --- a/src/providers/dreamina.ts +++ b/src/providers/dreamina.ts @@ -88,7 +88,7 @@ export class DreaminaProvider implements GenerateProvider { async generateVideo(opts: VideoOpts): Promise { const { prompt, workId, filename } = opts const duration = opts.duration ?? 5 - const modelVersion = opts.modelVersion ?? 'seedance2.0fast' + const modelVersion = opts.modelVersion ?? 'seedance2.0' try { let args: string[] diff --git a/src/providers/jimeng.ts b/src/providers/jimeng.ts index c4bdde10..e12c4bde 100644 --- a/src/providers/jimeng.ts +++ b/src/providers/jimeng.ts @@ -2,7 +2,7 @@ import { createHmac, createHash } from 'node:crypto' import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { dataDir } from '../config.js' -import type { GenerateProvider, ImageOpts, VideoOpts, GenerateResult } from './base.js' +import type { GenerateProvider, ImageOpts, VideoOpts, LipSyncOpts, GenerateResult } from './base.js' // ── Constants ─────────────────────────────────────────────────────────────── @@ -12,11 +12,15 @@ const SERVICE = 'cv' const API_VERSION = '2022-08-31' const SUBMIT_ACTION = 'CVSync2AsyncSubmitTask' const QUERY_ACTION = 'CVSync2AsyncGetResult' +// Lip-sync uses a different action pair +const LIPSYNC_SUBMIT_ACTION = 'CVSubmitTask' +const LIPSYNC_QUERY_ACTION = 'CVGetResult' +const LIPSYNC_REQ_KEY = 'realman_change_lips' const IMAGE_REQ_KEY = 'jimeng_t2i_v40' -// Video 3.0 Pro uses a unified req_key for both T2V and I2V -const VIDEO_T2V_REQ_KEY = 'jimeng_ti2v_v30_pro' -const VIDEO_I2V_REQ_KEY = 'jimeng_ti2v_v30_pro' +// Video 3.0 Pro req_keys (new API format with req_json wrapper) +const VIDEO_T2V_REQ_KEY = 'ImageGenerationTextToVideo' +const VIDEO_I2V_REQ_KEY = 'ImageGenerationImageToVideo' const POLL_INTERVAL_MS = 2000 const POLL_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes @@ -122,10 +126,12 @@ async function submitAndPoll( accessKey: string, secretKey: string, submitPayload: Record, + submitAction = SUBMIT_ACTION, + queryAction = QUERY_ACTION, ): Promise<{ data: any }> { // Submit task const submitBody = JSON.stringify(submitPayload) - const submitReq = signRequest(accessKey, secretKey, SUBMIT_ACTION, submitBody) + const submitReq = signRequest(accessKey, secretKey, submitAction, submitBody) const submitRes = await fetch(submitReq.url, { method: 'POST', headers: submitReq.headers, @@ -149,7 +155,7 @@ async function submitAndPoll( await new Promise(r => setTimeout(r, POLL_INTERVAL_MS)) const queryPayload = JSON.stringify({ req_key: submitPayload.req_key, task_id: taskId }) - const queryReq = signRequest(accessKey, secretKey, QUERY_ACTION, queryPayload) + const queryReq = signRequest(accessKey, secretKey, queryAction, queryPayload) const queryRes = await fetch(queryReq.url, { method: 'POST', headers: queryReq.headers, @@ -187,6 +193,7 @@ export class JimengProvider implements GenerateProvider { readonly name = 'jimeng' readonly supportsImage = true readonly supportsVideo = true + readonly supportsLipSync = true private accessKey: string private secretKey: string @@ -262,43 +269,34 @@ export class JimengProvider implements GenerateProvider { const isImageToVideo = !!opts.firstFrame const reqKey = isImageToVideo ? VIDEO_I2V_REQ_KEY : VIDEO_T2V_REQ_KEY - const payload: Record = { + // Build req_json inner object per 3.0 Pro API spec + const reqJson: Record = { req_key: reqKey, prompt, - return_url: true, } - if (opts.firstFrame) { - // If firstFrame looks like a URL, use image_urls; otherwise treat as base64 - if (opts.firstFrame.startsWith('http://') || opts.firstFrame.startsWith('https://')) { - payload.image_urls = [opts.firstFrame] - } else { - payload.binary_data_base64 = [opts.firstFrame] - } - } - if (opts.lastFrame) { - if (opts.lastFrame.startsWith('http://') || opts.lastFrame.startsWith('https://')) { - // For first+last frame mode, image_urls takes [firstFrame, lastFrame] - if (payload.image_urls) { - (payload.image_urls as string[]).push(opts.lastFrame) - } else { - payload.image_urls = [opts.lastFrame] - } - } else { - payload.binary_data_base64 = payload.binary_data_base64 ?? [] - ;(payload.binary_data_base64 as string[]).push(opts.lastFrame) - } + if (isImageToVideo && opts.firstFrame) { + reqJson.image_url = opts.firstFrame } + + // Map resolution/aspect ratio (e.g. "16:9", "9:16", "1:1") if (opts.resolution) { - // Map resolution string to aspect_ratio format expected by API - payload.aspect_ratio = opts.resolution + reqJson.aspect_ratio = opts.resolution + } else { + reqJson.aspect_ratio = '9:16' // default vertical for Douyin/XHS + } + + // Wrap in req_json string as the API expects + const payload: Record = { + req_key: reqKey, + req_json: JSON.stringify(reqJson), } const result = await submitAndPoll(this.accessKey, this.secretKey, payload) - // Extract video URL from result (different API versions use different field names) - const videoUrl = result.data?.video_urls?.[0] - ?? result.data?.video_url + // Extract video URL from result + const videoUrl = result.data?.video_url + ?? result.data?.video_urls?.[0] ?? result.data?.resp_data?.[0]?.video_url if (!videoUrl) { @@ -323,4 +321,50 @@ export class JimengProvider implements GenerateProvider { return { success: false, error: err.message, code: 'API_ERROR' } } } + + async lipSync(opts: LipSyncOpts): Promise { + const { videoUrl, audioUrl, workId, filename } = opts + + try { + const payload: Record = { + req_key: LIPSYNC_REQ_KEY, + video_url: videoUrl, + audio_url: audioUrl, + } + + const result = await submitAndPoll( + this.accessKey, + this.secretKey, + payload, + LIPSYNC_SUBMIT_ACTION, + LIPSYNC_QUERY_ACTION, + ) + + // Extract output video URL + const outputUrl = result.data?.resp_data?.[0]?.url + ?? result.data?.video_url + ?? result.data?.video_urls?.[0] + + if (!outputUrl) { + return { success: false, error: 'No video URL in lip-sync response', code: 'API_ERROR' } + } + + const assetPath = join(dataDir, 'works', workId, 'assets', 'clips', filename) + await downloadFile(outputUrl, assetPath) + + return { + success: true, + assetPath, + previewUrl: `/api/works/${workId}/assets/clips/${filename}`, + } + } catch (err: any) { + if (err.message?.includes('timed out')) { + return { success: false, error: err.message, code: 'TIMEOUT' } + } + if (err.message?.includes('Download failed')) { + return { success: false, error: err.message, code: 'DOWNLOAD_FAILED' } + } + return { success: false, error: err.message, code: 'API_ERROR' } + } + } } diff --git a/src/server/api.ts b/src/server/api.ts index 301d053b..46147cdd 100644 --- a/src/server/api.ts +++ b/src/server/api.ts @@ -5,18 +5,17 @@ import { promisify } from "node:util"; import { join, extname } from "node:path"; import { homedir } from "node:os"; import yaml from "js-yaml"; -import { loadConfig, saveConfig, dataDir } from "../config.js"; +import { loadConfig, saveConfig, dataDir, type Config } from "../config.js"; import { listWorks, getWork, createWork as storeCreateWork, updateWork as storeUpdateWork, deleteWork as storeDeleteWork, listAssets, getAssetPath, saveStepHistory, loadStepHistory, - saveWorkChat, saveEvalResult, loadAllEvalResults, - type Work, type PipelineStep, type EvalResult, + saveEvalResult, loadEvalResults, type EvalResult, } from "../work-store.js"; import { MemoryClient } from "../memory.js"; import type { WsBridge } from "../ws-bridge.js"; import { getProvider, getDefaultProvider, listProviders } from "../providers/registry.js"; -import { listSharedAssetsWithMeta, getSharedAssetPath, validateCategory, sanitizeFilename, saveSharedAsset, deleteSharedAsset, moveSharedAsset } from "../shared-assets.js"; +import { listSharedAssets, listSharedAssetsWithMeta, getSharedAssetPath, saveSharedAsset, deleteSharedAsset, moveSharedAsset, sanitizeFilename, validateCategory, CATEGORIES } from "../shared-assets.js"; import { getLatestCreatorData, getCreatorHistory } from "../analytics-collector.js"; import { syncStepConversation } from "../memory-sync.js"; import { log, readLogs } from "../logger.js"; @@ -148,18 +147,25 @@ apiRoutes.put("/api/config", async (c) => { apiRoutes.get("/api/works", async (c) => { try { const works = await listWorks(); - // Attach coverImage: prefer final video (for keyframe), then output image, then first asset image + // Attach coverImage: prefer cover image, then final video, then output image, then first asset image const enriched = await Promise.all(works.map(async (w) => { try { const assets = await listAssets(w.id); - // 1. Final video — browser will show keyframe as poster + // 1. Explicit cover image in output/ (best frame selected during assembly) + const coverImage = assets.find((a: string) => + /\.(png|jpe?g|webp)$/i.test(a) && a.startsWith("output/") && /cover/i.test(a) + ); + if (coverImage) { + return { ...w, coverImage: `/api/works/${w.id}/assets/${coverImage.split("/").map(encodeURIComponent).join("/")}` }; + } + // 2. Final video — browser will show keyframe as poster const finalVideo = assets.find((a: string) => /\.(mp4|mov|webm)$/i.test(a) && /final/i.test(a) ); if (finalVideo) { return { ...w, coverImage: `/api/works/${w.id}/assets/${finalVideo.split("/").map(encodeURIComponent).join("/")}`, coverIsVideo: true }; } - // 2. Output image (thumbnail/cover) + // 3. Output image (thumbnail) const outputImage = assets.find((a: string) => /\.(png|jpe?g|webp|gif)$/i.test(a) && a.startsWith("output/") ); @@ -193,6 +199,7 @@ apiRoutes.post("/api/works", async (c) => { videoSearchQuery?: string; platforms: string[]; topicHint?: string; + language?: "en" | "zh"; }>(); if (!body.title || !body.type || !body.platforms) { return c.json({ error: "title, type, and platforms are required" }, 400); @@ -205,6 +212,7 @@ apiRoutes.post("/api/works", async (c) => { videoSearchQuery: body.videoSearchQuery, platforms: body.platforms, topicHint: body.topicHint, + language: body.language, }); return c.json(work, 201); } catch (err) { @@ -272,40 +280,9 @@ apiRoutes.get("/api/works/:id/assets/*", async (c) => { try { // nestedPath maps directly to workspace subdirectory (e.g. "images/xxx.png", "output/xxx.png") const filePath = getAssetPath(id, nestedPath); - const { stat } = await import("node:fs/promises"); - const fileStat = await stat(filePath); - const fileSize = fileStat.size; - const mimeType = getMimeType(filePath); - const rangeHeader = c.req.header("range"); - - // Support HTTP Range requests (required for browser video/audio playback) - if (rangeHeader && (mimeType.startsWith("video/") || mimeType.startsWith("audio/"))) { - const match = rangeHeader.match(/bytes=(\d+)-(\d*)/); - if (match) { - const start = parseInt(match[1], 10); - const end = match[2] ? parseInt(match[2], 10) : fileSize - 1; - const chunkSize = end - start + 1; - const fullContent = await readFile(filePath); - const slice = fullContent.subarray(start, end + 1); - return new Response(slice, { - status: 206, - headers: { - "Content-Type": mimeType, - "Content-Range": `bytes ${start}-${end}/${fileSize}`, - "Content-Length": String(chunkSize), - "Accept-Ranges": "bytes", - }, - }); - } - } - const content = await readFile(filePath); return new Response(content, { - headers: { - "Content-Type": mimeType, - "Content-Length": String(fileSize), - "Accept-Ranges": "bytes", - }, + headers: { "Content-Type": getMimeType(filePath) }, }); } catch { return c.json({ error: "Asset not found" }, 404); @@ -380,8 +357,7 @@ apiRoutes.get("/api/analytics/creator/history", async (c) => { // POST /api/generate/image apiRoutes.post("/api/generate/image", async (c) => { const body = await c.req.json(); - const { workId, prompt, width, height, filename, provider: providerName, referenceImage, - aspectRatio, imageSize, seed, temperature, model } = body; + const { workId, prompt, width, height, filename, provider: providerName, referenceImage } = body; if (!workId || !prompt || !filename) { return c.json({ success: false, error: "Missing required fields", code: "INVALID_PARAMS" }, 400); } @@ -390,10 +366,7 @@ apiRoutes.post("/api/generate/image", async (c) => { return c.json({ success: false, error: "No image provider available", code: "INVALID_PARAMS" }, 400); } try { - const result = await provider.generateImage({ - prompt, width, height, workId, filename, referenceImage, - aspectRatio, imageSize, seed, temperature, model, - }); + const result = await provider.generateImage({ prompt, width, height, workId, filename, referenceImage }); return c.json(result); } catch (err: any) { return c.json({ success: false, error: err.message, code: "API_ERROR" }, 500); @@ -403,7 +376,7 @@ apiRoutes.post("/api/generate/image", async (c) => { // POST /api/generate/video apiRoutes.post("/api/generate/video", async (c) => { const body = await c.req.json(); - const { workId, prompt, firstFrame, lastFrame, resolution, filename, provider: providerName } = body; + const { workId, prompt, firstFrame, lastFrame, resolution, filename, provider: providerName, modelVersion } = body; if (!workId || !prompt || !filename) { return c.json({ success: false, error: "Missing required fields", code: "INVALID_PARAMS" }, 400); } @@ -412,7 +385,26 @@ apiRoutes.post("/api/generate/video", async (c) => { return c.json({ success: false, error: "No video provider available", code: "INVALID_PARAMS" }, 400); } try { - const result = await provider.generateVideo({ prompt, firstFrame, lastFrame, resolution, workId, filename }); + const result = await provider.generateVideo({ prompt, firstFrame, lastFrame, resolution, workId, filename, modelVersion }); + return c.json(result); + } catch (err: any) { + return c.json({ success: false, error: err.message, code: "API_ERROR" }, 500); + } +}); + +// POST /api/generate/lip-sync +apiRoutes.post("/api/generate/lip-sync", async (c) => { + const body = await c.req.json(); + const { workId, videoUrl, audioUrl, filename, provider: providerName } = body; + if (!workId || !videoUrl || !audioUrl || !filename) { + return c.json({ success: false, error: "Missing required fields (workId, videoUrl, audioUrl, filename)", code: "INVALID_PARAMS" }, 400); + } + const provider = providerName ? getProvider(providerName) : getProvider("jimeng"); + if (!provider?.supportsLipSync || !provider.lipSync) { + return c.json({ success: false, error: "No lip-sync provider available (requires Jimeng)", code: "INVALID_PARAMS" }, 400); + } + try { + const result = await provider.lipSync({ videoUrl, audioUrl, workId, filename }); return c.json(result); } catch (err: any) { return c.json({ success: false, error: err.message, code: "API_ERROR" }, 500); @@ -426,84 +418,60 @@ apiRoutes.get("/api/generate/providers", (c) => c.json(listProviders())); // Shared Assets // --------------------------------------------------------------------------- -apiRoutes.get("/api/shared-assets", async (c) => { - const assets = await listSharedAssetsWithMeta(); - return c.json(assets); -}); +// GET /api/shared-assets +apiRoutes.get("/api/shared-assets", async (c) => c.json(await listSharedAssetsWithMeta())); -apiRoutes.get("/api/shared-assets/:category/:file", async (c) => { +// POST /api/shared-assets/:category — upload files +apiRoutes.post("/api/shared-assets/:category", async (c) => { const category = c.req.param("category"); - const file = c.req.param("file"); try { validateCategory(category); - const filePath = getSharedAssetPath(category, file); - const data = await readFile(filePath); - const mime = getMimeType(filePath); - const isMedia = mime.startsWith("image/") || mime.startsWith("audio/") || mime.startsWith("video/"); - return new Response(data, { - headers: { - "Content-Type": mime, - "Content-Length": String(data.length), - "X-Content-Type-Options": "nosniff", - "Content-Disposition": isMedia ? "inline" : `attachment; filename="${encodeURIComponent(sanitizeFilename(file))}"`, - }, - }); + const body = await c.req.parseBody({ all: true }); + const files = Array.isArray(body["file"]) ? body["file"] : body["file"] ? [body["file"]] : []; + const saved = []; + for (const f of files) { + if (f instanceof File) { + const buf = Buffer.from(await f.arrayBuffer()); + saved.push(await saveSharedAsset(category, f.name, buf)); + } + } + return c.json({ uploaded: saved }); } catch (e: any) { - if (e.code === "ENOENT") return c.json({ error: "File not found" }, 404); - if (e.message?.includes("Invalid")) return c.json({ error: e.message }, 400); - return c.json({ error: "Failed to read file" }, 500); + return c.json({ error: e.message }, 400); } }); -apiRoutes.post("/api/shared-assets/move", async (c) => { +// DELETE /api/shared-assets/:category/:file +apiRoutes.delete("/api/shared-assets/:category/:file", async (c) => { try { - const { from, to, file } = await c.req.json<{ from: string; to: string; file: string }>(); - if (!from || !to || !file) return c.json({ error: "from, to, and file are required" }, 400); - await moveSharedAsset(from, to, file); - return c.json({ moved: true, from, to, file }); + await deleteSharedAsset(c.req.param("category"), c.req.param("file")); + return c.json({ ok: true }); } catch (e: any) { - if (e.code === "ENOENT") return c.json({ error: "File not found" }, 404); - if (e.message?.includes("Invalid")) return c.json({ error: e.message }, 400); - if (e.message?.includes("already exists")) return c.json({ error: e.message }, 409); - return c.json({ error: e.message ?? "Move failed" }, 500); + return c.json({ error: e.message }, 400); } }); -apiRoutes.post("/api/shared-assets/:category", async (c) => { - const category = c.req.param("category"); - try { - validateCategory(category); - } catch { - return c.json({ error: `Invalid category: ${category}` }, 400); - } +// POST /api/shared-assets/:fromCat/:file/move — move file to another category +apiRoutes.post("/api/shared-assets/:fromCat/:file/move", async (c) => { try { - const body = await c.req.parseBody({ all: true }); - const files = Array.isArray(body["file"]) ? body["file"] : body["file"] ? [body["file"]] : []; - const uploaded = []; - for (const f of files) { - if (!(f instanceof File)) continue; - if (f.size > 100 * 1024 * 1024) return c.json({ error: `File ${f.name} exceeds 100MB limit` }, 400); - const buf = Buffer.from(await f.arrayBuffer()); - const asset = await saveSharedAsset(category, f.name, buf); - uploaded.push({ ...asset, url: `/api/shared-assets/${category}/${encodeURIComponent(asset.name)}` }); - } - if (uploaded.length === 0) return c.json({ error: "No files provided" }, 400); - return c.json({ uploaded }); + const { toCat } = await c.req.json<{ toCat: string }>(); + await moveSharedAsset(c.req.param("fromCat"), toCat, c.req.param("file")); + return c.json({ ok: true }); } catch (e: any) { - return c.json({ error: e.message ?? "Upload failed" }, 500); + return c.json({ error: e.message }, 400); } }); -apiRoutes.delete("/api/shared-assets/:category/:file", async (c) => { - const category = c.req.param("category"); - const file = c.req.param("file"); +// GET /api/shared-assets/:category/:file — serve file with correct MIME type +apiRoutes.get("/api/shared-assets/:category/:file", async (c) => { + const filePath = getSharedAssetPath(c.req.param("category"), c.req.param("file")); try { - await deleteSharedAsset(category, file); - return c.json({ deleted: true }); - } catch (e: any) { - if (e.code === "ENOENT") return c.json({ error: "File not found" }, 404); - if (e.message?.includes("Invalid")) return c.json({ error: e.message }, 400); - return c.json({ error: "Delete failed" }, 500); + const data = await readFile(filePath); + return new Response(data, { + headers: { "Content-Type": getMimeType(filePath) }, + }); + } catch { + return c.json({ error: "File not found" }, 404); } }); @@ -742,12 +710,12 @@ apiRoutes.post("/api/trends/refresh-stream", async (c) => { `## 核心创作方向(强制执行)`, ``, `每个推荐的话题/方向必须能触发以下四种情绪中的至少一种,否则不予推荐:`, - `1. **焦虑**(落后焦虑/错过焦虑/被替代焦虑/身份下坠焦虑)— 让观众觉得"我是不是落后了"`, - `2. **愤怒**(不公/冒犯/双标/欺骗/价值观冲突)— 让观众觉得"这不对/凭什么"`, + `1. **共鸣**(身份认同/处境代入/被戳中的感觉)— 让观众觉得"这说的不就是我吗"`, + `2. **争议感**(不公/双标/价值观碰撞/辩论欲)— 让观众觉得"这不对/凭什么",想站队`, `3. **搞笑/抽象**(反转/共鸣/错位)— 让观众笑出来想转发`, `4. **羡慕**(想成为/想拥有)— 让观众觉得"我也想要这样的生活"`, ``, - `每个话题必须在 JSON 中标注 "emotionType"(焦虑/愤怒/搞笑/羡慕)和 "emotionSubtype"(具体子类型)。`, + `每个话题必须在 JSON 中标注 "emotionType"(共鸣/争议/搞笑/羡慕)和 "emotionSubtype"(具体子类型)。`, ``, `如果上面的 API 数据不够充分,请使用 WebSearch 补充搜索:`, `- "${platformLabel} 爆款内容 趋势 2026"`, @@ -762,8 +730,8 @@ apiRoutes.post("/api/trends/refresh-stream", async (c) => { ` "heat":4,`, ` "competition":"中",`, ` "opportunity":"金矿",`, - ` "emotionType":"焦虑",`, - ` "emotionSubtype":"被替代焦虑",`, + ` "emotionType":"共鸣",`, + ` "emotionSubtype":"处境代入",`, ` "description":"趋势描述和为什么值得做",`, ` "tags":["推荐标签1","推荐标签2","推荐标签3"],`, ` "contentAngles":["切入角度1","切入角度2"],`, @@ -773,7 +741,7 @@ apiRoutes.post("/api/trends/refresh-stream", async (c) => { `- topics 至少 10 个`, `- heat 为 1-5 整数,competition 为 "低"/"中"/"高"`, `- opportunity 为 "金矿"(高热低竞)/"蓝海"(低热低竞)/"红海"(高热高竞)`, - `- emotionType 必填,为 "焦虑"/"愤怒"/"搞笑"/"羡慕" 之一`, + `- emotionType 必填,为 "共鸣"/"争议"/"搞笑"/"羡慕" 之一`, `- emotionSubtype 必填,为该情绪的具体子类型`, `- tags 3-5 个平台推荐标签`, `- contentAngles 2-3 个具体的内容切入角度`, @@ -833,14 +801,77 @@ apiRoutes.post("/api/works/:id/session", async (c) => { const work = await getWork(id); if (!work) return c.json({ error: "Work not found" }, 404); + // Detect existing assets for skip awareness + const sessionAssets = await listAssets(id); + const sessionHasClips = sessionAssets.some(a => a.includes("clips/") && (a.endsWith(".mp4") || a.endsWith(".mov"))); + const sessionHasFrames = sessionAssets.some(a => a.includes("frames/") && (a.endsWith(".png") || a.endsWith(".jpg"))); + const sessionHasImages = sessionAssets.some(a => a.includes("images/") && (a.endsWith(".png") || a.endsWith(".jpg"))); + const sessionHasAssets = sessionHasClips || sessionHasFrames || sessionHasImages; + const sessionHasDirection = !!(work.topicHint && work.topicHint.length > 50); + + // Auto-skip steps that are already covered by user-provided context + const stepEntries = Object.entries(work.pipeline); + let pipelineChanged = false; + for (const [key, s] of stepEntries) { + if (s.status !== "pending" && s.status !== "active") continue; + let canAutoSkip = false; + if (key === "research" && sessionHasDirection) canAutoSkip = true; + if (key === "plan" && sessionHasDirection && sessionHasAssets) canAutoSkip = true; + if (key === "material-search" && sessionHasClips) canAutoSkip = true; + if (key === "assets" && sessionHasImages) canAutoSkip = true; + if (canAutoSkip) { + s.status = "skipped"; + s.completedAt = new Date().toISOString(); + s.note = "Auto-skipped: user provided sufficient context"; + pipelineChanged = true; + } else { + break; // Stop at the first step that can't be skipped + } + } + if (pipelineChanged) { + // Activate the next pending step + const nextPending = stepEntries.find(([, s]) => s.status === "pending"); + if (nextPending) { + nextPending[1].status = "active"; + nextPending[1].startedAt = new Date().toISOString(); + } + await storeUpdateWork(id, { pipeline: work.pipeline }); + } + const steps = Object.entries(work.pipeline); const pendingStep = steps.find(([, s]) => s.status === "pending" || s.status === "active"); const stepName = pendingStep ? pendingStep[1].name : steps[0]?.[1]?.name ?? "创作"; - const prompt = [ + // Build skip context + const skipContext = (sessionHasAssets || sessionHasDirection) ? [ + ``, + `NOTE: The user has already provided ${[ + sessionHasClips ? "video clips" : "", + sessionHasFrames ? "frame images" : "", + sessionHasImages ? "content images" : "", + sessionHasDirection ? "detailed creative direction in the topic hint" : "", + ].filter(Boolean).join(", ")}.`, + `Use the user's direction as-is — do NOT propose alternatives or redo their creative decisions.`, + `Proceed directly with what the user described.`, + ] : []; + + const isEn = work.language === "en"; + const prompt = isEn ? [ + `You are a content creation assistant. You are helping the user create: "${work.title}" (type: ${work.type}).`, + `Target platforms: ${work.platforms.map((p: any) => typeof p === "string" ? p : p.platform).join(", ")}.`, + work.topicHint ? `Topic direction: ${work.topicHint}` : "", + ...skipContext, + ``, + `Current step: "${stepName}".`, + `First confirm with the user: briefly explain what you'll do in this step, ask if they have specific directions or requirements, then wait for confirmation before starting.`, + `Do not start executing immediately — communicate with the user first.`, + ``, + `IMPORTANT: All your responses, generated content, titles, copytext, and tags must be in English.`, + ].filter(Boolean).join("\n") : [ `你是一个内容创作助手。你正在帮助用户创作: "${work.title}" (类型: ${work.type})。`, `目标平台: ${work.platforms.map((p: any) => typeof p === "string" ? p : p.platform).join(", ")}。`, work.topicHint ? `选题方向: ${work.topicHint}` : "", + ...skipContext, ``, `当前步骤: "${stepName}"。`, `请先向用户确认:简要说明这个步骤你将做什么,询问用户是否有特定方向或要求,等用户确认后再开始工作。`, @@ -892,24 +923,99 @@ apiRoutes.post("/api/works/:id/step/:step", async (c) => { const pipelineStep = work.pipeline[step]; if (!pipelineStep) return c.json({ error: `Unknown pipeline step: ${step}` }, 404); - // Check prerequisites: all preceding steps must be done/skipped + // Detect what the user has already provided + const assets = await listAssets(id); + const hasClips = assets.some(a => a.includes("clips/") && (a.endsWith(".mp4") || a.endsWith(".mov"))); + const hasFrames = assets.some(a => a.includes("frames/") && (a.endsWith(".png") || a.endsWith(".jpg"))); + const hasImages = assets.some(a => a.includes("images/") && (a.endsWith(".png") || a.endsWith(".jpg"))); + const hasNarration = assets.some(a => a.includes("narration")); + const hasMusic = assets.some(a => a.includes("music/") || a.includes("bgm")); + const hasAssets = hasClips || hasFrames || hasImages; + const hasDetailedDirection = !!(work.topicHint && work.topicHint.length > 50); + const hasTitle = !!(work.title && work.title.length > 10); + + // Smart skip: auto-skip preceding steps when user already provided enough context const stepKeys = Object.keys(work.pipeline); const stepIdx = stepKeys.indexOf(step); + let skippedSteps: string[] = []; for (let i = 0; i < stepIdx; i++) { const prev = work.pipeline[stepKeys[i]]; if (prev.status !== "done" && prev.status !== "skipped") { - return c.json({ error: `Previous step "${prev.name}" is not completed` }, 400); + // Determine if this step can be auto-skipped + const prevKey = stepKeys[i]; + let canSkip = false; + if (prevKey === "research" && (hasDetailedDirection || hasAssets)) { + // User already has a clear direction or provided assets — skip research + canSkip = true; + } else if (prevKey === "plan" && hasAssets && hasDetailedDirection) { + // User provided assets and detailed direction — skip planning + canSkip = true; + } else if (prevKey === "material-search" && hasClips) { + // User already has video clips — skip material search + canSkip = true; + } else if (prevKey === "assets" && hasImages) { + // User already provided images — skip image generation + canSkip = true; + } + + if (canSkip) { + prev.status = "skipped"; + prev.completedAt = new Date().toISOString(); + prev.note = "Auto-skipped: user provided sufficient context/assets"; + skippedSteps.push(prevKey); + } else { + return c.json({ error: `Previous step "${prev.name}" is not completed` }, 400); + } } } + // Persist any auto-skipped steps + if (skippedSteps.length > 0) { + await storeUpdateWork(id, { pipeline: work.pipeline }); + } + const isEn = work.language === "en"; const promptParts = [ `You are working on a content piece: "${work.title}" (type: ${work.type}).`, work.contentCategory ? `Content category: ${work.contentCategory}.` : "", `Platforms: ${work.platforms.map((p: any) => typeof p === "string" ? p : p.platform).join(", ")}.`, work.topicHint ? `Topic hint: ${work.topicHint}` : "", ``, + ...(isEn ? [ + `## LANGUAGE REQUIREMENT`, + `ALL your responses, generated content, titles, copytext, tags, and text overlays on images must be in **English**.`, + `Do NOT output Chinese text. The user interface is in English and all deliverables must be English.`, + ``, + ] : []), ]; + // Inject context about existing assets and skipped steps + if (hasAssets || skippedSteps.length > 0) { + const assetSummary: string[] = []; + if (hasClips) assetSummary.push("video clips"); + if (hasFrames) assetSummary.push("frame images"); + if (hasImages) assetSummary.push("content images"); + if (hasNarration) assetSummary.push("narration audio"); + if (hasMusic) assetSummary.push("background music"); + promptParts.push( + `## EXISTING CONTEXT`, + ``, + `The user has already provided: ${assetSummary.join(", ")}.`, + `Available assets: ${assets.filter(a => !a.includes("_sadtalker_tmp") && !a.includes(".mat") && !a.includes(".txt")).join(", ")}.`, + skippedSteps.length > 0 + ? `Steps auto-skipped because user provided sufficient context: ${skippedSteps.join(", ")}.` + : "", + ``, + `**IMPORTANT:** The user already has a clear direction and/or assets. Do NOT redo work that the user has already provided.`, + hasDetailedDirection + ? `The user's topic hint contains detailed direction — use it as the creative brief. Do not contradict or reinterpret it.` + : "", + hasClips + ? `Video clips already exist — use them directly instead of generating new ones, unless the user asks otherwise.` + : "", + ``, + ); + } + if (step === "material-search" && work.videoSearchQuery) { promptParts.push( `Execute the "视频搜索" step.`, @@ -947,28 +1053,36 @@ apiRoutes.post("/api/works/:id/step/:step", async (c) => { `- Video files MUST have audio. Always use yt-dlp with audio merging, never plain curl/wget.`, `- Files must be actually downloaded and saved as assets so the inline player can play them.`, ); + } else if (step === "research" && hasDetailedDirection) { + // User already has detailed direction — fast-track research + promptParts.push( + `Execute the "${pipelineStep.name}" step.`, + ``, + `## FAST-TRACK: User has already provided detailed creative direction`, + ``, + `The user's topic hint already contains a clear, detailed description of what they want to create:`, + `"${work.topicHint}"`, + ``, + `**Do NOT generate 3 alternative proposals.** The user already knows what they want.`, + `Instead:`, + `1. Briefly confirm the direction with the user — summarize what you understand from their description`, + `2. If the direction is clear enough, write the research output (a single content brief based on their direction) and save it`, + `3. Mark this step as done and advance to the next step:`, + ` \`curl -X POST http://localhost:3271/api/works/${work.id}/pipeline/advance -H "Content-Type: application/json" -d '{"completedStep":"research","nextStep":"plan"}'\``, + ``, + `The research output should be a single, focused brief that captures:`, + `- The core emotion/hook`, + `- The narrative angle (first-person, coach, etc.)`, + `- The target audience reaction`, + `- Key talking points or content beats`, + ``, + `Do NOT search for trending topics or propose alternative directions. The user has already decided.`, + ); } else if (step === "research" && work.contentCategory && work.contentCategory !== "comedy") { const emotionEffect: Record = { - anxiety: "看完之后感到焦虑、危机感、害怕自己落后或错过", - conflict: "看完之后感到愤怒、不公、想站队、想在评论区吵架", - envy: "看完之后感到羡慕、向往、想收藏、想拥有同样的生活", - }; - const routeTemplates: Record = { - anxiety: [ - `路线1 观点输出型:文字卡片封面(≤20字,一句极端观点)+ 文案(第一人称+身边案例+绝对表态)`, - `路线2 对话截图型:微信对话截图封面 + 一句话文案`, - `路线3 清单盘点型:极端判断句封面 + 清单图 + 文案`, - ].join("\n"), - conflict: [ - `路线1 观点输出型:文字卡片封面(≤20字,一句极端观点)+ 文案(第一人称+身边案例+绝对表态)`, - `路线2 对话截图型:微信对话截图封面 + 一句话文案`, - `路线3 清单盘点型:极端判断句封面 + 清单图 + 文案`, - ].join("\n"), - envy: [ - `路线1 反差跃迁型:before/after 两张搜图 + 文案强调路径短`, - `路线2 关系羡慕型:1-5张甜蜜瞬间搜图(风格统一)+ 一句话文案`, - `路线3 隐性阶层信号型:1-5张日常搜图(细节暗示阶层)+ 轻描淡写文案`, - ].join("\n"), + anxiety: "看完之后感到被戳中、共鸣强烈、忍不住分享给朋友", + conflict: "看完之后产生强烈的争议感、正义感、想站队、想在评论区辩论", + envy: "看完之后强烈羡慕、想拥有同样的生活——展示的必须是极少数人才能享有的精致/富裕/甜蜜生活,而非普通人日常", }; const cat = work.contentCategory as string; // Load user interests and competitors for topic relevance @@ -981,87 +1095,219 @@ apiRoutes.post("/api/works/:id/step/:step", async (c) => { const competitorClause = douyinUrl ? `\n用户的竞品账号:${douyinUrl}。选题风格和受众定位可以参考这个账号的方向。\n` : ""; - promptParts.push([ - `Execute the "${pipelineStep.name}" step.`, - ``, - `## 你要产出什么`, - ``, - `3 个完整的图文选题,每个可以直接复制粘贴去小红书/抖音发布。`, - interestClause, - competitorClause, - `## 内容视角:永远是"我"的故事`, - ``, - `这不是新闻报道。所有内容都是发布者以**第一人称**在聊自己的主观感受、自己的经历、自己的处境。`, - ``, - `正确示例:`, - `- "我今年28,单身,没房没车。我妈说我是废物。"(第一人称,聊自己)`, - `- "我老公今天突然送了我一束花,没有任何原因。"(第一人称,聊自己的关系)`, - `- "周三下午,一个人坐在阳台上喝咖啡。"(第一人称,聊自己的日常)`, - ``, - `错误示例(绝对禁止):`, - `- "某地房价暴跌30%,购房者损失惨重"(这是新闻报道,不是个人帖子)`, - `- "年轻人就业压力增大,专家建议..."(这是客观分析,不是个人感受)`, - `- "据统计,2026年考研人数再创新高"(这是数据引用,不是个人故事)`, - ``, - `热点话题只用来选标签、蹭流量,内容本身必须是"我"的故事。`, - ``, - `## 第一步:搜索当前热门标签`, - ``, - `用 WebSearch 搜索"今日热搜""微博热搜""抖音热点",找到当前有热度的话题。`, - `这些话题只用来选标签(蹭流量),不是用来写内容的。`, - ``, - `## 第二步:围绕热门话题,构造"我"的故事`, - ``, - `每个选题的核心是一个虚构但真实感极强的第一人称故事/感受,读完后让观众${emotionEffect[cat] ?? "产生强烈情绪"}。`, - ``, - `构造方法:`, - `1. 给"我"一个身份(年龄、职业、城市、处境)`, - `2. 讲"我"的一段具体经历或此刻的感受`, - `3. 让读者代入"我"的处境后,自然地${emotionEffect[cat] ?? "产生情绪"}`, - ``, - `## 3 条路线模板(3 个选题各用一条)`, - ``, - routeTemplates[cat] ?? "", - ``, - `## 输出格式:3 个完整选题`, - ``, - `每个选题包含:`, - `1. **蹭的热门话题**:用来选标签的热点`, - `2. **路线**:用的哪条路线`, - `3. **封面**:文字卡片写出完整文字(≤20字);搜图类给出关键词`, - `4. **标题**:可以直接用的发布标题`, - `5. **完整文案**:以"我"的第一人称写的完整成品文案,读起来像一个真人在倾诉自己的经历/感受`, - `6. **标签**:5-6 个(从热搜中选)`, - ``, - `请用户从 3 个中选一个。`, - ].join("\n")); + + // Envy category uses a two-step research flow + if (cat === "envy") { + promptParts.push([ + `Execute the "${pipelineStep.name}" step.`, + ``, + `## 向往拥有类 — 两轮选题流程`, + ``, + `这个流程分两轮。请严格按照步骤执行,不要跳步。`, + interestClause, + competitorClause, + ``, + `### 第一轮:展示 3 个主方向`, + ``, + `先用 WebSearch 调研当前平台热门趋势,然后向用户展示以下 3 个创作主方向,每个方向只需简要说明(2-3 句话),不要给出具体选题:`, + ``, + `**方向 A:反差跃迁型**`, + `before/after 对比,展示从平凡到惊艳的跃迁。核心是"路径很短但反差巨大",让观众觉得"我花点心思也能做到"。`, + ``, + `**方向 B:关系羡慕型**`, + `展示甜蜜关系中的具体细节和瞬间。核心是"用心对待"的具体行为,触发对理想关系的向往。`, + ``, + `**方向 C:隐性阶层信号型**`, + `看似随意的日常,细节透露出高于普通人的生活层级——时间自由、空间品质、不赶不挤。`, + ``, + `对每个方向,基于你搜索到的趋势数据,简要分析:`, + `- 当前热度和素材丰富度`, + `- 平台匹配度(抖音 vs 小红书)`, + `- 竞争程度和差异化空间`, + ``, + `然后给出推荐排序,并**请用户选择一个方向**。`, + ``, + `⚠️ 第一轮到此为止!不要给出具体选题,不要自行决定子方向。等用户回复后再进入第二轮。`, + ``, + `### 第二轮:给出 7-10 个具体子方向(等用户选择后再执行)`, + ``, + `用户选定主方向后,根据所选方向给出 **7-10 个具体的子方向/选题角度**。`, + `每个子方向包含:`, + `1. **子方向名称**:一句话概括(如"独居女生的周三下午")`, + `2. **内容概述**:2-3 句话描述这个选题要拍/写什么`, + `3. **情绪触发点**:观众看到后会产生什么感受`, + `4. **素材方向**:大致需要什么类型的图片/视频`, + ``, + `然后请用户从中选择一个子方向,进入下一步。`, + ``, + `## 内容视角:永远是"我"的故事`, + ``, + `所有内容都是发布者以**第一人称**在展示自己的生活。不是新闻,不是报道,是"我"的日常。`, + ``, + `## 核心要求`, + ``, + `图片/视频展示的生活方式必须是极少数人才能享有的,绝对不可以是普通人日常。`, + `可以是精致/富裕的生活,也可以是极少数人才有的视角或甜蜜关系中的细节。`, + ].join("\n")); + } else { + // anxiety / conflict: original single-round flow + const routeTemplates: Record = { + anxiety: [ + `路线1 观点输出型:文字卡片封面(≤20字,一句极端观点)+ 文案(第一人称+身边案例+绝对表态)`, + `路线2 对话截图型:微信对话截图封面 + 一句话文案`, + `路线3 清单盘点型:极端判断句封面 + 清单图 + 文案`, + ].join("\n"), + conflict: [ + `路线1 观点输出型:文字卡片封面(≤20字,一句极端观点)+ 文案(第一人称+身边案例+绝对表态)`, + `路线2 对话截图型:微信对话截图封面 + 一句话文案`, + `路线3 清单盘点型:极端判断句封面 + 清单图 + 文案`, + ].join("\n"), + }; + promptParts.push([ + `Execute the "${pipelineStep.name}" step.`, + ``, + `## 你要产出什么`, + ``, + `3 个完整的图文选题,每个可以直接复制粘贴去小红书/抖音发布。`, + interestClause, + competitorClause, + `## 内容视角:永远是"我"的故事`, + ``, + `这不是新闻报道。所有内容都是发布者以**第一人称**在聊自己的主观感受、自己的经历、自己的处境。`, + ``, + `正确示例:`, + `- "我今年28,单身,没房没车。我妈说我是废物。"(第一人称,聊自己)`, + `- "我老公今天突然送了我一束花,没有任何原因。"(第一人称,聊自己的关系)`, + `- "周三下午,一个人坐在阳台上喝咖啡。"(第一人称,聊自己的日常)`, + ``, + `错误示例(绝对禁止):`, + `- "某地房价暴跌30%,购房者损失惨重"(这是新闻报道,不是个人帖子)`, + `- "年轻人就业压力增大,专家建议..."(这是客观分析,不是个人感受)`, + `- "据统计,2026年考研人数再创新高"(这是数据引用,不是个人故事)`, + ``, + `热点话题只用来选标签、蹭流量,内容本身必须是"我"的故事。`, + ``, + `## 第一步:搜索当前热门标签`, + ``, + `用 WebSearch 搜索"今日热搜""微博热搜""抖音热点",找到当前有热度的话题。`, + `这些话题只用来选标签(蹭流量),不是用来写内容的。`, + ``, + `## 第二步:围绕热门话题,构造"我"的故事`, + ``, + `每个选题的核心是一个虚构但真实感极强的第一人称故事/感受,读完后让观众${emotionEffect[cat] ?? "产生强烈情绪"}。`, + ``, + `构造方法:`, + `1. 给"我"一个身份(年龄、职业、城市、处境)`, + `2. 讲"我"的一段具体经历或此刻的感受`, + `3. 让读者代入"我"的处境后,自然地${emotionEffect[cat] ?? "产生情绪"}`, + ``, + `## 3 条路线模板(3 个选题各用一条)`, + ``, + routeTemplates[cat] ?? "", + ``, + `## 输出格式:3 个完整选题`, + ``, + `每个选题包含:`, + `1. **蹭的热门话题**:用来选标签的热点`, + `2. **路线**:用的哪条路线`, + `3. **封面**:文字卡片写出完整文字(≤20字);搜图类给出关键词`, + `4. **标题**:可以直接用的发布标题`, + `5. **完整文案**:以"我"的第一人称写的完整成品文案,读起来像一个真人在倾诉自己的经历/感受`, + `6. **标签**:5-6 个(从热搜中选)`, + ``, + `请用户从 3 个中选一个。`, + ].join("\n")); + } } else { promptParts.push( `Execute the "${pipelineStep.name}" step of the pipeline.`, `Produce output appropriate for this step. Be thorough and creative.`, ); - if (step === "assets" && work.type === "short-video") { + if (step === "assembly" && work.type === "short-video") { + // Narration voice generation with edge-tts promptParts.push( ``, - `## Asset Acquisition Strategy`, - `For this step, you should acquire video materials by **downloading real clips from the internet** using yt-dlp.`, - `Do NOT use AI generation APIs unless the user explicitly requests it.`, + `## REQUIRED: Generate Narration Audio`, + ``, + `Before assembling the final video, you MUST generate a narration voiceover audio file.`, + ``, + `**Step 0 — Detect person gender from video/image assets (MUST DO FIRST):**`, + `Before selecting a voice, you MUST identify the gender of the person appearing in the video clips or frame images.`, + `Extract a frame from the main video clip and examine it:`, + `\`\`\`bash`, + `ffmpeg -i -ss 00:00:01 -frames:v 1 -y /tmp/gender_check.png`, + `\`\`\``, + `Look at the extracted frame to determine if the person is male or female. Then select a matching voice:`, + isEn ? [ + `- If female: use en-US-JennyNeural (confident female English voice)`, + `- If male: use en-US-AndrewNeural (confident male English voice)`, + `- Other female options: en-US-AriaNeural, en-GB-SoniaNeural`, + `- Other male options: en-US-GuyNeural, en-GB-RyanNeural`, + ].join("\n") : [ + `- 如果是女性: 使用 zh-CN-XiaoxiaoNeural(自信女声)`, + `- 如果是男性: 使用 zh-CN-YunxiNeural(自信男声)`, + `- 其他女声: zh-CN-XiaohanNeural`, + `- 其他男声: zh-CN-YunyangNeural`, + ].join("\n"), + ``, + `**Then tell the user your detection result and chosen voice, and ask for confirmation before proceeding.** For example:`, + `"${isEn + ? `I can see the person in the video is female, so I'll use a female English voice (en-US-JennyNeural) for the narration. Does that work for you?` + : `视频中的人物是女性,我将使用女声中文旁白(zh-CN-XiaoxiaoNeural)。可以吗?`}"`, + ``, + `**How to generate (after user confirms):**`, + `Use the \`edge-tts\` command to convert the narration script to audio:`, + `\`\`\`bash`, + `edge-tts --text "YOUR NARRATION TEXT HERE" --voice --write-media /assets/clips/narration.mp3`, + `\`\`\``, ``, - `### Workflow:`, - `1. Read the storyboard/plan from the previous step to understand what clips are needed`, - `2. For each shot, construct search keywords based on the scene description`, - `3. Search YouTube/Bilibili: \`yt-dlp "ytsearch5:keywords" --get-title --get-url --get-duration\``, - `4. Download best quality: \`yt-dlp -f "bestvideo[height<=1080]+bestaudio/best" --merge-output-format mp4 -o "clips/clip-NN.mp4" "URL"\``, - `5. Trim to needed segment: \`ffmpeg -i clip.mp4 -ss START -to END -c copy -y trimmed.mp4\``, - `6. Verify audio exists: \`ffprobe -v error -show_entries stream=codec_type -of csv=p=0 clip.mp4 | grep audio\``, + `**Steps:**`, + `1. Extract a frame from the main video clip and detect the person's gender`, + `2. Select a matching voice and tell the user your choice — ask for confirmation`, + `3. Write the narration script based on the content plan`, + `4. After confirmation, run edge-tts to generate the audio file`, + `4. If the content plan uses talking-head/口播 style (marked as 口播(一镜到底)):`, + ` - The person video clip should already exist from asset-generation (no lip-sync applied yet)`, + ` - Call POST /api/generate/lip-sync with the person video URL and narration audio URL to generate lip-synced video`, + ` - The lip-synced video replaces the original person clip as the main footage`, + ` - Then overlay per-sentence subtitles at the bottom (synced to narration timing) + BGM`, + `5. For non-口播 style: merge clips + narration audio + subtitles + BGM using ffmpeg`, ``, - `Read the SKILL.md section "素材获取方式:全网搜索下载" for full details.`, - `Save all clips to the work assets directory under clips/.`, ); - } - if (step === "assembly" && work.type === "short-video") { + // Background music generation with Lyria promptParts.push( + `## REQUIRED: Generate Background Music with Lyria`, + ``, + `Generate original background music that matches the content mood using Google Lyria:`, + `\`\`\`bash`, + `python3 skills/asset-generation/scripts/lyria_music.py \\`, + ` --prompt "YOUR MUSIC DESCRIPTION" \\`, + ` --output /assets/clips/bgm.mp3`, + `\`\`\``, ``, + `**Music prompt tips:**`, + `- Be specific: genre, tempo (BPM), mood, instruments`, + `- Match the content emotion:`, + isEn ? [ + ` - Resonance/emotional content → soft piano, gentle strings, melancholic, 70-90 BPM`, + ` - Debate/controversy → tense, dramatic, driving percussion, 100-120 BPM`, + ` - Comedy/absurd → quirky, playful, upbeat, fun synths, 110-130 BPM`, + ` - Aspiration/envy → dreamy, luxurious, lo-fi chill, warm pads, 80-100 BPM`, + ].join("\n") : [ + ` - 深度共鸣类 → 轻柔钢琴、弦乐、感性氛围、70-90 BPM`, + ` - 观点分歧类 → 紧张感、节奏驱动、适度戏剧性、100-120 BPM`, + ` - 搞笑抽象类 → 活泼、俏皮、欢快合成器、110-130 BPM`, + ` - 向往拥有类 → 梦幻、精致、lo-fi chill、温暖音色、80-100 BPM`, + ].join("\n"), + `- Use \`google/lyria-3-clip-preview\` for 30s clips (default, good for short videos)`, + `- Use \`--model google/lyria-3-pro-preview\` for longer tracks if needed`, + ``, + `**In the final ffmpeg mix**, layer the BGM under the narration:`, + `- BGM volume should be ~20-30% of narration volume (use \`-filter_complex "[1:a]volume=0.25[bgm];[0:a][bgm]amix=inputs=2:duration=first"\`)`, + `- Fade in BGM at start (2s) and fade out at end (3s)`, + ``, + ); + promptParts.push( `## CRITICAL: Horizontal-to-Vertical Video Conversion`, `The final output MUST be 9:16 vertical (1080x1920). If any source clip is horizontal (wider than tall):`, ``, @@ -1111,8 +1357,8 @@ apiRoutes.post("/api/works/:id/step/:step", async (c) => { } // Inject emotion-driven directives based on content category const emotionMap: Record = { - anxiety: "焦虑 (anxiety/crisis). Read modules/emotional-hooks.md and apply the 焦虑 emotion rules. For image-text, use one of the 3 mandatory routes (观点输出/对话截图/清单盘点).", - conflict: "愤怒 (conflict/debate). Read modules/emotional-hooks.md and apply the 愤怒 emotion rules. For image-text, use one of the 3 mandatory routes (观点输出/对话截图/清单盘点).", + anxiety: "深度共鸣 (resonance). Read modules/emotional-hooks.md and apply the 共鸣 emotion rules. For image-text, use one of the 3 mandatory routes (观点输出/对话截图/清单盘点).", + conflict: "观点分歧/争议感 (debate/controversy). Read modules/emotional-hooks.md and apply the 争议 emotion rules. For image-text, use one of the 3 mandatory routes (观点输出/对话截图/清单盘点).", comedy: "搞笑/抽象 (comedy/abstract). Read genres/comedy.md and apply its rules to this step.", envy: "羡慕 (aspiration/envy). Read modules/emotional-hooks.md and apply the 羡慕 emotion rules. For image-text, use one of the 3 mandatory routes (反差跃迁/关系羡慕/隐性阶层信号).", }; @@ -1122,51 +1368,82 @@ apiRoutes.post("/api/works/:id/step/:step", async (c) => { ``, `## IMPORTANT: Target emotion for this content is ${emotionDirective}`, ); - // Additional comedy-specific directives - if (work.contentCategory === "comedy") { - promptParts.push( - `## IMPORTANT: This is comedy/abstract content (搞笑/抽象类).`, - `You MUST read the genres/comedy.md file in the current step's skill directory and apply its rules.`, - ); - const comedyByStep: Record = { - research: [ - `For the research step, focus on:`, - `- Finding trending comedy/abstract topics, memes, and formats on the target platform`, - `- Analyzing what reversal types (经典反转/递进荒诞/错位/重复打破/平行对比/紧张崩塌/微观共鸣) are currently performing well`, - `- For abstract content: which "mismatch dimensions" (感官错配/过度认真/过度随意/语境位移/形式解构/真实解构/平行宇宙) are trending`, - `- Identifying comedy hooks and BGM trends`, - ].join("\n"), - plan: [ - `For the planning step, the script/storyboard MUST follow the comedy genre rules (see genres/comedy.md):`, - `- Choose a specific structure from the 7 comedy types or 7 abstract types in the skill`, - `- Design the Hook (first 3 seconds) using the Hook types table`, - `- Write dialogue following the comedy dialogue rules (短句为王, 口语化, 留白)`, - `- Plan BGM strategy (情绪铺垫反转 / 卡点强化 / 反差配乐 / 梗音乐)`, - `- Plan sound effects at key moments (反转点必须有声音标记)`, - `- For abstract content: define the two "mismatch dimensions" and ensure purity of each extreme`, - `- Run the 爆款自检清单 before finalizing`, - ].join("\n"), - assembly: [ - `For the assembly step, you handle BOTH asset generation AND editing/compositing.`, - ``, - `### Editing & Compositing:`, - `- Editing rhythm: normal during setup, sudden change at reversal point`, - `- BGM must have a sound marker at the reversal point (静音/音效/换曲)`, - `- Jump cuts for comedy, longer takes for abstract`, - `- Add sound effects precisely (急刹车 at reversals, 静音0.3-0.5s before twists)`, - `- For abstract: consider using silence instead of sound effects to maintain the "dead serious" tone`, - `- Volume: dialogue 100%, BGM 15-25% during speech, 40-60% during visual-only`, - ``, - `### Available Scripts (MUST USE, do NOT write inline code):`, - `- **BGM search**: Read \`modules/music-search.md\` for yt-dlp search/download workflow`, - `- **Beat detection**: \`python3 ~/.claude/skills/content-assembly/scripts/beat-sync/detect_beats.py bgm.mp3 -o beats.json\``, - `- **Beat-sync editing**: \`python3 ~/.claude/skills/content-assembly/scripts/beat-sync/beat_sync_edit.py --video source.mp4 --music bgm.mp3 --output final.mp4 --style dramatic\``, - `- Read \`modules/beat-sync.md\` for detailed usage of 3 styles (fast/smooth/dramatic)`, - ].join("\n"), - }; - const comedyDirective = comedyByStep[step]; - if (comedyDirective) promptParts.push(comedyDirective); - } + } + + // Category-specific title rules + const titleRules: Record = { + envy: [ + `## 羡慕类标题规则`, + `标题必须**简短**(一般≤15字),并且直接点明发布者令人羡慕的身份/特征。`, + `标题的作用是让读者一眼就知道"这个人拥有我想要的东西"。`, + ``, + `好的标题示例:`, + `- "哈佛本科生普通的周三"(身份:哈佛学生)`, + `- "北京三套房女生的日常"(资产:三套房)`, + `- "25岁年薪百万后的生活"(收入:年薪百万)`, + `- "和男朋友在巴黎的第3天"(关系+地点:甜蜜关系+巴黎)`, + `- "辞职后在大理的第100天"(生活方式:自由+大理)`, + ``, + `坏的标题示例(禁止):`, + `- "记录一下我很普通的生活"(没有点明令人羡慕的点)`, + `- "今天也是元气满满的一天!"(空洞,没有信息量)`, + `- "分享我的日常vlog"(太泛,没有差异化)`, + ].join("\n"), + anxiety: [ + `## 深度共鸣类标题规则`, + `标题必须**简短**(一般≤15字),直接点明发布者的身份/处境中令人共鸣的痛点。`, + `标题的作用是让读者一眼就觉得"这说的不就是我吗",产生强烈代入感。`, + ``, + `好的标题示例:`, + `- "我今年28,单身,没房,没车。"(处境:年龄+现状)`, + `- "月薪5000,在北京租房的第6年"(收入+城市+时间)`, + `- "考研二战失败后的第一天"(经历:考研失败)`, + `- "35岁被裁后,我妈说我活该"(年龄+事件+家庭关系)`, + `- "存款为0的我,刚查出甲状腺结节"(经济+健康)`, + ``, + `坏的标题示例(禁止):`, + `- "当代年轻人的压力有多大?"(新闻腔,不是第一人称)`, + `- "生活好难啊"(太笼统,没有具体信息)`, + `- "来聊聊你们的压力源"(互动征集,不是个人故事)`, + ].join("\n"), + conflict: [ + `## 观点分歧类标题规则`, + `标题必须**简短**(一般≤15字),直接点明发布者的身份/处境中引发争议的点。`, + `标题的作用是让读者一眼就产生"这说的对/不对"的站队冲动,忍不住点进来看。`, + ``, + `好的标题示例:`, + `- "我拒绝了月薪3万的offer"(反常行为引发好奇+争议)`, + `- "相亲对象AA制,我直接走了"(事件+态度,引发站队)`, + `- "我劝你别考公"(逆主流观点,引发反驳欲)`, + `- "婆婆住进来第3天,我搬走了"(关系冲突+行动)`, + `- "同事天天迟到,领导只骂我"(不公平处境,引发正义感和辩论欲)`, + ``, + `坏的标题示例(禁止):`, + `- "你们觉得AA制合理吗?"(提问式,不是个人故事)`, + `- "关于婆媳关系的一些看法"(议论文标题,没有冲突感)`, + `- "职场那些事儿"(太泛,没有具体矛盾点)`, + ].join("\n"), + comedy: [ + `## 搞笑类标题规则`, + `标题必须**简短**(一般≤15字),**绝对不能暴露笑点**。`, + `标题的作用是用一个引人代入的情绪或处境制造好奇心,让读者忍不住点进来,看到内容后才笑出来。`, + ``, + `好的标题示例:`, + `- "我妈今天的操作让我彻底崩溃了"(情绪引导+悬念,笑点在内容里)`, + `- "合租室友的脑回路我真的服了"(吐槽情绪+好奇,不知道具体发生了什么)`, + `- "相亲回来我整个人都不好了"(情绪+悬念,可能搞笑可能离谱)`, + `- "公司新来的同事第一天就干了这事"(悬念+好奇,不知道是什么事)`, + `- "我终于知道我单身的原因了"(自嘲情绪+悬念)`, + ``, + `坏的标题示例(禁止):`, + `- "我爸把猫剃成了光头哈哈哈"(笑点直接暴露了,没必要点进去看)`, + `- "搞笑!外卖小哥送错了三次"(直接标注"搞笑",缺少悬念)`, + `- "史上最离谱的翻车现场"(夸张空洞,没有代入感)`, + ].join("\n"), + }; + const cat = work.contentCategory as string; + if (titleRules[cat]) { + promptParts.push(``, titleRules[cat]); } // For image-text assets step: enforce correct asset acquisition method per category @@ -1174,30 +1451,61 @@ apiRoutes.post("/api/works/:id/step/:step", async (c) => { const assetMethod: Record = { envy: [ ``, - `## 严禁 AI 生图!严禁 ffmpeg 生成文字卡片!所有图片(包括封面)必须从网上搜索下载真实照片`, + `## 图片核心原则`, + ``, + `**内容要求:必须展示极少数人才能享有的生活**`, + `图片必须让观众产生强烈的"我也想要"的冲动。展示的生活方式**必须是极少数人才能享有的**,绝对不可以是普通人的日常。`, ``, - `"向往拥有/羡慕"类图文的**所有图片(封面图 + 内容图)**都必须是从网上搜索下载的真实照片。`, - `- ❌ 禁止使用 AI 生成图片`, + `**风格要求:绝对真实、日常、零 stock image 感**`, + `图片必须看起来像真人用手机随手拍的生活片段,而不是商业图库素材。`, + `- ✅ 构图随意、不完美,像顺手拿起手机拍一张`, + `- ✅ 自然光线,有真实环境的杂乱细节(桌上的水杯、背景里的路人)`, + `- ✅ 轻微的手抖、焦点偏移、过曝/欠曝都可以接受`, + `- ❌ 完美居中构图、纯净背景、均匀打光 = stock image,绝对不要`, + `- ❌ 模特摆拍姿势、刻意的微笑、眼神直视镜头`, + `- ❌ 图片水印、图库logo`, + ``, + `**可以展现的内容方向:**`, + `- 精致/富裕的生活方式:高端餐厅、私人泳池、海景别墅、头等舱、精品酒店`, + `- 极少数人才有的视角:CEO在会议室顶端俯瞰所有参会人员、私人飞机窗外的云海、游艇甲板上的日落`, + `- 恋爱中的甜蜜视角:精致礼物盒特写、伴侣牵手的第一人称视角、精致餐厅桌面对面是穿着考究的伴侣、被鲜花包围的早餐托盘`, + `- 隐性阶层信号:不直接展示奢侈品logo,但通过细节(空间、光线、质感)传达高品质生活`, + ``, + `**绝对禁止:**`, + `- ❌ 普通人的日常生活(普通公寓、快餐店、拥挤的公共交通)`, + `- ❌ 过于直白的炫富(堆砌奢侈品logo、晒存款截图)`, + `- ❌ 任何有 stock image 感的图片(构图太完美、光线太均匀、背景太干净)`, + ``, + `## 图片获取方式`, + ``, + `优先全网搜索真实照片,如果搜不到合适的可以用 AI 生图。`, + `- ✅ 搜图关键词要加"candid""real""iPhone""casual"等限定词,避免搜到图库图`, + ` 示例:"luxury restaurant candid iPhone photo"、"holding hands boyfriend pov real"、"CEO boardroom candid shot"`, + `- ✅ AI 生图提示词必须强调:candid snapshot, iPhone photo, natural lighting, slightly imperfect composition, real life moment`, `- ❌ 禁止使用 ffmpeg 生成文字卡片作为封面`, - `- ✅ 封面图也必须是真实照片——看似普通,但细节透露"中产以上层级"的照片`, ``, - `### 封面图要求`, - `通过细节(不是直接展示奢侈品)传达阶层信号:`, - `- 地点:某个特定区域/场所(独立咖啡馆、大落地窗客厅、安静的街区)`, - `- 时间:工作日白天在做某件悠闲的事(暗示不用上班)`, - `- 行为:不赶时间、从容不迫的状态`, - `照片风格:像 iPhone 随手拍的,自然光线,构图不能太精心,不能有摆拍痕迹。`, + `### 封面图要求(最重要!)`, + `封面首图决定了用户是否点击,必须在视觉上**极度震撼、壮丽、精致、吸引眼球**。`, + `- 画面必须有强烈的视觉美感:大气的构图、饱和的色彩、惊艳的光影`, + `- 适合封面的场景:绝美海景日落、高空俯瞰城市灯火、雪山星空、无边泳池倒映天空、巴黎屋顶的晨光、圣托里尼的蓝白教堂、满桌精致法餐的航拍视角`, + `- 封面图可以比内页图更"精致"——因为它的任务是吸引点击,而不是讲故事`, + `- 色彩要浓郁鲜明(金色夕阳、湛蓝海水、翠绿植被),不要灰暗沉闷的色调`, + `- 构图要有纵深感和层次感,避免平面化的随手拍`, + `- AI 生图提示词要强调:breathtaking, cinematic lighting, stunning colors, ultra high quality, magazine cover worthy`, + `- 搜图关键词要加:breathtaking, stunning, beautiful, dreamy, aesthetic`, + `- ❌ 禁止用普通的随手拍作为封面——内页可以日常,但封面必须惊艳`, ``, `### 图2-5 要求`, `每张图内容不同,但**风格、清晰度、色调、画风必须完全一致**,像同一部手机同一天拍的。`, ``, `### 执行步骤`, - `1. 从内容规划方案中提取每张图的搜图关键词(关键词要具体到场景细节)`, - `2. 所有搜图关键词加上统一的风格限定词(如"自然光 手机拍摄 日常 真实")`, - `3. 用 WebSearch 搜索对应的图片`, - `4. 用 curl 下载找到的图片,保存到作品的 assets/images/ 目录`, - `5. 下载后用 ffmpeg 统一调色(亮度/对比度/色温),消除不同来源的色差`, - `6. 如果某张图风格偏离太大,弃用重搜,不要强行调色凑数`, + `1. 从内容规划方案中提取每张图的关键词(要具体到场景细节,突出"极少数人才有"的特征)`, + `2. 所有关键词加上"candid real iPhone casual snapshot"等反stock限定词`, + `3. 优先用 WebSearch 搜索图片,筛选时严格排除任何stock感的结果`, + `4. 搜不到合适的就用 AI 生图脚本,提示词必须包含 candid/snapshot/iPhone 等关键词`, + `5. 用 curl 下载图片,保存到作品的 assets/images/ 目录`, + `6. 下载后用 ffmpeg 统一调色(亮度/对比度/色温),确保风格一致`, + `7. 最终检查:如果任何一张图看起来像图库素材(太完美、太干净),必须弃用重新获取`, ``, `参考 modules/emotional-hooks.md 中羡慕类的素材生成指令获取详细规则。`, ].join("\n"), @@ -1205,21 +1513,21 @@ apiRoutes.post("/api/works/:id/step/:step", async (c) => { ``, `## 图片生成方式`, ``, - `"危机感/焦虑"类图文:只有封面是文字卡片(用 ffmpeg 生成)。`, + `"深度共鸣"类图文:只有封面是文字卡片(用 ffmpeg 生成)。`, `**除封面外的其他图片禁止写文字观点!** 文字观点全部在文案正文里体现。`, `其余配图用与话题相关的真实照片(全网搜索下载)。`, `如果方案使用的是路线2(对话截图型),对话截图仅限封面,其余图用真实照片。`, - `参考 modules/emotional-hooks.md 中焦虑类的素材生成指令。`, + `参考 modules/emotional-hooks.md 中共鸣类的素材生成指令。`, ].join("\n"), conflict: [ ``, `## 图片生成方式`, ``, - `"观点分歧/愤怒"类图文:只有封面是文字卡片(用 ffmpeg 生成)。`, + `"观点分歧/争议感"类图文:只有封面是文字卡片(用 ffmpeg 生成)。`, `**除封面外的其他图片禁止写文字观点!** 文字观点全部在文案正文里体现。`, `其余配图用与话题相关的真实照片(全网搜索下载)。`, `如果方案使用的是路线2(对话截图型),对话截图仅限封面,其余图用真实照片。`, - `参考 modules/emotional-hooks.md 中焦虑类的素材生成指令。`, + `参考 modules/emotional-hooks.md 中共鸣类的素材生成指令。`, ].join("\n"), }; const method = assetMethod[work.contentCategory as string]; @@ -1243,337 +1551,25 @@ apiRoutes.post("/api/works/:id/step/:step", async (c) => { } }); -// ── Evaluation helpers ────────────────────────────────────────────────────── - -function broadcastPipelineUpdate(workId: string, pipeline: Record): void { - if (!wsBridge) return; - const session = wsBridge.getSession(workId); - if (!session) return; - for (const ws of session.browserSockets) { - if (ws.readyState === 1) { - ws.send(JSON.stringify({ - event: "pipeline_updated", - data: { workId, pipeline }, - timestamp: new Date().toISOString(), - })); - } - } -} - -async function waitForCreatorIdle(workId: string, timeoutMs = 120_000): Promise { - if (!wsBridge) return; - const session = wsBridge.getSession(workId); - if (!session) return; - - // If creator CLI is still running, wait for it to exit - if (session.cliProcess) { - log("info", "api", "eval_waiting_for_creator", workId, {}); - const start = Date.now(); - await new Promise((resolve) => { - const check = () => { - if (!session.cliProcess || Date.now() - start > timeoutMs) { - resolve(); - return; - } - setTimeout(check, 500); - }; - // Listen for the process exit directly if possible - if (session.cliProcess) { - session.cliProcess.once("exit", () => { - // Give a small delay for final messages to flush - setTimeout(resolve, 1000); - }); - // Fallback timeout - setTimeout(check, 500); - } else { - resolve(); - } - }); - log("info", "api", "eval_creator_idle", workId, { waitedMs: Date.now() - start }); - } -} - -async function runEvaluation(workId: string, completedStep: string, nextStep?: string): Promise { - if (!wsBridge) throw new Error("WsBridge not initialized"); - - // CRITICAL: Wait for creator agent's CLI process to finish before starting evaluator - // The creator calls pipeline/advance as a tool use during its turn — we must not - // start the evaluator until the creator's turn is fully complete to avoid interleaved output. - await waitForCreatorIdle(workId); - - const work = await getWork(workId); - if (!work) throw new Error("Work not found"); - - const session = wsBridge.ensureSession(workId); - session.evalStep = completedStep; - - const attempt = (work.evalAttempts?.[completedStep] ?? 0) + 1; - - // Load step history for context - const stepHistory = await loadStepHistory(workId, completedStep); - const historyText = (stepHistory as any)?.blocks - ?.filter((b: any) => b.type === "text") - .map((b: any) => b.text) - .join("\n\n") - .slice(0, 8000) ?? ""; - - // Load previous eval results - const prevResults = await loadAllEvalResults(workId, completedStep); - const prevResultsText = prevResults.length > 0 - ? prevResults.map(r => `第${r.attempt}轮评审: ${r.verdict}\n问题: ${r.issues.map(i => i.description).join("; ")}\n建议: ${r.suggestions.join("; ")}`).join("\n\n") - : ""; - - // Build evaluator prompt with work directory path - const workDir = join(dataDir, "works", workId); - const evalPrompt = buildEvalPrompt(work, completedStep, attempt, historyText, prevResultsText, workDir); - - // Broadcast eval_divider start - session.messageHistory.push({ - type: "eval_divider" as any, - text: `评审开始 (第${attempt}轮)`, - source: "evaluator", - timestamp: new Date().toISOString(), - }); - wsBridge.broadcastToBrowsers(workId, { - event: "eval_divider", - data: { type: "start", step: completedStep, attempt }, - }); - - // Always spawn a fresh evaluator session (no --resume) so it reads the latest files - // from disk without relying on cached file content from prior eval rounds. - try { - const evalResult = await wsBridge.spawnEvaluator(session, evalPrompt); - evalResult.step = completedStep; - evalResult.attempt = attempt; - evalResult.timestamp = new Date().toISOString(); - - // Save result - await saveEvalResult(workId, completedStep, attempt, evalResult); - - // Update attempts - const evalAttempts = { ...(work.evalAttempts ?? {}), [completedStep]: attempt }; - // Also persist evalSessionId - const evalSessionIds = { ...(work.evalSessionIds ?? {}), [completedStep]: session.evalSessionId ?? "" }; - await storeUpdateWork(workId, { evalAttempts, evalSessionIds } as any); - - if (evalResult.verdict === "pass") { - // PASS — advance pipeline - session.messageHistory.push({ - type: "eval_divider" as any, - text: "评审通过 ✓", - source: "evaluator", - timestamp: new Date().toISOString(), - }); - wsBridge.broadcastToBrowsers(workId, { - event: "eval_divider", - data: { type: "end", step: completedStep, verdict: "pass", scores: evalResult.scores }, - }); - - // Clean up eval session for this step - const cleanedEvalSessionIds = { ...evalSessionIds }; - delete cleanedEvalSessionIds[completedStep]; - - const freshWork = await getWork(workId); - if (freshWork) { - freshWork.pipeline[completedStep].status = "done"; - freshWork.pipeline[completedStep].completedAt = new Date().toISOString(); - if (nextStep && freshWork.pipeline[nextStep]) { - freshWork.pipeline[nextStep].status = "active"; - freshWork.pipeline[nextStep].startedAt = new Date().toISOString(); - } - await storeUpdateWork(workId, { - pipeline: freshWork.pipeline, - evalSessionIds: cleanedEvalSessionIds, - evalAttempts: { ...(freshWork.evalAttempts ?? {}), [completedStep]: 0 }, - } as any); - broadcastPipelineUpdate(workId, freshWork.pipeline); - } - - // Persist chat - saveWorkChat(workId, { blocks: session.messageHistory }).catch(() => {}); - - // Auto-resume creator agent to continue with next step - if (nextStep) { - const continuePrompt = `评审已通过,pipeline 已自动推进到「${freshWork?.pipeline[nextStep]?.name ?? nextStep}」阶段。请继续执行该阶段的工作。`; - await wsBridge.sendMessage(workId, continuePrompt); - } - } else { - // FAIL — send feedback to creator agent - session.messageHistory.push({ - type: "eval_divider" as any, - text: `评审未通过 ✗ (${evalResult.issues.length}个问题)`, - source: "evaluator", - timestamp: new Date().toISOString(), - }); - wsBridge.broadcastToBrowsers(workId, { - event: "eval_divider", - data: { type: "end", step: completedStep, verdict: "fail", scores: evalResult.scores, issues: evalResult.issues }, - }); - - // Check iteration limit - if (attempt >= 3) { - const freshWork = await getWork(workId); - if (freshWork) { - freshWork.pipeline[completedStep].status = "eval_blocked" as any; - await storeUpdateWork(workId, { pipeline: freshWork.pipeline }); - broadcastPipelineUpdate(workId, freshWork.pipeline); - } - wsBridge.broadcastToBrowsers(workId, { - event: "eval_blocked", - data: { workId, step: completedStep, attempt, result: evalResult }, - }); - saveWorkChat(workId, { blocks: session.messageHistory }).catch(() => {}); - return; - } - - // Set step back to active - const freshWork = await getWork(workId); - if (freshWork) { - freshWork.pipeline[completedStep].status = "active"; - await storeUpdateWork(workId, { pipeline: freshWork.pipeline }); - broadcastPipelineUpdate(workId, freshWork.pipeline); - } - - // Inject feedback into creator agent via resume - const feedbackPrompt = buildFeedbackPrompt(evalResult, attempt); - await wsBridge.sendMessage(workId, feedbackPrompt); - - // Persist chat - saveWorkChat(workId, { blocks: session.messageHistory }).catch(() => {}); - } - } catch (err) { - log("error", "api", "eval_error", workId, { error: (err as Error).message }); - // On evaluator failure, revert to active - const freshWork = await getWork(workId); - if (freshWork) { - freshWork.pipeline[completedStep].status = "active"; - await storeUpdateWork(workId, { pipeline: freshWork.pipeline }); - broadcastPipelineUpdate(workId, freshWork.pipeline); - } - } -} - -function buildFeedbackPrompt(evalResult: EvalResult, attempt: number): string { - const issueList = evalResult.issues - .map((i, idx) => `${idx + 1}. [${i.severity}] ${i.description}${i.file ? ` (文件: ${i.file})` : ""}`) - .join("\n"); - const suggestionList = evalResult.suggestions - .map((s, idx) => `${idx + 1}. ${s}`) - .join("\n"); - - return `## 评审反馈 (第${attempt}轮) - -评审未通过,请根据以下反馈修复问题后重新提交: - -### 问题列表 -${issueList} - -### 修改建议 -${suggestionList} - -请修复以上问题,修复完成后再次调用 pipeline/advance 提交评审。`; -} - -function buildEvalPrompt(work: Work, step: string, attempt: number, historyText: string, prevResultsText: string, workDir: string): string { - const stepName = work.pipeline[step]?.name ?? step; - const platforms = work.platforms?.join(", ") ?? "未指定"; - - return `你是一位严格的内容质量评审专家。你的任务是审查「${work.title}」的「${stepName}」阶段产出。 - -## 你的角色 -- 你是独立的评审者,不是创作者。你的职责是发现问题,而不是赞美。 -- AI 存在"自我评价偏差"——倾向于赞美自己的产出。你必须刻意克服这种倾向。 -- 使用硬性阈值,不要模糊通过。任何维度低于 6/10 分必须打回。 - -## 作品信息 -- 标题: ${work.title} -- 类型: ${work.type} -- 平台: ${platforms} -- 当前阶段: ${stepName} -- 评审轮次: 第${attempt}轮 -- **作品目录: ${workDir}** - -## 评审标准 -请阅读 skills/content-evaluator/criteria/${step}.md 获取该阶段的详细评审标准。如果文件不存在,请使用通用的内容质量标准进行评审。 - -## 创作产出摘要 -${historyText.slice(0, 6000) || "(无文本产出记录)"} - -## 评审指令 - -**重要:你必须从磁盘重新读取文件的最新内容。不要依赖之前会话中缓存的文件内容。创作者可能已经修改了文件。** - -1. 使用 Read 工具从 ${workDir} 目录读取实际文件(必须重新读取,不要使用缓存) -2. 对于图片文件:使用 Read 工具查看图片,评估视觉质量 -3. 对于视频文件:使用 ffprobe 检查技术参数(分辨率、时长、编码、音频轨) -4. 根据评审标准逐项评分 -5. 输出结构化评审结果 - -常用文件路径: -- 调研报告: ${workDir}/research/report.md -- 内容方案: ${workDir}/plan/plan.md -- 图片素材: ${workDir}/assets/images/ -- 视频素材: ${workDir}/assets/clips/ -- 最终输出: ${workDir}/output/ - -${prevResultsText ? `## 历史评审记录\n${prevResultsText}\n\n请特别关注之前指出的问题是否已修复。**必须重新读取文件确认修复,不要依赖之前的缓存内容。**` : ""} - -## 输出格式(必须严格遵循) - -在你的分析之后,输出以下 JSON 代码块: - -\`\`\`json -{ - "verdict": "pass" 或 "fail", - "scores": { - "维度1": 1-10, - "维度2": 1-10 - }, - "issues": [ - {"severity": "critical/major/minor", "description": "问题描述", "file": "相关文件路径(可选)"} - ], - "suggestions": ["修改建议1", "修改建议2"] -} -\`\`\` - -规则: -- 任何 critical 问题 → 必须 fail -- 任何维度 < 6/10 → 必须 fail -- 所有维度 ≥ 7/10 且无 critical 问题 → pass`; -} - // POST /api/works/:id/pipeline/advance — agent calls this to advance pipeline apiRoutes.post("/api/works/:id/pipeline/advance", async (c) => { const id = c.req.param("id"); try { - const body = await c.req.json<{ completedStep: string; nextStep?: string }>().catch(() => ({} as any)); - log("info", "api", "pipeline_advance", id, { completedStep: body.completedStep, nextStep: body.nextStep }); + const body = await c.req.json<{ completedStep: string; nextStep?: string; title?: string }>().catch(() => ({} as any)); + log("info", "api", "pipeline_advance", id, { completedStep: body.completedStep, nextStep: body.nextStep, title: body.title }); const work = await getWork(id); if (!work) return c.json({ error: "Work not found" }, 404); const { completedStep, nextStep } = body; if (!completedStep) return c.json({ error: "completedStep is required" }, 400); - // ── Evaluation gate ───────────────────────────────────────────────── - if (work.evaluationMode && work.pipeline[completedStep]?.status !== "evaluating") { - work.pipeline[completedStep].status = "evaluating" as any; - await storeUpdateWork(id, { pipeline: work.pipeline }); - broadcastPipelineUpdate(id, work.pipeline); - - // Start evaluation asynchronously (don't await — return immediately) - runEvaluation(id, completedStep, nextStep).catch((err) => { - log("error", "api", "eval_failed", id, { error: (err as Error).message }); - }); - - return c.json({ ok: true, evaluating: true, pipeline: work.pipeline }); - } - - // ── Normal advance (eval off or already passed) ───────────────────── + // Mark completed step as done if (work.pipeline[completedStep]) { work.pipeline[completedStep].status = "done"; work.pipeline[completedStep].completedAt = new Date().toISOString(); } + // Also mark all steps before completedStep as done (in case agent skipped) const stepKeys = Object.keys(work.pipeline); const completedIdx = stepKeys.indexOf(completedStep); if (completedIdx > 0) { @@ -1586,29 +1582,141 @@ apiRoutes.post("/api/works/:id/pipeline/advance", async (c) => { } } + // Mark next step as active if provided if (nextStep && work.pipeline[nextStep]) { work.pipeline[nextStep].status = "active"; work.pipeline[nextStep].startedAt = new Date().toISOString(); } - await storeUpdateWork(id, { pipeline: work.pipeline }); + // Update title if agent provided one (only once — skip if already locked) + const titleUpdate: Partial = { pipeline: work.pipeline }; + if (body.title && typeof body.title === "string" && !work.titleLocked) { + const trimmedTitle = body.title.trim(); + titleUpdate.title = trimmedTitle; + titleUpdate.titleLocked = true; + work.title = trimmedTitle; + } + + await storeUpdateWork(id, titleUpdate); - // Memory sync (keep existing logic) + // Sync conversation to EverMemOS (fire and forget) if (completedStep) { loadStepHistory(id, completedStep).then(history => { const h = history as { blocks?: { type: string; text: string }[] } | null; if (h?.blocks) { getWork(id).then(w => { syncStepConversation( - id, w?.title ?? "Untitled", completedStep, - w?.pipeline?.[completedStep]?.name ?? completedStep, h.blocks!, - ).catch(() => {}); - }).catch(() => {}); + id, + w?.title ?? "Untitled", + completedStep, + w?.pipeline?.[completedStep]?.name ?? completedStep, + h.blocks!, + ).catch(() => {}) + }).catch(() => {}) } - }).catch(() => {}); + }).catch(() => {}) + } + + // Broadcast pipeline update to browsers via WsBridge + if (wsBridge) { + const session = wsBridge.getSession(id); + if (session) { + for (const ws of session.browserSockets) { + if (ws.readyState === 1) { + ws.send(JSON.stringify({ + event: "pipeline_updated", + data: { workId: id, pipeline: work.pipeline, title: work.title }, + timestamp: new Date().toISOString(), + })); + } + } + } + } + + // ── Evaluator trigger ────────────────────────────────────────────── + const evalEnabled = work.evaluationMode ?? true; // default on + if (evalEnabled && wsBridge && completedStep) { + // Don't eval the last step (assembly) — it has its own final eval + const isLastStep = !nextStep; + if (!isLastStep) { + const session = wsBridge.getSession(id); + if (session) { + // Set step to evaluating + work.pipeline[completedStep].status = "evaluating"; + // Revert next step to pending during eval + if (nextStep && work.pipeline[nextStep]) { + work.pipeline[nextStep].status = "pending"; + } + await storeUpdateWork(id, { pipeline: work.pipeline }); + + // Broadcast evaluating status + wsBridge.broadcastToBrowsers(id, { + event: "pipeline_updated", + data: { workId: id, pipeline: work.pipeline, title: work.title }, + }); + + // Add eval divider to chat + wsBridge.broadcastToBrowsers(id, { + event: "eval_start", + data: { workId: id, step: completedStep }, + }); + + session.evalStep = completedStep; + + // Build eval prompt + const evalPrompt = await buildEvalPrompt(work, completedStep); + + // Spawn evaluator asynchronously (don't block response) + wsBridge.spawnEvaluator(session, evalPrompt) + .then(async (evalResult) => { + evalResult.step = completedStep; + evalResult.timestamp = new Date().toISOString(); + await saveEvalResult(id, completedStep, evalResult); + + const freshWork = await getWork(id); + if (!freshWork) return; + + if (evalResult.verdict === "pass") { + freshWork.pipeline[completedStep].status = "done"; + if (nextStep && freshWork.pipeline[nextStep]) { + freshWork.pipeline[nextStep].status = "active"; + freshWork.pipeline[nextStep].startedAt = new Date().toISOString(); + } + } else { + freshWork.pipeline[completedStep].status = "eval_blocked"; + } + await storeUpdateWork(id, { pipeline: freshWork.pipeline }); + + wsBridge?.broadcastToBrowsers(id, { + event: "eval_complete", + data: { workId: id, step: completedStep, result: evalResult }, + }); + wsBridge?.broadcastToBrowsers(id, { + event: "pipeline_updated", + data: { workId: id, pipeline: freshWork.pipeline, title: freshWork.title }, + }); + }) + .catch(async () => { + // Eval failed — fall through to pass + const freshWork = await getWork(id); + if (!freshWork) return; + freshWork.pipeline[completedStep].status = "done"; + if (nextStep && freshWork.pipeline[nextStep]) { + freshWork.pipeline[nextStep].status = "active"; + freshWork.pipeline[nextStep].startedAt = new Date().toISOString(); + } + await storeUpdateWork(id, { pipeline: freshWork.pipeline }); + wsBridge?.broadcastToBrowsers(id, { + event: "pipeline_updated", + data: { workId: id, pipeline: freshWork.pipeline }, + }); + }); + + return c.json({ ok: true, pipeline: work.pipeline, evaluating: true }); + } + } } - broadcastPipelineUpdate(id, work.pipeline); return c.json({ ok: true, pipeline: work.pipeline }); } catch (err) { return c.json({ error: err instanceof Error ? err.message : "Pipeline advance error" }, 500); @@ -1616,29 +1724,73 @@ apiRoutes.post("/api/works/:id/pipeline/advance", async (c) => { }); // --------------------------------------------------------------------------- -// Evaluation API endpoints +// Evaluator prompt builder +// --------------------------------------------------------------------------- + +async function buildEvalPrompt(work: { id: string; type: string; pipeline: Record }, completedStep: string): Promise { + const skillDir = join(process.cwd(), "skills", "content-evaluator"); + let skillMd = ""; + try { skillMd = await readFile(join(skillDir, "SKILL.md"), "utf-8"); } catch { /* missing */ } + + const criteriaMap: Record = { + research: "research.md", + plan: "plan.md", + assets: "assets.md", + assembly: "assembly.md", + }; + let criteriaMd = ""; + const criteriaFile = criteriaMap[completedStep]; + if (criteriaFile) { + try { criteriaMd = await readFile(join(skillDir, "criteria", criteriaFile), "utf-8"); } catch { /* missing */ } + } + + const workDir = join(dataDir, "works", work.id); + + return `${skillMd} + +## 本次评审任务 + +评审阶段:${completedStep}(${work.pipeline[completedStep]?.name ?? completedStep}) +作品ID:${work.id} +作品类型:${work.type} +作品目录:${workDir} + +### 阶段评审标准 +${criteriaMd} + +请按照评审流程,检查 ${workDir} 下的产出文件,逐维度评分,最后输出结构化 JSON 评审结果。`; +} + +// --------------------------------------------------------------------------- +// Evaluation API routes // --------------------------------------------------------------------------- -// POST /api/works/:id/eval/toggle +// POST /api/works/:id/eval/toggle — toggle evaluation mode apiRoutes.post("/api/works/:id/eval/toggle", async (c) => { const id = c.req.param("id"); const work = await getWork(id); if (!work) return c.json({ error: "Work not found" }, 404); - const newMode = !(work.evaluationMode ?? false); + const newMode = !(work.evaluationMode ?? true); await storeUpdateWork(id, { evaluationMode: newMode } as any); - return c.json({ ok: true, evaluationMode: newMode }); + return c.json({ evaluationMode: newMode }); }); -// POST /api/works/:id/eval/force-pass +// POST /api/works/:id/eval/force-pass — force pass a blocked eval apiRoutes.post("/api/works/:id/eval/force-pass", async (c) => { const id = c.req.param("id"); - const body = await c.req.json<{ step: string; nextStep?: string }>().catch(() => ({} as any)); + const { step, nextStep } = await c.req.json<{ step: string; nextStep?: string }>(); const work = await getWork(id); if (!work) return c.json({ error: "Work not found" }, 404); - const { step, nextStep } = body; - if (!step || !["eval_blocked", "evaluating"].includes(work.pipeline[step]?.status as string)) { - return c.json({ error: "Step not in eval_blocked/evaluating state" }, 400); + + // Kill running evaluator + if (wsBridge) { + const session = wsBridge.getSession(id); + if (session?.evalProcess) { + try { session.evalProcess.kill("SIGTERM"); } catch { /* already dead */ } + session.evalProcess = undefined; + } } + work.pipeline[step].status = "done"; work.pipeline[step].completedAt = new Date().toISOString(); if (nextStep && work.pipeline[nextStep]) { @@ -1646,33 +1798,46 @@ apiRoutes.post("/api/works/:id/eval/force-pass", async (c) => { work.pipeline[nextStep].startedAt = new Date().toISOString(); } await storeUpdateWork(id, { pipeline: work.pipeline }); - broadcastPipelineUpdate(id, work.pipeline); - return c.json({ ok: true, pipeline: work.pipeline }); + + if (wsBridge) { + wsBridge.broadcastToBrowsers(id, { + event: "pipeline_updated", + data: { workId: id, pipeline: work.pipeline }, + }); + } + + return c.json({ pipeline: work.pipeline }); }); -// POST /api/works/:id/eval/retry +// POST /api/works/:id/eval/retry — retry step with guidance apiRoutes.post("/api/works/:id/eval/retry", async (c) => { const id = c.req.param("id"); - const body = await c.req.json<{ step: string; guidance: string }>().catch(() => ({} as any)); + const { step, guidance } = await c.req.json<{ step: string; guidance: string }>(); const work = await getWork(id); if (!work) return c.json({ error: "Work not found" }, 404); - const { step, guidance } = body; - if (!step) return c.json({ error: "step required" }, 400); + work.pipeline[step].status = "active"; - const evalAttempts = { ...(work.evalAttempts ?? {}), [step]: 0 }; - await storeUpdateWork(id, { pipeline: work.pipeline, evalAttempts } as any); - broadcastPipelineUpdate(id, work.pipeline); - if (wsBridge && guidance) { - await wsBridge.sendMessage(id, `## 用户指导\n\n${guidance}\n\n请根据以上指导修改当前阶段的产出,完成后重新提交。`); + work.pipeline[step].startedAt = new Date().toISOString(); + delete work.pipeline[step].completedAt; + await storeUpdateWork(id, { pipeline: work.pipeline }); + + if (wsBridge) { + const guidanceMsg = `评审反馈要求重做此步骤。请根据以下评审意见修改:\n\n${guidance}`; + await wsBridge.sendMessage(id, guidanceMsg); + wsBridge.broadcastToBrowsers(id, { + event: "pipeline_updated", + data: { workId: id, pipeline: work.pipeline }, + }); } + return c.json({ ok: true }); }); -// GET /api/works/:id/eval/results/:step +// GET /api/works/:id/eval/results/:step — fetch eval results apiRoutes.get("/api/works/:id/eval/results/:step", async (c) => { const id = c.req.param("id"); const step = c.req.param("step"); - const results = await loadAllEvalResults(id, step); + const results = await loadEvalResults(id, step); return c.json({ results }); }); diff --git a/src/work-store.ts b/src/work-store.ts index d8f2b33e..89ea46fd 100644 --- a/src/work-store.ts +++ b/src/work-store.ts @@ -13,13 +13,29 @@ export type WorkStatus = "draft" | "creating" | "ready" | "failed"; export interface PipelineStep { name: string; - status: "pending" | "active" | "evaluating" | "done" | "skipped" | "eval_blocked"; + status: "pending" | "active" | "done" | "skipped" | "evaluating" | "eval_blocked"; startedAt?: string; completedAt?: string; note?: string; } -export type ContentCategory = "info" | "beauty" | "comedy"; +export interface EvalIssue { + severity: "critical" | "major" | "minor"; + description: string; + file?: string; +} + +export interface EvalResult { + step: string; + attempt: number; + verdict: "pass" | "fail"; + scores: Record; + issues: EvalIssue[]; + suggestions: string[]; + timestamp: string; +} + +export type ContentCategory = "info" | "beauty" | "comedy" | "anxiety" | "conflict" | "envy"; export type VideoSource = "upload" | "search" | "ai-generate"; export interface Work { @@ -35,9 +51,10 @@ export interface Work { cliSessionId?: string; coverImage?: string; topicHint?: string; + titleLocked?: boolean; + language?: "en" | "zh"; evaluationMode?: boolean; - evalSessionIds?: Record; - evalAttempts?: Record; + usePortrait?: boolean; createdAt: string; updatedAt: string; } @@ -106,7 +123,43 @@ function outputDir(id: string): string { async function readWorkFile(id: string): Promise { try { const raw = await readFile(workFilePath(id), "utf-8"); - return yaml.load(raw) as Work; + const work = yaml.load(raw) as Work; + // Repair pipeline: ensure all expected steps exist (fixes previously corrupted data) + if (work && work.pipeline && work.type) { + const template = defaultPipeline(work.type, work.videoSource); + let repaired = false; + for (const [key, step] of Object.entries(template)) { + if (!work.pipeline[key]) { + // Missing step — add it as done if later steps exist and are done, otherwise pending + const templateKeys = Object.keys(template); + const missingIdx = templateKeys.indexOf(key); + const laterSteps = templateKeys.slice(missingIdx + 1); + const hasLaterDone = laterSteps.some(k => work.pipeline[k]?.status === "done"); + work.pipeline[key] = { ...step, status: hasLaterDone ? "done" : "pending" }; + repaired = true; + } else if (!work.pipeline[key].name && step.name) { + // Existing step missing name — restore from template + work.pipeline[key].name = step.name; + repaired = true; + } + } + if (repaired) { + // Reorder pipeline to match template order + const templateKeys = Object.keys(template); + const ordered: Record = {}; + for (const key of templateKeys) { + if (work.pipeline[key]) ordered[key] = work.pipeline[key]; + } + // Keep any extra steps not in template + for (const [key, step] of Object.entries(work.pipeline)) { + if (!ordered[key]) ordered[key] = step; + } + work.pipeline = ordered; + // Persist the repair + writeWorkFile(work).catch(() => {}); + } + } + return work; } catch { return undefined; } @@ -135,7 +188,7 @@ function defaultPipeline(type: WorkType, videoSource?: VideoSource): Record> = { - "short-video": { research: "话题调研", plan: "分镜规划", assets: "素材准备", assembly: "视频合成" }, + "short-video": { research: "话题调研", plan: "分镜规划", assembly: "视频合成" }, "image-text": { research: "话题调研", plan: "内容规划", assets: "图片生成", assembly: "图文排版" }, }; for (const [key, name] of Object.entries(names[type])) { @@ -172,6 +225,7 @@ export async function createWork(input: { videoSearchQuery?: string; platforms: string[]; topicHint?: string; + language?: "en" | "zh"; }): Promise { const now = new Date().toISOString(); const id = generateId(); @@ -186,6 +240,7 @@ export async function createWork(input: { platforms: input.platforms, pipeline: defaultPipeline(input.type, input.videoSource as VideoSource | undefined), topicHint: input.topicHint, + language: input.language, createdAt: now, updatedAt: now, }; @@ -213,7 +268,18 @@ export async function updateWork(id: string, updates: Partial): Promise { await walk(join(baseDir, "assets")); await walk(join(baseDir, "output")); + await walk(join(baseDir, "output_en")); + + // Also pick up media files in the work root directory (agent sometimes puts final.mp4 here) + const mediaExts = new Set([".mp4", ".mov", ".mp3", ".wav", ".png", ".jpg", ".jpeg", ".webp", ".gif"]); + try { + const rootEntries = await readdir(baseDir, { withFileTypes: true }); + for (const entry of rootEntries) { + if (!entry.isFile()) continue; + const ext = entry.name.substring(entry.name.lastIndexOf(".")).toLowerCase(); + if (mediaExts.has(ext)) { + results.push(entry.name); + } + } + } catch { /* ignore */ } return results; } @@ -311,41 +391,28 @@ export async function loadWorkChat(id: string): Promise { } } -// ── Evaluation results ────────────────────────────────────────────────────── - -export interface EvalResult { - step: string; - attempt: number; - verdict: "pass" | "fail"; - scores: Record; - issues: Array<{ severity: "critical" | "major" | "minor"; description: string; file?: string }>; - suggestions: string[]; - timestamp: string; -} +// ── Eval result persistence ───────────────────────────────────────────────── -export async function saveEvalResult(id: string, step: string, attempt: number, result: EvalResult): Promise { - const dir = workDir(id); - await mkdir(dir, { recursive: true }); - const filePath = join(dir, `eval-${step}-${attempt}.json`); - await writeFile(filePath, JSON.stringify(result, null, 2), "utf-8"); -} - -export async function loadEvalResult(id: string, step: string, attempt: number): Promise { +/** Save an eval result for a pipeline step (appended to history). */ +export async function saveEvalResult(id: string, stepKey: string, result: EvalResult): Promise { + const evalDir = join(workDir(id), "eval"); + await mkdir(evalDir, { recursive: true }); + const filePath = join(evalDir, `${stepKey}.json`); + let results: EvalResult[] = []; try { - const filePath = join(workDir(id), `eval-${step}-${attempt}.json`); const raw = await readFile(filePath, "utf-8"); - return JSON.parse(raw) as EvalResult; - } catch { - return null; - } + results = JSON.parse(raw); + } catch { /* no previous results */ } + results.push(result); + await writeFile(filePath, JSON.stringify(results, null, 2), "utf-8"); } -export async function loadAllEvalResults(id: string, step: string): Promise { - const results: EvalResult[] = []; - for (let i = 1; i <= 10; i++) { - const r = await loadEvalResult(id, step, i); - if (r) results.push(r); - else break; +/** Load all eval results for a pipeline step. */ +export async function loadEvalResults(id: string, stepKey: string): Promise { + try { + const raw = await readFile(join(workDir(id), "eval", `${stepKey}.json`), "utf-8"); + return JSON.parse(raw); + } catch { + return []; } - return results; } diff --git a/src/ws-bridge.ts b/src/ws-bridge.ts index fc025108..b88ff70e 100644 --- a/src/ws-bridge.ts +++ b/src/ws-bridge.ts @@ -178,12 +178,16 @@ export class WsBridge { ## 你的能力 - 调研:使用WebSearch搜索 + 数据获取脚本(详见 trend-research skill) -- 生图:脚本工具 python3 ~/.claude/skills/asset-generation/scripts/openrouter_generate.py 或 jimeng_generate.py(详见 asset-generation skill) -- 生视频:调用 curl http://localhost:${port}/api/generate/video 或使用即梦脚本 -- 合成:使用ffmpeg命令剪辑视频(拼接片段+字幕+配乐+转场) +- 生图:**首选 Dreamina CLI**:\`dreamina text2image --prompt="描述" --ratio=9:16 --resolution_type=2k --poll=120\`,备选:python3 scripts/openrouter_generate.py +- 生视频:**必须使用 Dreamina CLI**:\`dreamina image2video --image=首帧图.png --prompt="运动描述" --model_version=seedance2.0 --duration=5 --poll=180\`。详见 \`modules/dreamina-mastery.md\` +- 生配乐:python3 skills/asset-generation/scripts/music_generate.py --prompt "音乐描述" --output bgm.mp3(Google Lyria AI 原创配乐) +- 生旁白:edge-tts --text "旁白文本" --voice zh-CN-YunxiNeural --write-media narration.mp3 +- 合成:使用ffmpeg命令剪辑视频(拼接片段+字幕+旁白+配乐+转场) - 公共素材:通过 curl http://localhost:${port}/api/shared-assets 查看可用素材 - 流水线管理:调用 curl -X POST http://localhost:${port}/api/works/${work.id}/pipeline/advance 更新流水线状态 +**重要:视频生成必须使用 dreamina CLI(已安装并登录,积分充足),禁止使用 Jimeng API key 或 curl 调用服务器 API。** 遇到 Jimeng API 报错时不要降级到 ffmpeg Ken Burns,直接用 dreamina CLI。 + ## 可用数据源 在创作过程中,你可以按需访问以下数据(请求失败则跳过,不阻断流程): @@ -198,6 +202,8 @@ export class WsBridge { **重要:你必须主动管理流水线状态。** 每次回答用户之前,根据对话上下文判断当前阶段是否已经完成、是否需要推进到下一步。 - 当你判断当前阶段的工作已经完成(例如调研报告已输出、规划方案已确认),**立即调用** pipeline/advance API 更新状态: curl -X POST http://localhost:${port}/api/works/${work.id}/pipeline/advance -H "Content-Type: application/json" -d '{"completedStep":"当前步骤key","nextStep":"下一步骤key"}' +- 当你为作品确定了标题(例如在规划阶段生成了发布标题),在调用 pipeline/advance 时加上 title 字段来更新作品标题: + curl -X POST http://localhost:${port}/api/works/${work.id}/pipeline/advance -H "Content-Type: application/json" -d '{"completedStep":"plan","nextStep":"assembly","title":"你生成的标题"}' - 当用户明确要求进入下一阶段时,同样调用此API。 - 不要等用户来点按钮,你自己判断并更新。 - 不要在工作未完成时提前推进。 @@ -208,7 +214,22 @@ ${workspacePath} ## 公共素材库 ${sharedAssetsInfo} -## 记忆上下文(如有) +${work.usePortrait ? `## 画像关联(已开启) + +**重要:用户已开启「关联你的画像」功能。** 你在生成所有视觉素材时,必须使用用户画像中的参考图来确保一致性: + +### 强制规则 +1. **人脸一致性**:所有需要出现人物的素材,必须使用 characters 分类下的参考图作为 reference image。生成图片时传入 --ref-image 参数,确保人脸与参考图一致。 +2. **场景参照**:如果 scenes 分类下有参考图(如家里、健身房、办公室),生成对应场景时必须参照这些图片的风格、色调、布局。 +3. **品牌调性**:如果 branding 分类下有参考图,所有素材的视觉风格(色调、字体感、质感)必须与之保持一致。 +4. **整体风格**:所有生成的图片/视频帧之间必须保持风格统一,就像同一个人在同一天拍的。 + +### 具体操作 +- 生成图片前,先用 \`curl http://localhost:${port}/api/shared-assets\` 获取画像文件列表 +- 将 characters 分类下的图片 URL(\`http://localhost:${port}/api/shared-assets/characters/\`)作为 reference image 传入生成脚本 +- 在 prompt 中明确描述参考图中人物的外貌特征(性别、体型、发型、肤色等) +- 每次生成都要传入参考图,不能只在第一张传入 +` : ''}## 记忆上下文(如有) ${memoryContext} ## 规则 @@ -695,6 +716,28 @@ ${memoryContext} if (!session.workId.startsWith("trends_")) { saveWorkChat(session.workId, { blocks: session.messageHistory }).catch(() => {}); } + // Auto-extract title from plan step output (only once — skip if already locked) + if (!session.workId.startsWith("trends_") && resultText) { + getWork(session.workId).then(w => { + if (!w || w.titleLocked) return; + const activeStep = Object.entries(w.pipeline).find(([, s]) => s.status === "active"); + if (activeStep && activeStep[0] === "plan") { + // Look for title patterns like **标题**:xxx or **标题**: xxx + const titleMatch = resultText.match(/\*{0,2}标题\*{0,2}\s*[::]\s*(.+)/); + if (titleMatch) { + const newTitle = titleMatch[1].replace(/\*+/g, "").trim(); + if (newTitle && newTitle !== w.title) { + updateWork(session.workId, { title: newTitle, titleLocked: true }).then(() => { + this.broadcastToBrowsers(session.workId, { + event: "title_updated", + data: { workId: session.workId, title: newTitle }, + }); + }).catch(() => {}); + } + } + } + }).catch(() => {}); + } // Auto-save step history from backend (doesn't rely on frontend) // Only save the NEW messages from this turn (not entire history) if (!session.workId.startsWith("trends_") && resultText) { diff --git a/web/src/App.svelte b/web/src/App.svelte index af5d1a97..3cf5018b 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -5,6 +5,7 @@ import Studio from "./pages/Studio.svelte"; import Works from "./pages/Works.svelte"; import NewWorkModal from "./components/NewWorkModal.svelte"; + import AssetLibrary from "./components/AssetLibrary.svelte"; import { fetchConfig, updateConfig, fetchWorks, createWorkApi, type WorkSummary, type ContentCategory } from "./lib/api"; import { t, getLanguage, setLanguage, subscribe } from "./lib/i18n"; @@ -50,17 +51,29 @@ let initialPrompt = $state(""); - function buildInitialPrompt(data: { title: string; type: string; contentCategory: string; videoSource: string; videoSearchQuery: string; topicHint: string }): string { - const categoryMap: Record = { - anxiety: "危机感/焦虑", - conflict: "观点分歧/愤怒", - comedy: "搞笑抽象", - envy: "向往拥有/羡慕", - }; - const typeMap: Record = { - "short-video": "短视频", - "image-text": "图文", - }; + function buildInitialPrompt(data: { title: string; type: string; contentCategory: string; videoSource: string; videoSearchQuery: string; imageSource: string; imageSearchQuery: string; topicHint: string }): string { + const isEn = lang === "en"; + if (isEn) { + const categoryMap: Record = { anxiety: "Resonance", conflict: "Debate/Anger", comedy: "Comedy/Absurd", envy: "Aspiration/Envy" }; + const typeMap: Record = { "short-video": "Short Video", "image-text": "Image & Text" }; + const parts: string[] = []; + parts.push(`Start creating.`); + parts.push(`Format: ${typeMap[data.type] ?? data.type}`); + parts.push(`Emotion category: ${categoryMap[data.contentCategory] ?? data.contentCategory}`); + if (data.title) parts.push(`Title: ${data.title}`); + if (data.topicHint) parts.push(`Direction: ${data.topicHint}`); + if (data.videoSource === "search" && data.videoSearchQuery) parts.push(`Video search: ${data.videoSearchQuery}`); + else if (data.videoSource === "ai-generate") parts.push(`Video source: AI generated`); + else if (data.videoSource === "upload") parts.push(`Video source: User upload`); + if (data.imageSource === "search" && data.imageSearchQuery) parts.push(`Image search: ${data.imageSearchQuery}`); + else if (data.imageSource === "search") parts.push(`Image source: Web search`); + else if (data.imageSource === "ai-generate") parts.push(`Image source: AI generated`); + else if (data.imageSource === "upload") parts.push(`Image source: User upload`); + parts.push(`Please start from the research step of the pipeline. Respond in English.`); + return parts.join("\n"); + } + const categoryMap: Record = { anxiety: "深度共鸣", conflict: "观点分歧/争议感", comedy: "搞笑抽象", envy: "向往拥有/羡慕" }; + const typeMap: Record = { "short-video": "短视频", "image-text": "图文" }; const parts: string[] = []; parts.push(`开始创作。`); parts.push(`内容形式:${typeMap[data.type] ?? data.type}`); @@ -70,11 +83,15 @@ if (data.videoSource === "search" && data.videoSearchQuery) parts.push(`视频素材搜索:${data.videoSearchQuery}`); else if (data.videoSource === "ai-generate") parts.push(`视频素材:AI 生成`); else if (data.videoSource === "upload") parts.push(`视频素材:用户上传`); + if (data.imageSource === "search" && data.imageSearchQuery) parts.push(`图片素材搜索:${data.imageSearchQuery}`); + else if (data.imageSource === "search") parts.push(`图片素材:全网搜索下载`); + else if (data.imageSource === "ai-generate") parts.push(`图片素材:AI 生成`); + else if (data.imageSource === "upload") parts.push(`图片素材:用户上传`); parts.push(`请从话题调研开始执行流水线。`); return parts.join("\n"); } - async function handleCreateWork(data: { title: string; type: string; contentCategory: string; videoSource: string; videoSearchQuery: string; topicHint: string }) { + async function handleCreateWork(data: { title: string; type: string; platforms: string[]; contentCategory: string; videoSource: string; videoSearchQuery: string; imageSource: string; imageSearchQuery: string; topicHint: string }) { showNewWorkModal = false; prefillTitle = ""; prefillTopicHint = ""; @@ -85,8 +102,9 @@ contentCategory: (data.contentCategory || "anxiety") as ContentCategory, videoSource: data.videoSource || undefined, videoSearchQuery: data.videoSearchQuery || undefined, - platforms: ["douyin", "xiaohongshu"], + platforms: data.platforms && data.platforms.length > 0 ? data.platforms : ["douyin", "xiaohongshu"], topicHint: data.topicHint || undefined, + language: lang as "en" | "zh", }); initialPrompt = buildInitialPrompt(data); currentWorkId = newWork.id; @@ -175,10 +193,11 @@ @@ -211,71 +230,23 @@
-
- {tt("languageSetting")} -
- EN + + + - -
- {tt("themeSetting")} - -
- -
- {tt("researchConfig")} -
- - -
- {tt("autoResearch")} - -
+
- - {#if settingsMessage} -

{settingsMessage}

- {/if} - -
{/if} @@ -518,12 +489,16 @@ display: flex; align-items: center; justify-content: center; - width: 32px; + gap: 0.35rem; height: 32px; + padding: 0 0.5rem; border: 1px solid transparent; border-radius: 4px; background: none; color: var(--text-muted); + font-size: var(--size-sm, 0.8rem); + font-weight: 500; + white-space: nowrap; cursor: pointer; transition: all var(--transition-fast); } @@ -618,6 +593,36 @@ gap: 1.5rem; } + .drawer-footer { + margin-top: auto; + padding-top: 1rem; + border-top: 1px solid var(--border); + } + + .switch-row { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 0.45rem; + } + + .switch-label { + font-size: var(--size-xs, 0.7rem); + font-weight: 500; + color: var(--text-dim); + } + + .switch-row svg { + color: var(--text-dim); + } + + .switch-divider { + width: 1px; + height: 14px; + background: var(--border); + margin: 0 0.25rem; + } + .field-group { display: flex; flex-direction: column; diff --git a/web/src/components/AssetLibrary.svelte b/web/src/components/AssetLibrary.svelte index b9198409..88e45c36 100644 --- a/web/src/components/AssetLibrary.svelte +++ b/web/src/components/AssetLibrary.svelte @@ -1,20 +1,26 @@
- - - - {#if expanded} -
+

{lang === "zh" ? "上传你的照片、场景、品牌素材等,它们将作为所有视频生成的参照物,让 AI 更准确地还原你的形象与风格。" : "Upload your photos, scenes, and brand assets — they'll be used as references for all video generation, helping AI accurately recreate your look and style."}

+
{#each CATS as cat} @@ -170,7 +171,7 @@ class:active={activeCat === cat.key} onclick={() => activeCat = cat.key} > - {cat.label} + {catLabel(cat.key)} {#if (assets[cat.key]?.length ?? 0) > 0} {assets[cat.key].length} {/if} @@ -178,25 +179,22 @@ {/each}
- -
- - { - const input = e.currentTarget as HTMLInputElement; - if (input.files) handleUpload(input.files); - input.value = ""; - }} - /> -
- - -
-
+ + + { + const input = e.currentTarget as HTMLInputElement; + if (input.files) handleUpload(input.files); + input.value = ""; + }} + />
{#if loading} -
加载中...
+
{lang === "zh" ? "加载中..." : "Loading..."}
{:else if currentFiles.length === 0} -
暂无文件,拖放或点击上传
+
{lang === "zh" ? "暂无文件" : "No files"}
{:else if viewMode === "grid"}
@@ -231,6 +229,9 @@ {fileIcon(file.name)}
{/if} +
{truncate(file.name, 12)}
{formatSize(file.size)}
@@ -241,9 +242,9 @@ - - - + + + @@ -267,9 +268,6 @@
文件名大小日期{lang === "zh" ? "文件名" : "Name"}{lang === "zh" ? "大小" : "Size"}{lang === "zh" ? "日期" : "Date"}
{/if}
-
- {/if} - {#if contextMenu}
e.stopPropagation()} > - 下载 - + {lang === "zh" ? "下载" : "Download"} +
-
移动到
+
{lang === "zh" ? "移动到" : "Move to"}
{#each CATS.filter(c => c.key !== activeCat) as cat} - + {/each}
{/if} @@ -297,155 +295,141 @@
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index a54e0982..690ea216 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -92,6 +92,8 @@ export interface Work { cliSessionId?: string; coverImage?: string; topicHint?: string; + titleLocked?: boolean; + language?: "en" | "zh"; createdAt: string; updatedAt: string; } @@ -117,6 +119,7 @@ export async function createWorkApi(input: { videoSearchQuery?: string; platforms?: string[]; topicHint?: string; + language?: "en" | "zh"; }): Promise { return request("/api/works", { method: "POST", @@ -190,10 +193,10 @@ export async function deleteAsset(category: string, filename: string): Promise { - const res = await fetch("/api/shared-assets/move", { + const res = await fetch(`/api/shared-assets/${encodeURIComponent(from)}/${encodeURIComponent(file)}/move`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ from, to, file }), + body: JSON.stringify({ toCat: to }), }); if (!res.ok) throw new Error(await res.text()); } diff --git a/web/src/lib/i18n.ts b/web/src/lib/i18n.ts index e4c47fe6..7f5a2955 100644 --- a/web/src/lib/i18n.ts +++ b/web/src/lib/i18n.ts @@ -68,12 +68,20 @@ const translations: Record> = { videoSourceSearchHint: "AI searches and downloads matching videos", videoSourceAIHint: "Generate video clips with Jimeng AI", videoSearchPlaceholder: "Describe the video you're looking for...", + imageSource: "Image Source", + imageSourceUpload: "Upload", + imageSourceSearch: "Web Search", + imageSourceAI: "AI Generate", + imageSourceUploadHint: "Upload your own images", + imageSourceSearchHint: "AI searches and downloads matching images from the web", + imageSourceAIHint: "Generate images with AI", + imageSearchPlaceholder: "Describe the images you're looking for...", contentCategory: "Target Emotion", - categoryAnxiety: "Crisis", + categoryAnxiety: "Resonance", categoryConflict: "Debate", categoryComedy: "Comedy", categoryEnvy: "Aspiration", - categoryAnxietyDesc: "FOMO, urgency, threats", + categoryAnxietyDesc: "Relatable struggles, shared feelings", categoryConflictDesc: "Controversy, injustice", categoryComedyDesc: "Skits, absurdist humor", categoryEnvyDesc: "Lifestyle, envy triggers", @@ -105,11 +113,28 @@ const translations: Record> = { stepRunningLabel: "Running...", stepCompletedLabel: "Done", stepFailedLabel: "Failed", - stepMaterialSearch: "Search", - stepResearch: "Research", - stepPlan: "Plan", - stepAssets: "Assets", - stepAssembly: "Assemble", + stepMaterialSearch: "Material Search", + stepResearch: "Topic Research", + stepPlan: "Storyboard", + stepPlanImageText: "Content Planning", + stepAssets: "Image Generation", + stepAssembly: "Video Assembly", + stepAssemblyImageText: "Layout Assembly", + stepEvaluatingLabel: "Evaluating...", + stepEvalBlockedLabel: "Review Failed", + evalPass: "Review Passed", + evalFail: "Review Failed", + evalForcePass: "Force Pass", + evalRetry: "Retry with Feedback", + evalRetryPlaceholder: "Enter revision guidance...", + evalScores: "Scores", + evalIssues: "Issues", + evalSuggestions: "Suggestions", + evalSeverityCritical: "Critical", + evalSeverityMajor: "Major", + evalSeverityMinor: "Minor", + evalReviewing: "Quality Review", + evalTooltip: "When enabled, an AI reviewer automatically checks the output of each pipeline step before moving on. It scores quality, flags issues, and can block progress if standards aren't met. Turn off to skip reviews and move faster.", // Tool display names toolSearching: "Searching...", @@ -124,9 +149,37 @@ const translations: Record> = { // Research progress researchDone: "Research complete", + researchFailed: "Research failed", + analyzingTrends: "AI is analyzing trends...", + collectingData: "Collecting trending data...", + retry: "Retry", + aiAnalysisOutput: "AI Analysis Output", + collapseReport: "Collapse research report", + viewFullReport: "View full research report", // Explore / Inspiration page startResearch: "Research", + unknownDirection: "Unknown direction", + cannotStartResearch: "Cannot start trend research", + searchLabel: "Search", + aiAnalyzing: "AI is analyzing...", + researchError: "Research failed", + networkError: "Network error, please retry", + angleLabel: "Content angles:", + tagsLabel: "Recommended tags:", + oppGold: "Gold Mine", + oppBlue: "Blue Ocean", + oppRed: "Red Ocean", + platformDouyin: "Douyin", + platformXiaohongshu: "Xiaohongshu", + noWorksYet: "No featured works yet", + noWorksYetDesc: "", + autoviralCreation: "AutoViral Creation", + filterAllPlatforms: "All", + autoResearchOnDesc: "Auto research is on. Turn off to stop.", + autoResearchOffDesc: "AI will automatically research trends at the set interval.", + turnOff: "Turn Off", + turnOn: "Turn On", cancelResearch: "Cancel", loadingTrends: "Loading trends...", emptyTrendsTitle: "No trends yet", @@ -185,11 +238,155 @@ const translations: Record> = { uploadAsset: "Upload", // Settings - settingsTitle: "Settings", + settingsTitle: "Your Portrait", languageSetting: "Language", themeSetting: "Theme", darkTheme: "Dark", lightTheme: "Light", + + // Misc UI + confirmLeaveWhileGenerating: "Content is being generated. Leaving will abort the task. Continue?", + labelTargetEmotion: "Target emotion", + labelContentAngle: "Angle", + labelViralHookColon: "Hook", + langZh: "Chinese", + + // InterestTags + followedTopics: "Followed Topics", + noTopicsFollowed: "No topics followed yet", + addTopicPlaceholder: "Add topic...", + followingTopics: "topics", + followingCompetitors: "competitors", + followingTopic: "topic", + followingCompetitor: "competitor", + + // CanvasWorkspace + catAll: "All", + catFrames: "Frames", + catClips: "Clips", + catImages: "Images", + catOutput: "Output", + sharedAssetLib: "Shared Assets", + uploading: "Uploading...", + uploadAssets: "Upload Assets", + sharedCatCharacters: "Characters", + sharedCatMusic: "Music", + sharedCatTemplates: "Templates", + noSharedAssets: "No assets", + loadingAssets: "Loading assets...", + assetsAppearHere: "Assets will appear here after generation", + assetsAppearHereSub: "Start creating and AI will display generated frames, clips and images on this canvas", + manualUpload: "Upload manually", + outputBadge: "Output", + viewLarge: "View", + downloadFile: "Download", + deselect: "Deselect", + aiModifyPlaceholder: "Ask AI to modify this asset, e.g.: change the background to a beach sunset...", + sendToAi: "Send to AI", + outputFiles: "Output Files", + downloadAllBtn: "Download All", + modifyFilePrefix: "Please modify", + + // Portrait reference + usePortraitLabel: "Link Portrait", + usePortraitTooltip: "When enabled, your portrait reference images will be used to guide asset generation for consistent style", + + // SharedAssets + playAudio: "Play", + deleteAsset: "Delete", + noAssetsInCategory: "No {category} assets", + uploadBtn: "Upload", + + // Settings Panel + settingsClose: "Close", + settingsLoadingConfig: "Loading config...", + jimengApi: "Jimeng API", + enterAccessKey: "Enter AccessKey", + enterSecretKey: "Enter SecretKey", + enterApiKey: "Enter API Key", + toggleHide: "Hide", + toggleShow: "Show", + toggleVisibility: "Toggle visibility", + researchSettings: "Research Settings", + enableAutoResearch: "Enable Auto Research", + cronExpression: "Cron Expression", + modelSelection: "Model Selection", + defaultModel: "Default Model", + creatorDataLabel: "Creator Data", + douyinHomeUrl: "Douyin Home URL", + douyinUrlHint: "Enter your Douyin homepage link; the system will auto-collect work data every hour", + aiMemory: "AI Memory", + memorySync: "Memory Sync", + memorySyncDesc: "When enabled, conversations are automatically synced to EverMemOS after each creation, so AI remembers your creative history", + memorySyncEnvHint: "Requires EVERMEMOS_API_KEY environment variable", + savingSettings: "Saving...", + saveSettings: "Save Settings", + + // Analytics Page + numHundredMillion: "B", + numTenThousand: "W", + timeJustNow: "just now", + timeMinutesAgo: "{n} min ago", + timeHoursAgo: "{n}h ago", + timeDaysAgo: "{n}d ago", + savedCollecting: "Saved, collecting data...", + saveFailedRetry: "Save failed, please retry", + loadingEllipsis: "Loading...", + douyinTabLabel: "Douyin", + xiaohongshuTabLabel: "Xiaohongshu", + connectDouyin: "Connect your Douyin account", + connectXiaohongshu: "Connect your Xiaohongshu account", + emptySubDesc: "Enter your homepage link to start collecting creator data and unlock the full data dashboard", + howToGetLink: "How to get the link?", + openDouyinApp: "Open Douyin on your phone", + tapTopRightMenu: "Tap the top-right", + selectMyQrCode: "Select", + myQrCodeLabel: "My QR Code", + tapShare: "Top-right", + shareLabel: "Share", + copyLink: "Copy Link", + openXiaohongshuApp: "Open Xiaohongshu App", + goToMePage: "Go to the", + mePageLabel: "\"Me\"", + pageSuffix: "page", + tapTopRightDots: "Tap the top-right", + dotsLabel: "...", + pasteDouyinUrl: "Paste your Douyin homepage link, e.g. https://v.douyin.com/xxx/", + pasteXiaohongshuUrl: "Paste your Xiaohongshu homepage link, e.g. https://www.xiaohongshu.com/user/xxx", + startCollecting: "Start Collecting", + comingSoon: "Coming soon", + lastCollected: "Last collected:", + refreshData: "Refresh data", + changeAccount: "Change account", + metricFollowers: "Followers", + metricLikes: "Likes", + metricAvgPlay: "Avg Plays", + metricEngagement: "Engagement", + engagementExcellent: "Excellent", + engagementNormal: "Normal", + engagementLow: "Low", + publishedWorks: "Published", + collectedWorks: "Collected", + avgLikes: "Avg Likes", + avgComments: "Avg Comments", + avgShares: "Avg Shares", + avgCollects: "Avg Collects", + worksPerformance: "Works Performance", + top3Viral: "Top 3 marked as viral", + noWorksData: "No works data yet", + colTitle: "Title", + colDate: "Date", + colPlays: "Plays", + colLikes: "Likes", + colComments: "Comments", + colShares: "Shares", + colHeat: "Heat", + viralBadge: "HOT", + noTitle: "(No title)", + urlEditHint: "Douyin app > menu > My QR Code > Share > Copy Link", + pasteDouyinLink: "Paste Douyin homepage link...", + urlSave: "Save", + urlCancel: "Cancel", }, zh: { // Navigation @@ -258,12 +455,20 @@ const translations: Record> = { videoSourceSearchHint: "AI 在网上搜索并下载匹配的视频", videoSourceAIHint: "使用即梦 AI 生成视频片段", videoSearchPlaceholder: "描述你想搜索的视频内容...", + imageSource: "图片来源", + imageSourceUpload: "自己上传", + imageSourceSearch: "全网搜索", + imageSourceAI: "AI 生成", + imageSourceUploadHint: "上传已有的图片素材", + imageSourceSearchHint: "AI 在网上搜索并下载匹配的图片", + imageSourceAIHint: "使用 AI 生成图片", + imageSearchPlaceholder: "描述你想搜索的图片内容...", contentCategory: "触发情绪", - categoryAnxiety: "危机感", + categoryAnxiety: "深度共鸣", categoryConflict: "观点分歧", categoryComedy: "搞笑抽象", categoryEnvy: "向往拥有", - categoryAnxietyDesc: "落后焦虑、错过恐惧", + categoryAnxietyDesc: "戳中痛点、引发共鸣", categoryConflictDesc: "争议话题、不公现象", categoryComedyDesc: "搞笑短剧、抽象内容", categoryEnvyDesc: "生活向往、关系羡慕", @@ -296,10 +501,27 @@ const translations: Record> = { stepCompletedLabel: "已完成", stepFailedLabel: "失败", stepMaterialSearch: "素材搜索", - stepResearch: "调研", - stepPlan: "策划", - stepAssets: "素材", - stepAssembly: "合成", + stepResearch: "话题调研", + stepPlan: "分镜规划", + stepPlanImageText: "内容规划", + stepAssets: "图片生成", + stepAssembly: "视频合成", + stepAssemblyImageText: "图文排版", + stepEvaluatingLabel: "评审中...", + stepEvalBlockedLabel: "评审未通过", + evalPass: "评审通过", + evalFail: "评审未通过", + evalForcePass: "强制通过", + evalRetry: "根据反馈重做", + evalRetryPlaceholder: "输入修改指导意见...", + evalScores: "评分详情", + evalIssues: "问题列表", + evalSuggestions: "修改建议", + evalSeverityCritical: "严重", + evalSeverityMajor: "重要", + evalSeverityMinor: "轻微", + evalReviewing: "质量评审", + evalTooltip: "开启后,AI 评审员会在每个流水线步骤完成后自动检查产出质量,评分并标记问题,不达标时会阻止进入下一步。关闭可跳过评审,加快流程。", // Tool display names toolSearching: "正在搜索...", @@ -314,9 +536,37 @@ const translations: Record> = { // Research progress researchDone: "调研完成", + researchFailed: "调研失败", + analyzingTrends: "AI 正在分析趋势...", + collectingData: "正在收集热搜数据...", + retry: "重试", + aiAnalysisOutput: "AI 分析输出", + collapseReport: "收起研究报告", + viewFullReport: "查看完整研究报告", // Explore / Inspiration page startResearch: "开始调研", + unknownDirection: "未知方向", + cannotStartResearch: "无法启动趋势调研", + searchLabel: "搜索", + aiAnalyzing: "AI 正在分析整理...", + researchError: "调研失败", + networkError: "网络错误,请重试", + angleLabel: "切入角度:", + tagsLabel: "推荐标签:", + oppGold: "金矿", + oppBlue: "蓝海", + oppRed: "红海", + platformDouyin: "抖音", + platformXiaohongshu: "小红书", + noWorksYet: "暂无优秀作品展示", + noWorksYetDesc: "", + autoviralCreation: "AutoViral 创作", + filterAllPlatforms: "全部", + autoResearchOnDesc: "自动调研已开启,关闭后将停止自动调研。", + autoResearchOffDesc: "开启后,AI 会按设定频率自动调研热门趋势。", + turnOff: "关闭", + turnOn: "开启", cancelResearch: "取消调研", loadingTrends: "加载趋势数据中...", emptyTrendsTitle: "暂无趋势数据", @@ -375,11 +625,155 @@ const translations: Record> = { uploadAsset: "自己上传", // Settings - settingsTitle: "设置", + settingsTitle: "你的画像", languageSetting: "语言", themeSetting: "主题", darkTheme: "深色", lightTheme: "浅色", + + // Misc UI + confirmLeaveWhileGenerating: "正在生成中,退出将中止当前任务。确认退出?", + labelTargetEmotion: "目标情绪", + labelContentAngle: "切入角度", + labelViralHookColon: "爆款钩子", + langZh: "中文", + + // InterestTags + followedTopics: "关注的话题", + noTopicsFollowed: "还没有关注的话题", + addTopicPlaceholder: "添加话题...", + followingTopics: "关注话题", + followingCompetitors: "关注竞品", + followingTopic: "关注话题", + followingCompetitor: "关注竞品", + + // CanvasWorkspace + catAll: "全部", + catFrames: "帧", + catClips: "片段", + catImages: "图片", + catOutput: "输出", + sharedAssetLib: "共享素材库", + uploading: "上传中…", + uploadAssets: "上传素材", + sharedCatCharacters: "人物", + sharedCatMusic: "配乐", + sharedCatTemplates: "模板", + noSharedAssets: "暂无素材", + loadingAssets: "加载素材中…", + assetsAppearHere: "素材将在生成后出现在这里", + assetsAppearHereSub: "开始创作,AI 会将生成的帧、片段和图片显示在此画布中", + manualUpload: "手动上传素材", + outputBadge: "输出", + viewLarge: "查看大图", + downloadFile: "下载", + deselect: "取消选择", + aiModifyPlaceholder: "让 AI 修改此素材,例如:把背景换成海边日落...", + sendToAi: "发送给 AI", + outputFiles: "输出文件", + downloadAllBtn: "全部下载", + modifyFilePrefix: "请修改", + + // Portrait reference + usePortraitLabel: "关联你的画像", + usePortraitTooltip: "开启后,生成素材时会使用「你的画像」里的参考图,确保风格一致", + + // SharedAssets + playAudio: "播放", + deleteAsset: "删除", + noAssetsInCategory: "暂无{category}素材", + uploadBtn: "上传", + + // Settings Panel + settingsClose: "关闭", + settingsLoadingConfig: "加载配置...", + jimengApi: "即梦 API", + enterAccessKey: "输入 AccessKey", + enterSecretKey: "输入 SecretKey", + enterApiKey: "输入 API Key", + toggleHide: "隐藏", + toggleShow: "显示", + toggleVisibility: "切换可见", + researchSettings: "调研设置", + enableAutoResearch: "启用自动调研", + cronExpression: "Cron 表达式", + modelSelection: "模型选择", + defaultModel: "默认模型", + creatorDataLabel: "创作者数据", + douyinHomeUrl: "抖音主页 URL", + douyinUrlHint: "输入你的抖音主页链接,系统将每小时自动采集作品数据", + aiMemory: "AI 记忆", + memorySync: "记忆同步", + memorySyncDesc: "开启后,每次创作完成时自动将对话同步到 EverMemOS,让 AI 记住你的创作历史", + memorySyncEnvHint: "需要配置 EVERMEMOS_API_KEY 环境变量", + savingSettings: "保存中...", + saveSettings: "保存设置", + + // Analytics Page + numHundredMillion: "亿", + numTenThousand: "万", + timeJustNow: "刚刚", + timeMinutesAgo: "{n} 分钟前", + timeHoursAgo: "{n} 小时前", + timeDaysAgo: "{n} 天前", + savedCollecting: "已保存,正在采集数据…", + saveFailedRetry: "保存失败,请重试", + loadingEllipsis: "加载中…", + douyinTabLabel: "抖音", + xiaohongshuTabLabel: "小红书", + connectDouyin: "连接你的抖音账号", + connectXiaohongshu: "连接你的小红书账号", + emptySubDesc: "输入账号主页链接,开始采集创作者数据,解锁完整数据看板", + howToGetLink: "如何获取链接?", + openDouyinApp: "打开手机抖音", + tapTopRightMenu: "点击右上角", + selectMyQrCode: "选择", + myQrCodeLabel: "我的二维码", + tapShare: "右上角", + shareLabel: "分享", + copyLink: "复制链接", + openXiaohongshuApp: "打开小红书 App", + goToMePage: "进入", + mePageLabel: "「我」", + pageSuffix: "页面", + tapTopRightDots: "点击右上角", + dotsLabel: "···", + pasteDouyinUrl: "粘贴你的抖音主页链接,如 https://v.douyin.com/xxx/", + pasteXiaohongshuUrl: "粘贴你的小红书主页链接,如 https://www.xiaohongshu.com/user/xxx", + startCollecting: "开始采集", + comingSoon: "即将支持", + lastCollected: "上次采集:", + refreshData: "刷新数据", + changeAccount: "更换账号", + metricFollowers: "粉丝", + metricLikes: "获赞", + metricAvgPlay: "平均播放", + metricEngagement: "互动率", + engagementExcellent: "优秀", + engagementNormal: "正常", + engagementLow: "偏低", + publishedWorks: "发布作品", + collectedWorks: "已采集", + avgLikes: "均点赞", + avgComments: "均评论", + avgShares: "均分享", + avgCollects: "均收藏", + worksPerformance: "作品表现", + top3Viral: "Top 3 已标注爆款", + noWorksData: "暂无作品数据", + colTitle: "标题", + colDate: "日期", + colPlays: "播放", + colLikes: "点赞", + colComments: "评论", + colShares: "分享", + colHeat: "热度", + viralBadge: "爆", + noTitle: "(无标题)", + urlEditHint: "手机抖音 → ≡ → 我的二维码 → 分享 → 复制链接", + pasteDouyinLink: "粘贴抖音主页链接...", + urlSave: "保存", + urlCancel: "取消", }, }; diff --git a/web/src/pages/Analytics.svelte b/web/src/pages/Analytics.svelte index 274565e1..851020e3 100644 --- a/web/src/pages/Analytics.svelte +++ b/web/src/pages/Analytics.svelte @@ -1,5 +1,9 @@ @@ -181,7 +190,7 @@ {#if loading}
- 加载中… + {tt('loadingEllipsis')}
{:else if !configured || !creatorData} @@ -191,33 +200,33 @@
- - + +

- {analyticsPlatform === "douyin" ? "连接你的抖音账号" : "连接你的小红书账号"} + {analyticsPlatform === "douyin" ? tt('connectDouyin') : tt('connectXiaohongshu')}

- 输入账号主页链接,开始采集创作者数据,解锁完整数据看板 + {tt('emptySubDesc')}

- 如何获取链接? + {tt('howToGetLink')} {#if analyticsPlatform === "douyin"}
    -
  1. 打开手机抖音
  2. -
  3. 点击右上角
  4. -
  5. 选择 我的二维码
  6. -
  7. 右上角 分享
  8. -
  9. 复制链接
  10. +
  11. {tt('openDouyinApp')}
  12. +
  13. {tt('tapTopRightMenu')}
  14. +
  15. {tt('selectMyQrCode')} {tt('myQrCodeLabel')}
  16. +
  17. {tt('tapShare')} {tt('shareLabel')}
  18. +
  19. {tt('copyLink')}
{:else}
    -
  1. 打开小红书 App
  2. -
  3. 进入 「我」 页面
  4. -
  5. 点击右上角 ···
  6. -
  7. 复制链接
  8. +
  9. {tt('openXiaohongshuApp')}
  10. +
  11. {tt('goToMePage')} {tt('mePageLabel')} {tt('pageSuffix')}
  12. +
  13. {tt('tapTopRightDots')} {tt('dotsLabel')}
  14. +
  15. {tt('copyLink')}
{/if}
@@ -227,8 +236,8 @@ class="url-input" type="text" placeholder={analyticsPlatform === "douyin" - ? "粘贴你的抖音主页链接,如 https://v.douyin.com/xxx/" - : "粘贴你的小红书主页链接,如 https://www.xiaohongshu.com/user/xxx"} + ? tt('pasteDouyinUrl') + : tt('pasteXiaohongshuUrl')} bind:value={douyinUrlInput} onkeydown={(e) => { if (e.key === "Enter") saveDouyinUrl(); }} /> @@ -236,11 +245,11 @@ {#if savingUrl} {/if} - 开始采集 + {tt('startCollecting')}
{#if saveMsg} -

{saveMsg}

+

{saveMsg}

{/if} @@ -252,12 +261,12 @@
-
@@ -271,16 +280,16 @@ {creatorData.account.nickname} - 抖音 + {tt('platformDouyin')}
- 上次采集: {timeAgo(creatorData.collected_at)} - -
@@ -288,19 +297,19 @@ {#if showUrlEdit}
- 手机抖音 → ≡ → 我的二维码 → 分享 → 复制链接 + {tt('urlEditHint')} { if (e.key === "Enter" && editUrlValue.trim()) { douyinUrlInput = editUrlValue; saveDouyinUrl(); showUrlEdit = false; } if (e.key === "Escape") showUrlEdit = false; }} />
{/if} @@ -315,7 +324,7 @@
{fmtNum(creatorData.account.follower_count)} - 粉丝 + {tt('metricFollowers')}
{#if delta.followers !== undefined} {deltaLabel(delta.followers)} @@ -329,7 +338,7 @@
{fmtNum(creatorData.account.total_favorited)} - 获赞 + {tt('metricLikes')}
{#if delta.favorited !== undefined} {deltaLabel(delta.favorited)} @@ -343,7 +352,7 @@
{fmtNum(creatorData.summary.avg_play)} - 平均播放 + {tt('metricAvgPlay')}
@@ -356,13 +365,13 @@ {fmtPct(creatorData.summary.engagement_rate)} - 互动率 + {tt('metricEngagement')} - {#if creatorData.summary.engagement_rate >= 0.05}优秀{:else if creatorData.summary.engagement_rate >= 0.02}正常{:else}偏低{/if} + {#if creatorData.summary.engagement_rate >= 0.05}{tt('engagementExcellent')}{:else if creatorData.summary.engagement_rate >= 0.02}{tt('engagementNormal')}{:else}{tt('engagementLow')}{/if} @@ -372,66 +381,66 @@
{creatorData.account.aweme_count} - 发布作品 + {tt('publishedWorks')}
{creatorData.summary.total_works_collected} - 已采集 + {tt('collectedWorks')}
{fmtNum(creatorData.summary.avg_digg)} - 均点赞 + {tt('avgLikes')}
{fmtNum(creatorData.summary.avg_comment)} - 均评论 + {tt('avgComments')}
{fmtNum(creatorData.summary.avg_share)} - 均分享 + {tt('avgShares')}
{fmtNum(creatorData.summary.avg_collect)} - 均收藏 + {tt('avgCollects')}
-

作品表现

- Top 3 已标注爆款 +

{tt('worksPerformance')}

+ {tt('top3Viral')}
{#if sortedWorks.length === 0} -

暂无作品数据

+

{tt('noWorksData')}

{:else}
- + - + @@ -439,9 +448,9 @@ diff --git a/web/src/pages/Explore.svelte b/web/src/pages/Explore.svelte index 0e235757..a506efe1 100644 --- a/web/src/pages/Explore.svelte +++ b/web/src/pages/Explore.svelte @@ -24,7 +24,7 @@ } type ContentCategory = "anxiety" | "conflict" | "comedy" | "envy"; - let activeCategory: ContentCategory = $state("anxiety"); + let activeCategory: ContentCategory = $state("conflict"); let interests: string[] = $state([]); let activePlatform: Platform = $state("douyin"); @@ -49,6 +49,244 @@ let configInterval = $state("1h"); let configModel = $state("sonnet"); + // Showcase: load all works with output content + interface ShowcaseWork { + id: string; + title: string; + coverImage: string; + images: string[]; + videoUrl: string; + body: string; + tags: string[]; + category: ContentCategory; + platforms: string[]; + } + let showcaseWorks: ShowcaseWork[] = $state([]); + let selectedWork: ShowcaseWork | null = $state(null); + let showcasePlatform: "all" | "douyin" | "xiaohongshu" = $state("all"); + let modalImageIdx = $state(0); + let dyPaused = $state(true); + + let filteredShowcase = $derived( + showcaseWorks.filter(w => + w.category === activeCategory && + (showcasePlatform === "all" || w.platforms.includes(showcasePlatform)) + ) + ); + + function parseCopytext(raw: string): { title: string; body: string; tags: string[] } { + let title = ""; + let bodyLines: string[] = []; + let tags: string[] = []; + let section = ""; + for (const rawLine of raw.split("\n")) { + const line = rawLine.trim(); + if (!line || line === "---" || line === "***") continue; + const m = line.match(/^#{1,3}\s+(.+)/); + if (m) { + const name = m[1].trim().toLowerCase(); + if (/标题|title/.test(name)) section = "title"; + else if (/标签|tag|话题|topic/.test(name)) section = "tags"; + else if (/发布建议|publish.?tip|注意事项/.test(name)) section = "tips"; + else section = "body"; + continue; + } + const cleaned = line.replace(/\*\*(.+?)\*\*/g, "$1").replace(/^[-*]\s+/, ""); + if (section === "title" && !title) title = cleaned; + else if (section === "tags") { + const found = cleaned.match(/#[\w\u4e00-\u9fff\u00c0-\u024f]+/g); + if (found) tags.push(...found); + else if (cleaned) tags.push(cleaned.startsWith("#") ? cleaned : "#" + cleaned); + } else if (section !== "tips") bodyLines.push(cleaned); + } + if (!title && bodyLines.length && bodyLines[0].length < 60) { + title = bodyLines.shift()!; + } + return { title, body: bodyLines.join("\n"), tags: [...new Set(tags)] }; + } + + // Hardcoded showcase entries — add works here to feature them + interface ShowcaseEntry { + id: string; + category: ContentCategory; + en?: { title: string; body: string; tags: string[]; imageDir?: string }; + } + const SHOWCASE_ENTRIES: ShowcaseEntry[] = [ + { id: "w_20260401_1537_1cf", category: "envy" }, + { id: "w_20260325_1753_75d", category: "conflict" }, + { id: "w_20260329_1710_ecf", category: "envy" }, + ]; + + function extractCopytextFromChat(blocks: { type: string; text: string }[]): { title: string; body: string; tags: string[] } | null { + // Walk backwards to find the last agent message with copytext + for (let i = blocks.length - 1; i >= 0; i--) { + const b = blocks[i]; + if (b.type !== "text") continue; + const text = b.text; + // Strategy: look for "标题" + "正文" pattern + const bodyMatch = text.match(/\*{0,2}正文\*{0,2}\s*[::]\s*/); + if (bodyMatch) { + // Extract body: everything between 正文 and 标签/tags section + const bodyStart = text.indexOf(bodyMatch[0]) + bodyMatch[0].length; + let bodyEnd = text.length; + const tagsIdx = text.search(/\*{0,2}(标签|话题标签)\*{0,2}\s*[::]/); + if (tagsIdx > bodyStart) bodyEnd = tagsIdx; + let body = text.slice(bodyStart, bodyEnd).replace(/^>\s*/gm, "").replace(/^\n+|\n+$/g, "").trim(); + // Extract tags + let tags: string[] = []; + if (tagsIdx >= 0) { + const tagsSection = text.slice(tagsIdx); + const found = tagsSection.match(/#[\w\u4e00-\u9fff]+/g); + if (found) tags = [...new Set(found)]; + } + // Title = first short line of body (matches 成品tab behavior) + let title = ""; + const bodyLines = body.split("\n").filter(l => l.trim()); + if (bodyLines.length > 1 && bodyLines[0].length < 60) { + title = bodyLines.shift()!; + body = bodyLines.join("\n"); + } + if (body) return { title, body, tags }; + } + // Fallback: look for quoted block after "发布文案" + if (/发布文案/.test(text)) { + const lines = text.split("\n"); + let inQuote = false; + let bodyLines: string[] = []; + let tags: string[] = []; + for (const line of lines) { + if (line.startsWith(">")) { + inQuote = true; + bodyLines.push(line.replace(/^>\s*/, "")); + } else if (inQuote && !line.trim()) { + bodyLines.push(""); + } else if (inQuote) { + inQuote = false; + } + const found = line.match(/#[\w\u4e00-\u9fff]+/g); + if (found && !line.startsWith(">")) tags.push(...found); + } + const cleanLines = bodyLines.filter(l => l.trim()); + let title = ""; + if (cleanLines.length > 1 && cleanLines[0].length < 60) { + title = cleanLines.shift()!; + } + const body = cleanLines.join("\n").trim(); + if (body) return { title, body, tags: [...new Set(tags)] }; + } + } + return null; + } + + async function loadShowcaseWork() { + const results: ShowcaseWork[] = []; + for (const entry of SHOWCASE_ENTRIES) { + try { + const res = await fetch(`/api/works/${entry.id}`); + if (!res.ok) continue; + const work = await res.json(); + + let title = work.title ?? ""; + let body = ""; + let tags: string[] = []; + let coverImage = ""; + let images: string[] = []; + let videoUrl = ""; + let assets: string[] = []; + + const assetsRes = await fetch(`/api/works/${entry.id}/assets`); + if (assetsRes.ok) { + const assetsData = await assetsRes.json(); + assets = assetsData.assets ?? assetsData; + + // Detect final video for short-video works + const finalVid = assets.find((f: string) => /^(output\/)?final\.(mp4|mov|webm)$/i.test(f)) + ?? assets.find((f: string) => /\.(mp4|mov|webm)$/i.test(f) && !/clip|norm|concat|raw|base/i.test(f)); + if (finalVid) videoUrl = `/api/works/${entry.id}/assets/${finalVid}`; + + // Collect all output images (sorted by name for correct order) + const outputImgs = assets + .filter((f: string) => f.startsWith("output/") && /\.(png|jpe?g|webp)$/i.test(f)) + .sort(); + // Fallback to assets/images/ if no output images + const imgPool = outputImgs.length > 0 + ? outputImgs + : assets.filter((f: string) => f.startsWith("assets/images/") && /\.(png|jpe?g|webp)$/i.test(f)).sort(); + images = imgPool.map((f: string) => `/api/works/${entry.id}/assets/${f}`); + + // Cover: prefer output dir cover, then any cover, then first output image + const cover = outputImgs.find((f: string) => /cover|p1/i.test(f)) + ?? assets.find((f: string) => /cover/i.test(f) && /\.(png|jpe?g|webp)$/i.test(f)) + ?? (outputImgs.length > 0 ? outputImgs[0] : null); + if (cover) coverImage = `/api/works/${entry.id}/assets/${cover}`; + + const copytextFile = assets.find((f: string) => + f.startsWith("output/") && /copy|caption|文案/.test(f) && /\.(md|txt)$/i.test(f) + ) ?? assets.find((f: string) => f.startsWith("output/") && /\.md$/i.test(f)) + ?? assets.find((f: string) => /copy|caption|文案/.test(f) && /\.(md|txt)$/i.test(f)); + if (copytextFile) { + const copyRes = await fetch(`/api/works/${entry.id}/assets/${copytextFile}`); + if (copyRes.ok) { + const parsed = parseCopytext(await copyRes.text()); + if (parsed.title) title = parsed.title; + body = parsed.body; + tags = parsed.tags; + } + } + } + + // Fallback: extract copytext from chat history if no file found + if (!body) { + try { + const chatRes = await fetch(`/api/works/${entry.id}/chat`); + if (chatRes.ok) { + const chatData = await chatRes.json(); + const blocks = chatData.blocks ?? chatData; + const extracted = extractCopytextFromChat(blocks); + if (extracted) { + if (extracted.title) title = extracted.title; + body = extracted.body; + if (extracted.tags.length) tags = extracted.tags; + } + } + } catch {} + } + + // Apply English overrides when language is English + if (lang === "en" && entry.en) { + title = entry.en.title; + body = entry.en.body; + tags = entry.en.tags; + // Switch to English images if available + if (entry.en.imageDir) { + const enDir = entry.en.imageDir; + const enImgs = assets + .filter((f: string) => f.startsWith(enDir + "/") && /\.(png|jpe?g|webp)$/i.test(f)) + .sort(); + if (enImgs.length > 0) { + images = enImgs.map((f: string) => `/api/works/${entry.id}/assets/${f}`); + const enCover = enImgs.find((f: string) => /cover|p1/i.test(f)) ?? enImgs[0]; + coverImage = `/api/works/${entry.id}/assets/${enCover}`; + } + } + } + + results.push({ + id: entry.id, + title, + coverImage, + images, + videoUrl, + body, + tags, + category: entry.category, + platforms: work.platforms ?? [], + }); + } catch {} + } + showcaseWorks = results; + } + async function loadAutoResearch() { try { const res = await fetch("/api/config"); @@ -107,7 +345,7 @@ const arr = data.topics ?? data.directions ?? data.trends ?? data.items ?? data.videos; if (Array.isArray(arr) && arr.length > 0) { directions = arr.map((item: any) => ({ - title: item.title ?? item.name ?? item.direction ?? "未知方向", + title: item.title ?? item.name ?? item.direction ?? tt("unknownDirection"), heat: Math.min(5, Math.max(1, Number(item.heat ?? item.hotness ?? item.score ?? 3))), competition: item.competition ?? item.competitionLevel ?? "中", opportunity: item.opportunity ?? "", @@ -181,7 +419,7 @@ if (!res.ok) { researchPhase = "error"; - progressLines = [{ type: "error", text: "无法启动趋势调研" }]; + progressLines = [{ type: "error", text: tt("cannotStartResearch") }]; researchActive = false; return; } @@ -194,14 +432,14 @@ case "search_query": progressLines = [...progressLines, { type: "search", - text: `搜索 "${data.query}"`, + text: `${tt("searchLabel")} "${data.query}"`, }]; break; case "search_result": { const updated = [...progressLines]; for (let i = updated.length - 1; i >= 0; i--) { if (updated[i].type === "search") { - updated[i] = { type: "result", text: updated[i].text + " " + (data.summary || "完成") }; + updated[i] = { type: "result", text: updated[i].text + " " + (data.summary || tt("done")) }; break; } } @@ -220,7 +458,7 @@ if (!progressLines.some(l => l.type === "analyzing")) { progressLines = [...progressLines, { type: "analyzing", - text: "AI 正在分析整理...", + text: tt("aiAnalyzing"), }]; } break; @@ -260,7 +498,7 @@ researchPhase = "error"; progressLines = [...progressLines, { type: "error", - text: data.message || "调研失败", + text: data.message || tt("researchError"), }]; researchActive = false; break; @@ -271,7 +509,7 @@ }); } catch { researchPhase = "error"; - progressLines = [{ type: "error", text: "网络错误,请重试" }]; + progressLines = [{ type: "error", text: tt("networkError") }]; researchActive = false; } } @@ -310,8 +548,8 @@ const hint = [ dir.title, dir.description, - dir.contentAngles?.length ? `切入角度: ${dir.contentAngles.join("; ")}` : "", - dir.tags?.length ? `推荐标签: ${dir.tags.map(t => "#" + t).join(" ")}` : "", + dir.contentAngles?.length ? `${tt("angleLabel")} ${dir.contentAngles.join("; ")}` : "", + dir.tags?.length ? `${tt("tagsLabel")} ${dir.tags.map(t => "#" + t).join(" ")}` : "", ].filter(Boolean).join("\n"); const event = new CustomEvent("createWork", { @@ -322,14 +560,14 @@ } function opportunityColor(opp: string): string { - if (opp === "金矿") return "opp-gold"; - if (opp === "蓝海") return "opp-blue"; - if (opp === "红海") return "opp-red"; + if (opp === "金矿" || opp === "Gold Mine") return "opp-gold"; + if (opp === "蓝海" || opp === "Blue Ocean") return "opp-blue"; + if (opp === "红海" || opp === "Red Ocean") return "opp-red"; return ""; } let hasData = $derived(directions.length > 0 || rawContent.length > 0); - let platformLabel = $derived(activePlatform === "douyin" ? "抖音" : "小红书"); + let platformLabel = $derived(activePlatform === "douyin" ? tt("platformDouyin") : tt("platformXiaohongshu")); async function loadReport() { try { @@ -341,6 +579,12 @@ } catch {} } + // Reload showcase when language changes + $effect(() => { + void lang; + loadShowcaseWork(); + }); + onMount(() => { const unsub = subscribe(() => { lang = getLanguage(); }); loadTrends(); @@ -352,51 +596,221 @@
- -
- - - - + +
+
+ + + + +
+
+ + + +
- +
- {#if activeCategory === "conflict"} -
-
-
+ {#if filteredShowcase.length > 0} + {#each filteredShowcase as work} + +
{ modalImageIdx = 0; dyPaused = true; selectedWork = work; }}> + {#if work.videoUrl} + +
+ + + + +
+

{work.title}

+
+
+ {:else} + + {#if work.coverImage || work.images.length > 0} +
+ {work.title} + {#if work.images.length > 1} + + + {work.images.length} + + {/if} +
+ {/if} +
+

{work.title}

+ {#if work.body} +

{work.body.slice(0, 80)}{work.body.length > 80 ? "…" : ""}

+ {/if} + +
+ {/if} +
+ {/each} + {:else} +
+

{tt("noWorksYet")}

+

{tt("noWorksYetDesc")}

+
+ {/if} +
+
+ + +{#if selectedWork} + +
{ if ((e.target as HTMLElement).classList.contains('work-modal-overlay')) selectedWork = null; }}> +
+ +
+
+ {#if selectedWork.videoUrl} + +
+
+ + + {#if dyPaused} +
+ +
+ {/if} + +
+
+ + 2.4w +
+
+ + 3.6k +
+
+ + 8.1k +
+
+ +
+
+ @AutoViral +
+

{selectedWork.title}

+ {#if selectedWork.tags.length} +
+ {#each selectedWork.tags.slice(0, 4) as tag} + {tag} + {/each} +
+ {/if} +
+
+
+ {:else} +
-
-
- 封面 -
+ {#if selectedWork.images.length > 0} +
+ {selectedWork.title} + {#if selectedWork.images.length > 1} + +
{ e.stopPropagation(); if (modalImageIdx > 0) modalImageIdx--; }}>
+ +
{ e.stopPropagation(); if (modalImageIdx < selectedWork!.images.length - 1) modalImageIdx++; }}>
+
+ {#each selectedWork.images as _, i} + + {/each} +
+ {modalImageIdx + 1}/{selectedWork.images.length} + {/if} +
+ {:else if selectedWork.coverImage} +
+ {selectedWork.title} +
+ {/if}
-

一个能引发所有人讨论的价值观冲突很严重的话题

+

{selectedWork.title}

- AutoViral 创作 -
-

我今年28,单身,没房,没车。不是我不努力,是这个社会疯了。凭什么结婚就必须买房?凭什么我爸妈辛苦了大半辈子,老了还要掏空自己来成全我的"面子"?

-
- #结婚必须买房吗 - #买房焦虑 - #年轻人不买房 - #婚姻观 + {tt("autoviralCreation")} +
+ {#each selectedWork.platforms as p} + + {p === "douyin" ? tt("platformDouyin") : tt("platformXiaohongshu")} + + {/each} +
+ {#if selectedWork.body} +

{selectedWork.body}

+ {/if} + {#if selectedWork.tags.length} +
+ {#each selectedWork.tags as tag} + {tag} + {/each} +
+ {/if}
@@ -414,21 +828,12 @@
-
-
-
- {tt("categoryConflict")} - {lang === "zh" ? "路线1 · 观点输出型" : "Route 1 · Opinion"} -
-
- {:else} -
-

{lang === "zh" ? "优秀案例即将上线" : "Showcase examples coming soon"}

-

{lang === "zh" ? "这里将展示由 AutoViral 生成的优秀作品" : "Featured works created with AutoViral will appear here"}

+ {/if} +
- {/if} +
-
+{/if} {#if showConfigModal} @@ -436,7 +841,7 @@

{tt("autoResearchLabel")}

- {autoResearchOn ? (lang === "zh" ? "自动调研已开启,关闭后将停止自动调研。" : "Auto research is on. Turn off to stop.") : (lang === "zh" ? "开启后,AI 会按设定频率自动调研热门趋势。" : "AI will automatically research trends at the set interval.")} + {autoResearchOn ? tt("autoResearchOnDesc") : tt("autoResearchOffDesc")}

@@ -463,7 +868,7 @@
@@ -486,12 +891,20 @@ margin-bottom: 1.5rem; } + /* Header layout */ + .explore-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.25rem; + flex-wrap: wrap; + } + /* Category tabs */ .category-tabs { display: flex; - justify-content: center; gap: 0.4rem; - margin-bottom: 1.25rem; } .cat-tab { @@ -520,6 +933,16 @@ color: var(--text); } + .cat-tab-icon { + color: var(--text-dim); + flex-shrink: 0; + transition: color 0.15s; + } + + .cat-tab.active .cat-tab-icon { + color: var(--spark-red, #FE2C55); + } + .cat-tab-name { font-size: 0.85rem; font-weight: 650; @@ -535,10 +958,45 @@ color: var(--text-muted); } - /* Showcase */ + /* Platform tabs */ + .platform-tabs { + display: flex; + gap: 0.25rem; + background: var(--bg-inset, rgba(0,0,0,0.03)); + border-radius: 8px; + padding: 0.2rem; + flex-shrink: 0; + } + + .plat-tab { + display: flex; + align-items: center; + gap: 0.3rem; + padding: 0.35rem 0.75rem; + border-radius: 6px; + border: none; + background: none; + color: var(--text-dim); + font-size: 0.78rem; + font-weight: 600; + font-family: inherit; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + } + + .plat-tab:hover { color: var(--text); } + .plat-tab.active { + background: var(--bg-elevated, #fff); + color: var(--text); + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + } + + /* Showcase grid */ .showcase-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + grid-auto-rows: 1fr; gap: 1rem; } @@ -567,56 +1025,216 @@ margin: 0; } - /* Phone showcase */ - .phone-showcase { - grid-column: 1 / -1; + /* Work cards */ + .work-card { + background: var(--card-bg, #fff); + border: 1px solid var(--card-border, #e5e7eb); + border-radius: 12px; + overflow: hidden; + cursor: pointer; + transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s; + animation: fadeUp 0.3s ease both; display: flex; flex-direction: column; - align-items: center; - gap: 0.75rem; - padding: 2rem 0; } - .phone-frame { - width: 280px; - background: #000; - border-radius: 32px; - padding: 8px; - box-shadow: 0 20px 60px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.05); - position: relative; + .work-card:hover { + border-color: rgba(0,0,0,0.25); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0,0,0,0.12); } - .phone-notch { - width: 80px; - height: 22px; - background: #000; - border-radius: 0 0 14px 14px; - margin: 0 auto; + .work-cover { + width: 100%; + aspect-ratio: 3/4; + overflow: hidden; + background: #f5e6e0; position: relative; - z-index: 2; - margin-top: -1px; } - .phone-screen { - background: #fff; - border-radius: 24px; + .work-cover img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .work-cover-count { + position: absolute; + top: 8px; + left: 8px; + background: rgba(0,0,0,0.5); + color: #fff; + font-size: 0.6rem; + font-weight: 600; + padding: 2px 6px; + border-radius: 8px; + display: flex; + align-items: center; + gap: 3px; + } + + .work-platform-tags { + position: absolute; + top: 8px; + right: 8px; + display: flex; + gap: 4px; + } + + .work-platform-tag { + font-size: 0.65rem; + font-weight: 650; + padding: 0.15rem 0.45rem; + border-radius: 4px; + backdrop-filter: blur(8px); + } + + .work-platform-tag.douyin { + background: rgba(0, 0, 0, 0.65); + color: #fff; + } + + .work-platform-tag.xhs { + background: rgba(254, 44, 85, 0.85); + color: #fff; + } + + .work-card-body { + padding: 0.6rem 0.7rem 0.55rem; + display: flex; + flex-direction: column; + gap: 0.3rem; + flex: 1; + } + + .work-card-title { + font-size: 0.82rem; + font-weight: 700; + color: var(--text); + margin: 0; + line-height: 1.35; + letter-spacing: -0.01em; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; overflow: hidden; - margin-top: -12px; - max-height: 520px; - overflow-y: auto; } - .phone-screen::-webkit-scrollbar { display: none; } + .work-card-text { + font-size: 0.72rem; + color: var(--text-muted); + line-height: 1.5; + margin: 0; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } - .phone-home-bar { - width: 100px; - height: 4px; + .work-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: auto; + padding-top: 0.3rem; + } + + .work-card-author { + display: flex; + align-items: center; + gap: 4px; + } + + .work-card-avatar { + width: 16px; + height: 16px; + border-radius: 50%; + background: linear-gradient(135deg, #FE2C55, #FF6B6B); + flex-shrink: 0; + } + + .work-card-name { + font-size: 0.65rem; + color: var(--text-dim); + font-weight: 500; + } + + .work-card-platforms { + display: flex; + gap: 3px; + } + + .work-card-plat { + font-size: 0.55rem; + font-weight: 600; + padding: 1px 4px; + border-radius: 3px; + } + + .work-card-plat.douyin { + background: rgba(0, 0, 0, 0.08); + color: var(--text-muted); + } + + .work-card-plat.xhs { + background: rgba(254, 44, 85, 0.1); + color: #FE2C55; + } + + /* Work detail modal — phone frame */ + .work-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(6px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; + animation: fadeIn 0.15s ease; + } + + .phone-modal { + position: relative; + animation: scaleIn 0.2s ease; + } + + .phone-modal-close { + position: absolute; + top: -40px; + right: 0; + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background: rgba(255,255,255,0.15); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + z-index: 2; + transition: background 0.15s; + } + + .phone-modal-close:hover { background: rgba(255,255,255,0.3); - border-radius: 2px; - margin: 6px auto 2px; } - /* XHS post mockup */ + .phone-modal .phone-frame { + width: 320px; + background: #000; + border-radius: 36px; + padding: 8px; + box-shadow: 0 20px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.08); + } + + .phone-modal .phone-screen { + max-height: 600px; + } + + /* XHS post inside phone */ .xhs-post { font-family: -apple-system, "PingFang SC", "Helvetica Neue", sans-serif; color: #333; @@ -627,6 +1245,7 @@ aspect-ratio: 3/4; overflow: hidden; background: #f5e6e0; + position: relative; } .xhs-cover img { @@ -635,48 +1254,100 @@ object-fit: cover; } + /* Tap zones for prev/next — invisible, cover left/right halves */ + .cover-tap-left, .cover-tap-right { + position: absolute; + top: 0; + bottom: 0; + width: 40%; + cursor: pointer; + z-index: 2; + } + .cover-tap-left { left: 0; } + .cover-tap-right { right: 0; } + + /* Dot indicators — centered at bottom like XHS/Instagram */ + .cover-dots { + position: absolute; + bottom: 10px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 5px; + z-index: 3; + } + .cover-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(255,255,255,0.45); + transition: all 0.2s; + } + .cover-dot.active { + background: #fff; + transform: scale(1.2); + } + + /* Counter badge — top right like XHS */ + .cover-counter { + position: absolute; + top: 10px; + right: 10px; + background: rgba(0,0,0,0.45); + color: #fff; + font-size: 0.65rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 10px; + z-index: 3; + letter-spacing: 0.5px; + } + .xhs-body { - padding: 10px 12px 14px; + padding: 10px 14px 14px; } .xhs-title { - font-size: 14px; + font-size: 15px; font-weight: 700; color: #222; margin: 0 0 8px; - line-height: 1.35; + line-height: 1.4; } .xhs-author { display: flex; align-items: center; gap: 6px; - margin-bottom: 8px; + margin-bottom: 10px; } .xhs-avatar { - width: 22px; - height: 22px; + width: 24px; + height: 24px; border-radius: 50%; background: linear-gradient(135deg, #FE2C55, #FF6B6B); flex-shrink: 0; } .xhs-name { - font-size: 11px; + font-size: 12px; color: #999; font-weight: 500; + flex: 1; } - .xhs-text { - font-size: 12.5px; - color: #444; - line-height: 1.65; - margin: 0 0 8px; - display: -webkit-box; - -webkit-line-clamp: 4; - -webkit-box-orient: vertical; - overflow: hidden; + .xhs-platform-badges { + display: flex; + gap: 4px; + } + + .xhs-fulltext { + font-size: 13.5px; + color: #333; + line-height: 1.75; + margin: 0 0 12px; + white-space: pre-wrap; } .xhs-tags { @@ -687,7 +1358,7 @@ } .xhs-tag { - font-size: 11px; + font-size: 12px; color: #FE2C55; font-weight: 500; } @@ -703,7 +1374,7 @@ display: flex; align-items: center; gap: 4px; - font-size: 11px; + font-size: 12px; color: #999; font-weight: 500; } @@ -714,26 +1385,200 @@ stroke: #999; } - .showcase-caption { + /* Phone frame shared */ + .phone-frame { + width: 280px; + background: #000; + border-radius: 32px; + padding: 8px; + box-shadow: 0 20px 60px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.05); + position: relative; + } + + .phone-notch { + width: 80px; + height: 22px; + background: #000; + border-radius: 0 0 14px 14px; + margin: 0 auto; + position: relative; + z-index: 2; + margin-top: -1px; + } + + .phone-screen { + background: #fff; + border-radius: 24px; + overflow: hidden; + overflow-y: auto; + margin-top: -12px; + max-height: 520px; + } + + .phone-screen::-webkit-scrollbar { display: none; } + + .phone-home-bar { + width: 100px; + height: 4px; + background: rgba(255,255,255,0.3); + border-radius: 2px; + margin: 6px auto 2px; + } + + /* Video cover card styles — fills entire card */ + .work-cover.video-cover { + aspect-ratio: unset; + flex: 1; + } + .work-cover video { + width: 100%; + height: 100%; + object-fit: cover; + } + .work-cover-play { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 44px; + height: 44px; + border-radius: 50%; + background: rgba(0,0,0,0.45); display: flex; align-items: center; - gap: 0.5rem; + justify-content: center; + pointer-events: none; + } + .work-cover-play svg { + margin-left: 2px; } - .caption-tag { - font-size: 0.72rem; - font-weight: 650; - color: var(--spark-red, #FE2C55); - padding: 0.15rem 0.5rem; - border: 1px solid rgba(254, 44, 85, 0.2); - border-radius: 4px; - background: rgba(254, 44, 85, 0.04); + /* Video card bottom overlay — title on top of video */ + .video-card-bottom { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 24px 10px 8px; + background: linear-gradient(transparent, rgba(0,0,0,0.7)); + } + .video-card-title { + font-size: 0.8rem; + font-weight: 700; + color: #fff; + margin: 0; + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-shadow: 0 1px 3px rgba(0,0,0,0.5); } - .caption-route { - font-size: 0.72rem; - color: var(--text-dim); + /* Douyin-style fullscreen video player */ + .dy-screen { + background: #000 !important; + max-height: 600px !important; + overflow: hidden !important; + } + + .dy-video-container { + position: relative; + width: 100%; + height: 600px; + background: #000; + overflow: hidden; + cursor: pointer; + } + + .dy-video { + width: 100%; + height: 100%; + object-fit: contain; + background: #000; + } + + .dy-play-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + opacity: 0.9; + } + + /* Right sidebar — Douyin action buttons */ + .dy-sidebar { + position: absolute; + right: 8px; + bottom: 140px; + display: flex; + flex-direction: column; + align-items: center; + gap: 18px; + z-index: 3; + } + + .dy-action { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + } + + .dy-action svg { + filter: drop-shadow(0 1px 3px rgba(0,0,0,0.5)); + } + + .dy-action-count { + font-size: 10px; + color: #fff; + font-weight: 600; + text-shadow: 0 1px 3px rgba(0,0,0,0.6); + } + + /* Bottom overlay — author, description, tags */ + .dy-bottom { + position: absolute; + bottom: 12px; + left: 10px; + right: 54px; + z-index: 3; + } + + .dy-author { + margin-bottom: 6px; + } + + .dy-author-name { + font-size: 13px; + font-weight: 700; + color: #fff; + text-shadow: 0 1px 4px rgba(0,0,0,0.7); + } + + .dy-desc { + font-size: 12px; + color: #fff; + line-height: 1.5; + margin: 0 0 6px; + text-shadow: 0 1px 4px rgba(0,0,0,0.7); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + + .dy-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + } + + .dy-tag { + font-size: 11px; + color: #fff; font-weight: 500; + text-shadow: 0 1px 3px rgba(0,0,0,0.6); } .ranking-grid { diff --git a/web/src/pages/SettingsPanel.svelte b/web/src/pages/SettingsPanel.svelte index 8ff1a772..e0084e1d 100644 --- a/web/src/pages/SettingsPanel.svelte +++ b/web/src/pages/SettingsPanel.svelte @@ -1,8 +1,12 @@ - { showTypeDropdown = false; showCategoryDropdown = false; }} />
- {#if showTypeDropdown} -
- {#each [["short-video", tt("shortVideo")], ["image-text", tt("imageText")]] as [val, label]} - - {/each} -
- {/if} -
+ {work.type === "short-video" ? tt("shortVideo") : tt("imageText")} {#if work.contentCategory} -
- - {#if showCategoryDropdown} -
- {#each [["anxiety", tt("categoryAnxiety")], ["conflict", tt("categoryConflict")], ["comedy", tt("categoryComedy")], ["envy", tt("categoryEnvy")]] as [val, label]} - - {/each} -
- {/if} -
+ {work.contentCategory === "anxiety" ? tt("categoryAnxiety") : work.contentCategory === "conflict" ? tt("categoryConflict") : work.contentCategory === "comedy" ? tt("categoryComedy") : work.contentCategory === "envy" ? tt("categoryEnvy") : work.contentCategory} {/if} {/if}
-
- - 质量评审 +
+ {tt("evalReviewing")} + + showEvalTooltip = true} + onmouseleave={() => showEvalTooltip = false} + > + + {#if showEvalTooltip} + {tt("evalTooltip")} + {/if} + +
@@ -652,25 +528,10 @@
{#each streamBlocks as block, i} - {#if block.type === "eval_divider"} -
- - - {#if block.evalData?.verdict === "pass"} - - {:else if block.evalData?.verdict === "fail"} - - {:else} - - {/if} - {block.text} - - -
- {:else if block.type === "step_divider"} + {#if block.type === "step_divider"}
- {block.text} + {STEP_I18N_MAP[block.text] ? stepLabel(block.text) : block.text}
{:else if block.type === "user"} @@ -682,25 +543,16 @@ {#if i === 0 || !["thinking", "tool_use", "tool_result"].includes(streamBlocks[i - 1]?.type)} {@const group = getThinkingGroup(i, streamBlocks)} - {@const isEval = block.source === "evaluator"} - {#if !block.collapsed}
{#each group.indices as gi} {@const gb = streamBlocks[gi]} {#if gb.type === "tool_use"} -
- {#if gb.source === "evaluator"}评审{/if} - {gb.toolName ?? "Tool"} -
+
{gb.toolName ?? "Tool"}
{gb.text}
{:else if gb.type === "tool_result"}
{gb.text}
@@ -729,15 +581,57 @@
{/each}
- {:else} -
-
- {#if block.source === "evaluator"} - 评审 - {:else} - Agent - {/if} + {:else if block.type === "eval_result" && block.evalResult} + {@const er = block.evalResult} +
+
+ + {er.verdict === "pass" ? tt("evalPass") : tt("evalFail")} +
+ {#if Object.keys(er.scores).length > 0} +
+ {#each Object.entries(er.scores) as [dim, score]} +
+ {dim} +
+
= 6 && (score as number) < 7}>
+
+ {score}/10 +
+ {/each} +
+ {/if} + {#if er.issues.length > 0} +
+ {#each er.issues as issue} +
+ {issue.severity === "critical" ? tt("evalSeverityCritical") : issue.severity === "major" ? tt("evalSeverityMajor") : tt("evalSeverityMinor")} + {issue.description} +
+ {/each} +
+ {/if} + {#if er.suggestions.length > 0} +
+ {#each er.suggestions as s} +
{s}
+ {/each} +
+ {/if} + {#if er.verdict === "fail"} +
+ +
+ + +
+
+ {/if} +
+ {:else} +
+
{block.source === "evaluator" ? tt("evalReviewing") : "Agent"}
@@ -759,113 +653,37 @@
- {#if evalBlocked} -
-
- ⚠️ - 评审已达最大迭代次数 ({evalBlocked.attempt}/3) -
-
- -
- { if (e.key === "Enter" && guidanceText.trim()) handleRetryWithGuidance(); }} - /> - -
-
-
- {/if} - -
- {#if attachments.length > 0} -
- {#each attachments as att, i} - - {att.name.match(/\.(png|jpg|jpeg|gif|webp|svg)$/i) ? '🖼' : att.name.match(/\.(mp3|wav|ogg|m4a|aac)$/i) ? '🎵' : att.name.match(/\.(mp4|mov|webm)$/i) ? '🎬' : '📄'} - {att.name} - - - {/each} -
- {/if} - - {#if showAssetPicker} -
-
从素材库选择
-
- {#each CATS as cat} - - {/each} -
-
- {#each (pickerAssets[pickerCategory] ?? []) as asset} - - {/each} - {#if (pickerAssets[pickerCategory] ?? []).length === 0} -
暂无素材
- {/if} -
-
- -
- {/if} -
- - {#if streaming} + {#if streaming && !aborted} + {:else if aborted} + {:else} - {/if}
-
-
- - +
+
@@ -942,57 +760,6 @@ flex-shrink: 0; } - .header-tag.clickable { - cursor: pointer; - display: flex; - align-items: center; - gap: 0.2rem; - background: none; - font-family: inherit; - transition: all 0.12s; - } - .header-tag.clickable:hover { - border-color: var(--text-muted); - color: var(--text); - } - - .tag-dropdown-wrap { - position: relative; - flex-shrink: 0; - } - - .tag-dropdown { - position: absolute; - top: calc(100% + 4px); - left: 0; - background: var(--bg-elevated); - border: 1px solid var(--border); - border-radius: 6px; - box-shadow: var(--shadow-lg, 0 8px 24px rgba(0,0,0,0.15)); - z-index: 100; - min-width: 100px; - padding: 0.2rem; - animation: modalIn 0.1s ease; - } - - .tag-option { - display: block; - width: 100%; - text-align: left; - background: none; - border: none; - color: var(--text-muted); - font-family: inherit; - font-size: 0.72rem; - font-weight: 500; - padding: 0.35rem 0.6rem; - border-radius: 4px; - cursor: pointer; - transition: all 0.1s; - } - .tag-option:hover { background: rgba(148,163,184,0.08); color: var(--text); } - .tag-option.active { color: var(--spark-red, #FE2C55); font-weight: 650; } - .header-controls { display: flex; align-items: center; @@ -1030,48 +797,12 @@ min-width: 0; display: flex; flex-direction: column; - position: relative; } .panel-right { width: 320px; flex-shrink: 0; overflow: hidden; - position: relative; - transition: width 0.25s ease; - } - - .panel-right.panel-expanded { - width: 520px; - } - - .panel-expand-toggle { - position: absolute; - left: -16px; - top: 50%; - transform: translateY(-50%); - z-index: 10; - width: 16px; - height: 48px; - display: flex; - align-items: center; - justify-content: center; - background: var(--bg-surface); - border: 1px solid var(--border); - border-right: none; - border-radius: 6px 0 0 6px; - cursor: pointer; - color: var(--text-muted); - transition: all 0.15s; - padding: 0; - opacity: 0.6; - } - - .panel-expand-toggle:hover { - color: var(--text); - background: var(--accent-soft, rgba(254, 44, 85, 0.08)); - border-color: var(--accent, #FE2C55); - opacity: 1; } /* Stream area */ @@ -1308,8 +1039,6 @@ font-family: inherit; resize: none; line-height: 1.5; - max-height: 150px; - overflow-y: auto; } .msg-input:focus { outline: none; } .msg-input:disabled { opacity: 0.5; cursor: not-allowed; } @@ -1337,6 +1066,12 @@ } .send-btn.abort-mode:hover { opacity: 0.7; } + .send-btn.resume-mode { + color: var(--text); + opacity: 1; + } + .send-btn.resume-mode:hover { opacity: 0.7; } + /* AskUserQuestion options */ .ask-block { max-width: 90%; } @@ -1396,268 +1131,143 @@ line-height: 1.35; } - /* Attachment system */ - .attachment-bar { - display: flex; flex-wrap: wrap; gap: 0.3rem; padding: 0.4rem 0.6rem; - border-bottom: 1px solid var(--border); - } - .attachment-chip { - display: flex; align-items: center; gap: 0.25rem; - background: var(--bg-surface); border: 1px solid var(--border); border-radius: 6px; - padding: 0.2rem 0.4rem; font-size: 0.72rem; - } - .att-name { max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .att-remove { background: none; border: none; cursor: pointer; color: var(--text-dim); font-size: 0.65rem; padding: 0 0.15rem; } - .att-remove:hover { color: var(--spark-red); } - - .attach-btn { - background: none; border: none; cursor: pointer; font-size: 1.1rem; padding: 0.3rem; - color: var(--text-muted); transition: color 0.15s; - } - .attach-btn:hover { color: var(--text); } - - .asset-picker-popover { - position: absolute; bottom: 100%; left: 0; right: 0; - background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 8px; - box-shadow: 0 -4px 12px rgba(0,0,0,0.15); max-height: 280px; overflow: hidden; - display: flex; flex-direction: column; z-index: 100; - } - .picker-header { font-size: 0.75rem; font-weight: 600; padding: 0.5rem 0.6rem; color: var(--text-muted); } - .picker-cats { display: flex; gap: 0.2rem; padding: 0 0.5rem 0.4rem; flex-wrap: wrap; } - .picker-cat-btn { - font-size: 0.68rem; padding: 0.15rem 0.4rem; border-radius: 4px; - background: var(--bg-surface); border: 1px solid var(--border); color: var(--text-muted); cursor: pointer; - } - .picker-cat-btn.active { background: var(--spark-red); color: #fff; border-color: transparent; } - .picker-grid { - display: grid; grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); - gap: 0.3rem; padding: 0 0.5rem; overflow-y: auto; flex: 1; max-height: 160px; - } - .picker-item { - display: flex; flex-direction: column; align-items: center; gap: 0.15rem; - padding: 0.3rem; border-radius: 6px; border: 1px solid transparent; - background: none; cursor: pointer; color: var(--text); - } - .picker-item:hover { background: var(--bg-surface); border-color: var(--border); } - .picker-thumb { width: 48px; height: 48px; object-fit: cover; border-radius: 4px; } - .picker-icon { font-size: 1.5rem; } - .picker-name { font-size: 0.6rem; max-width: 60px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; } - .picker-empty { grid-column: 1/-1; text-align: center; color: var(--text-dim); font-size: 0.72rem; padding: 1rem; } - .picker-divider { height: 1px; background: var(--border); margin: 0.3rem 0.5rem; } - .picker-upload { - display: flex; align-items: center; gap: 0.3rem; padding: 0.4rem 0.6rem; - font-size: 0.72rem; color: var(--text-muted); cursor: pointer; - } - .picker-upload:hover { color: var(--text); } - - /* Responsive */ - @media (max-width: 1024px) { - .panel-right { display: none; } - .panel-expand-toggle { display: none; } - } - @media (max-width: 768px) { - .panel-left { display: none; } - .studio-body { border-radius: 12px; } - } - - /* ── Evaluator styling ── */ - .msg-evaluator { - border-left: 3px solid var(--amber, #f59e0b); - background: color-mix(in srgb, var(--amber, #f59e0b) 5%, transparent); - border-radius: 8px; - margin: 4px 0; - padding-left: 12px; - } - - .eval-badge { - display: inline-flex; - align-items: center; - padding: 1px 8px; - border-radius: 10px; - font-size: 11px; - font-weight: 600; - background: color-mix(in srgb, var(--amber, #f59e0b) 15%, transparent); - color: var(--amber, #f59e0b); - letter-spacing: 0.5px; - } - - /* Eval divider */ - .eval-divider { + /* Eval switch */ + .eval-switch-row { display: flex; align-items: center; - gap: 12px; - margin: 16px 0; - padding: 0 8px; + gap: 0.45rem; } - - .eval-divider-line { - flex: 1; - height: 1px; - background: color-mix(in srgb, var(--amber, #f59e0b) 30%, transparent); - } - - .eval-divider-label { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; - font-weight: 600; - color: var(--amber, #f59e0b); - white-space: nowrap; + .eval-switch-label { + font-size: 0.7rem; + font-weight: 500; + color: var(--text-dim); } - - .eval-divider.eval-pass .eval-divider-line { background: color-mix(in srgb, #22c55e 30%, transparent); } - .eval-divider.eval-pass .eval-divider-label { color: #22c55e; } - .eval-divider.eval-fail .eval-divider-line { background: color-mix(in srgb, #ef4444 30%, transparent); } - .eval-divider.eval-fail .eval-divider-label { color: #ef4444; } - - .eval-icon { font-size: 14px; font-weight: 700; } - - /* Eval toggle — impeccable design: clean, readable on both light/dark themes */ - .eval-toggle { + .eval-info { + position: relative; display: flex; align-items: center; - gap: 8px; - margin-left: 16px; - padding: 6px 14px; - border-radius: 20px; - background: rgba(0, 0, 0, 0.06); - border: 1px solid rgba(0, 0, 0, 0.08); - transition: all 0.2s ease; + color: var(--text-dim); + cursor: help; } - - .eval-toggle:hover { - background: rgba(0, 0, 0, 0.1); + .eval-tooltip { + position: absolute; + top: calc(100% + 6px); + right: 0; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 8px; + padding: 0.45rem 0.65rem; + font-size: 0.68rem; + font-weight: 400; + color: var(--text-secondary); + line-height: 1.45; + white-space: normal; + width: 220px; + z-index: 100; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + pointer-events: none; } - - .toggle-switch { + .switch { + width: 36px; + height: 20px; + border-radius: 10px; + background: var(--text-dim); + border: none; + cursor: pointer; position: relative; - display: inline-block; - width: 38px; - height: 22px; + transition: background 0.2s ease; flex-shrink: 0; + padding: 0; } - - .toggle-switch input { opacity: 0; width: 0; height: 0; position: absolute; } - - .toggle-slider { - position: absolute; - inset: 0; - background: #ccc; - border-radius: 11px; - cursor: pointer; - transition: background 0.25s ease; - } - - .toggle-slider::before { - content: ""; + .switch.on { background: var(--spark-red, #FE2C55); } + .switch-thumb { position: absolute; - width: 18px; - height: 18px; + top: 2px; left: 2px; - bottom: 2px; - background: white; + width: 16px; + height: 16px; border-radius: 50%; - transition: transform 0.25s ease; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - } - - .toggle-switch input:checked + .toggle-slider { - background: #f59e0b; - } - - .toggle-switch input:checked + .toggle-slider::before { - transform: translateX(16px); + background: #fff; + transition: transform 0.2s ease; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); } + .switch.on .switch-thumb { transform: translateX(16px); } - .toggle-label { - font-size: 13px; - color: #555; - font-weight: 600; - letter-spacing: 0.3px; - user-select: none; + /* Evaluator blocks */ + .evaluator-block { + border-left: 2px solid #f59e0b; + padding-left: 0.75rem; + margin-left: 0.25rem; } + .evaluator-block .block-label { color: #f59e0b; } - /* Eval blocked panel */ - .eval-blocked-panel { - margin: 8px 16px; - padding: 16px; + /* Eval result card */ + .eval-result-block { + background: var(--bg-surface); + border: 1.5px solid var(--border); border-radius: 12px; - background: #fef2f2; - border: 1px solid #fecaca; - animation: slideUp 0.3s ease-out; - } - - @keyframes slideUp { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } - } - - .eval-blocked-header { - display: flex; - align-items: center; - gap: 8px; - font-size: 14px; - font-weight: 600; - color: #991b1b; - margin-bottom: 12px; - } - - .eval-blocked-icon { font-size: 18px; } - - .eval-blocked-actions { + padding: 0.85rem; display: flex; flex-direction: column; - gap: 8px; - } - - .eval-btn { - padding: 8px 16px; - border-radius: 8px; - border: none; - font-size: 13px; - font-weight: 600; - cursor: pointer; - transition: all 0.15s; + gap: 0.65rem; } + .eval-result-block.eval-pass { border-color: rgba(34, 197, 94, 0.4); } + .eval-result-block.eval-fail { border-color: rgba(239, 68, 68, 0.4); } - .eval-btn-pass { - background: #fef3c7; - color: #92400e; - width: fit-content; - border: 1px solid #fcd34d; + .eval-header { display: flex; align-items: center; gap: 0.5rem; } + .eval-verdict-badge { + font-size: 0.75rem; + font-weight: 700; + padding: 0.2rem 0.6rem; + border-radius: 6px; } - - .eval-btn-pass:hover { background: #fde68a; } - - .eval-guidance-row { - display: flex; - gap: 8px; + .eval-verdict-badge.pass { background: rgba(34, 197, 94, 0.15); color: #16a34a; } + .eval-verdict-badge.fail { background: rgba(239, 68, 68, 0.15); color: #dc2626; } + + .eval-scores { display: flex; flex-direction: column; gap: 0.3rem; } + .score-row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.75rem; } + .score-dim { width: 5rem; color: var(--text-muted); font-weight: 500; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .score-bar-bg { flex: 1; height: 6px; background: var(--bg-inset); border-radius: 3px; overflow: hidden; } + .score-bar-fill { height: 100%; border-radius: 3px; background: #22c55e; transition: width 0.3s; } + .score-bar-fill.score-low { background: #ef4444; } + .score-bar-fill.score-mid { background: #f59e0b; } + .score-value { font-weight: 650; font-variant-numeric: tabular-nums; min-width: 2.5rem; text-align: right; } + .score-value.score-low { color: #ef4444; } + + .eval-issues { display: flex; flex-direction: column; gap: 0.25rem; } + .issue-item { font-size: 0.74rem; line-height: 1.4; display: flex; gap: 0.4rem; align-items: baseline; } + .issue-severity { + font-size: 0.62rem; font-weight: 700; text-transform: uppercase; padding: 0.08rem 0.35rem; border-radius: 4px; flex-shrink: 0; + } + .issue-critical .issue-severity { background: rgba(239, 68, 68, 0.15); color: #dc2626; } + .issue-major .issue-severity { background: rgba(245, 158, 11, 0.15); color: #d97706; } + .issue-minor .issue-severity { background: rgba(148, 163, 184, 0.15); color: var(--text-muted); } + + .eval-suggestions { display: flex; flex-direction: column; gap: 0.2rem; } + .suggestion-item { font-size: 0.74rem; color: var(--text-muted); line-height: 1.4; padding-left: 0.75rem; position: relative; } + .suggestion-item::before { content: "→"; position: absolute; left: 0; color: var(--text-dim); } + + .eval-actions { display: flex; flex-direction: column; gap: 0.5rem; padding-top: 0.35rem; border-top: 1px solid var(--border); } + .eval-btn { + font-family: inherit; font-size: 0.78rem; font-weight: 600; padding: 0.4rem 0.85rem; border-radius: 8px; cursor: pointer; border: 1.5px solid var(--border); background: var(--bg-surface); color: var(--text); transition: all 0.12s; } - - .eval-guidance-input { - flex: 1; - padding: 8px 12px; - border-radius: 8px; - border: 1px solid #d1d5db; - background: white; - color: #333; - font-size: 13px; - outline: none; - transition: border-color 0.15s, box-shadow 0.15s; + .eval-btn:hover { background: var(--bg-hover); } + .eval-btn:disabled { opacity: 0.4; cursor: not-allowed; } + .eval-force-pass { align-self: flex-start; } + .eval-retry { flex-shrink: 0; } + .eval-retry-row { display: flex; gap: 0.4rem; align-items: flex-end; } + .eval-retry-input { + flex: 1; font-family: inherit; font-size: 0.78rem; padding: 0.4rem 0.65rem; border: 1.5px solid var(--border); border-radius: 8px; background: var(--bg-inset); color: var(--text); resize: none; line-height: 1.4; } + .eval-retry-input:focus { outline: none; border-color: var(--text-muted); } + .eval-retry-input::placeholder { color: var(--text-dim); } - .eval-guidance-input:focus { - border-color: #f59e0b; - box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.15); + /* Responsive */ + @media (max-width: 1024px) { + .panel-right { display: none; } } - - .eval-btn-retry { - background: #f59e0b; - color: white; - white-space: nowrap; + @media (max-width: 768px) { + .panel-left { display: none; } + .studio-body { border-radius: 12px; } } - - .eval-btn-retry:hover { opacity: 0.9; } - .eval-btn-retry:disabled { opacity: 0.4; cursor: not-allowed; } diff --git a/web/src/pages/Works.svelte b/web/src/pages/Works.svelte index 95cc3a11..9f30e1e2 100644 --- a/web/src/pages/Works.svelte +++ b/web/src/pages/Works.svelte @@ -3,7 +3,6 @@ import { t, getLanguage, subscribe } from "../lib/i18n"; import { fetchWorks, deleteWorkApi, type WorkSummary } from "../lib/api"; import InterestTags from "../components/InterestTags.svelte"; - import AssetLibrary from "../components/AssetLibrary.svelte"; let { onOpenStudio, @@ -308,10 +307,10 @@ function dispatchCreate(dir: TrendDirection) { const hint = [ - dir.emotionType ? `目标情绪: ${dir.emotionType}${dir.emotionSubtype ? `(${dir.emotionSubtype})` : ""}` : "", + dir.emotionType ? `${tt("labelTargetEmotion")}: ${dir.emotionType}${dir.emotionSubtype ? `(${dir.emotionSubtype})` : ""}` : "", dir.description, - dir.contentAngles?.length ? `切入角度: ${dir.contentAngles.join("; ")}` : "", - dir.exampleHook ? `爆款钩子: ${dir.exampleHook}` : "", + dir.contentAngles?.length ? `${tt("labelContentAngle")}: ${dir.contentAngles.join("; ")}` : "", + dir.exampleHook ? `${tt("labelViralHookColon")}: ${dir.exampleHook}` : "", dir.tags?.length ? dir.tags.map(t => "#" + t).join(" ") : "", ].filter(Boolean).join("\n"); onCreateFromTrend(dir.title, hint); @@ -336,8 +335,6 @@
- -

标题{tt('colTitle')} toggleSort("create_time")}> - 日期 {sortCol === "create_time" ? (sortAsc ? "↑" : "↓") : ""} + {tt('colDate')} {sortCol === "create_time" ? (sortAsc ? "↑" : "↓") : ""} toggleSort("play_count")}> - 播放 {sortCol === "play_count" ? (sortAsc ? "↑" : "↓") : ""} + {tt('colPlays')} {sortCol === "play_count" ? (sortAsc ? "↑" : "↓") : ""} toggleSort("digg_count")}> - 点赞 {sortCol === "digg_count" ? (sortAsc ? "↑" : "↓") : ""} + {tt('colLikes')} {sortCol === "digg_count" ? (sortAsc ? "↑" : "↓") : ""} toggleSort("comment_count")}> - 评论 {sortCol === "comment_count" ? (sortAsc ? "↑" : "↓") : ""} + {tt('colComments')} {sortCol === "comment_count" ? (sortAsc ? "↑" : "↓") : ""} toggleSort("share_count")}> - 分享 {sortCol === "share_count" ? (sortAsc ? "↑" : "↓") : ""} + {tt('colShares')} {sortCol === "share_count" ? (sortAsc ? "↑" : "↓") : ""} 热度{tt('colHeat')}
{#if i < 3} - + {tt('viralBadge')} {/if} - {work.desc || "(无标题)"} + {work.desc || tt('noTitle')} {fmtDate(work.create_time)} {fmtNum(work.play_count)}