From 979d2bcb94afda263f9c0bc223b2e7dd9246f887 Mon Sep 17 00:00:00 2001 From: Hong Phuc Date: Wed, 20 May 2026 00:13:38 +0700 Subject: [PATCH 01/14] chore: add gstack skill routing rules to CLAUDE.md Co-Authored-By: Claude Opus 4.7 --- .agents/skills/create-news-video/SKILL.md | 439 ++++++ .claude/skills/create-news-video/SKILL.md | 165 ++- .env.example | 10 + CLAUDE.md | 19 + CONTEXT.md | 32 + package-lock.json | 1560 ++++++++++++++++----- package.json | 19 +- rerender.ts | 3 + src/config.test.ts | 5 + src/config.ts | 28 +- src/llm/anthropic-client.ts | 125 ++ src/llm/llm-client.ts | 38 + src/llm/openai-compatible-client.ts | 147 ++ src/llm/web-fetcher.ts | 54 + src/render/script-schema.ts | 2 +- src/server.test.ts | 73 + src/server.ts | 456 ++++++ src/ui/app.js | 194 +++ src/ui/index.html | 68 + src/ui/styles.css | 317 +++++ 20 files changed, 3335 insertions(+), 419 deletions(-) create mode 100644 .agents/skills/create-news-video/SKILL.md create mode 100644 CLAUDE.md create mode 100644 CONTEXT.md create mode 100644 src/llm/anthropic-client.ts create mode 100644 src/llm/llm-client.ts create mode 100644 src/llm/openai-compatible-client.ts create mode 100644 src/llm/web-fetcher.ts create mode 100644 src/server.test.ts create mode 100644 src/server.ts create mode 100644 src/ui/app.js create mode 100644 src/ui/index.html create mode 100644 src/ui/styles.css diff --git a/.agents/skills/create-news-video/SKILL.md b/.agents/skills/create-news-video/SKILL.md new file mode 100644 index 0000000..e4c07fc --- /dev/null +++ b/.agents/skills/create-news-video/SKILL.md @@ -0,0 +1,439 @@ +--- +name: create-news-video +description: Tạo video tin tức ngắn 9:16 (~60s) từ URL bài báo hoặc file .txt tiếng Việt. Trigger khi user yêu cầu tạo video tin tức, làm short news, làm bản tin video, render tin thành video, làm TikTok tin tức. Output: video.mp4 + voice.mp3 + script.txt cho CapCut. +--- + +# Create News Video Skill + +Generate a Vietnamese 9:16 motion-graphic news video from a URL or .txt file. + +## Input + +Single argument: a news article URL (starts with `http://` or `https://`) OR a path to a `.txt` file. + +## Workflow (MUST follow these steps in order) + +### Step 1: Detect input type + +- Starts with `http://` or `https://` → URL mode +- Otherwise → file mode + +### Step 2: Fetch content + +**URL mode:** +- Use `WebFetch` with prompt: + ``` + Trích xuất từ trang này: + - title (string): tiêu đề bài báo + - content (string): nội dung chính, ~500-1500 từ + - ogImage (string|null): URL ảnh og:image (meta og:image hoặc ảnh đầu bài) + - domain (string): domain của URL (vd "vnexpress.net") + Trả về JSON với 4 field trên. + ``` +- If WebFetch fails (paywall, JS-rendered, 4xx) → tell user to save content to a .txt file and pass that instead. Stop. + +**File mode:** +- Use `Read` to read the .txt file +- Title = first non-empty line (strip whitespace, max 80 chars) +- Content = remaining lines joined +- ogImage = `null` +- domain = `"local"` + +### Step 3: Create slug + output directory + +- slug = lowercase ASCII (strip Vietnamese diacritics, đ→d), replace non-alphanumeric with `-`, trim dashes, max 40 chars +- timestamp = current local time as `YYYYMMDD-HHmm` +- outputDir = `output/-/` +- Use Bash: `mkdir -p ` + +### Step 4: Generate script.json + +Following the schema in `src/render/script-schema.ts` (Zod discriminated union, 6 templates). Key rules: + +**Script content (Vietnamese):** +- Total voiceText: ~150–200 words → ~55–65s spoken at speed 1.0 +- Number of scenes: **5–8** (1 hook + 3–6 body + 1 outro) +- Each scene voiceText is 1-3 short sentences, văn nói (spoken style, not formal) +- No emoji, no markdown in voiceText + +### ⚠️ CRITICAL: Vietnamese TTS Phonetic Rules + +The `voiceText` field is read aloud by LucyLab/ElevenLabs Vietnamese TTS. **Numbers and symbols are read literally** — if you write "5.5", TTS may say "năm rưỡi" (five and a half — WRONG for version numbers). **Always spell out numbers in Vietnamese phonetic form** in `voiceText`. The `templateData` fields (visual text on screen) can keep the original "5.5" / "82.7%" formatting. + +**Mandatory rules for `voiceText`:** + +| Number form | WRONG (TTS misreads) | RIGHT (spell out in Vietnamese) | +|---|---|---| +| Decimal version | `GPT 5.5` → "năm rưỡi" ❌ | `GPT năm chấm năm` ✅ | +| Decimal stat | `82.7%` | `tám mươi hai phẩy bảy phần trăm` | +| Version | `iPhone 17` | `iPhone mười bảy` (or `iPhone 17` works for whole numbers) | +| Version with point | `iOS 18.2` | `iOS mười tám chấm hai` | +| Tech spec | `200MP` | `hai trăm megapixel` | +| Battery | `5000mAh` | `năm nghìn miliampe giờ` | +| Tokens | `1M tokens` / `1000000 tokens` | `một triệu token` | +| Price VND | `21 triệu đồng` | `hai mươi mốt triệu đồng` | +| Price USD | `$5` | `năm đô la` (or `năm đô`) | +| Multiplier | `2x` | `gấp đôi` (more natural than "hai lần") | +| Year | `2026` | `hai nghìn không trăm hai mươi sáu` (or just `năm 2026` reads OK) | +| Percentage with decimal | `30%` | `ba mươi phần trăm` | +| Time | `60 giây` | `sáu mươi giây` | +| Frequency | `5G` | `năm gờ` (be careful — TTS often says "năm-gờ") | + +**Notation choices:** +- For decimal point use `chấm` (more spoken/natural) or `phẩy` (formal). Both work; pick consistent. +- For comma separator, use `phẩy` (e.g. "1,000" → "một nghìn") +- For ratio "3:1" → say `ba trên một` or `ba so với một` + +**English brand names — keep as-is**, TTS handles them OK: +- `Apple`, `Google`, `OpenAI`, `Microsoft`, `TikTok`, `YouTube` ✅ + +**English acronyms — write phonetically if TTS misreads:** +- `AI` → usually OK, sometimes write `ây ai` for clarity +- `API` → write `ây pi ai` if matter +- `GPT` → usually OK; if not, write `gí pi tí` +- `iOS` → write `ai ô ét` if matter + +**Symbols to AVOID in voiceText:** +- `→` `&` `%` `$` `#` `+` `=` (TTS may say literal name or skip) +- `!` `?` at end of sentence is OK — they create natural intonation +- Emoji: NEVER (TTS pronounces or skips inconsistently) +- URLs: NEVER (TTS reads dot/slash literally) + +**End each `voiceText` sentence with `.` or `?`** for natural pause/intonation. + +**Examples — full scene:** + +WRONG (will sound bad): +```json +{ "voiceText": "GPT 5.5 đạt 82.7% trên Terminal-Bench, vượt GPT 5.4 (75.1%)." } +``` +→ TTS reads: "GPT năm rưỡi đạt tám mươi hai chấm bảy phần trăm trên Terminal-Bench..." + +RIGHT (natural): +```json +{ "voiceText": "GPT năm chấm năm đạt tám mươi hai phẩy bảy phần trăm trên Terminal Bench, vượt phiên bản năm chấm bốn ở mức bảy mươi lăm phẩy một." } +``` + +**Note**: `templateData` (text on screen) CAN use original formatting — the visual is separate from spoken: +```json +{ + "voiceText": "GPT năm chấm năm đạt tám mươi hai phẩy bảy phần trăm.", + "templateData": { + "template": "stat-hero", + "value": "82.7%", ← Visual: keep readable formatting + "label": "Terminal-Bench" + } +} +``` + +**Hook (most important — gets first 3 seconds of viewer attention):** +- Must contain a claim, statistic, or curious question +- NEVER generic ("Hôm nay chúng ta sẽ nói về..." is wrong) +- When source has og:image, set `bgSrc: "$source.image"` and pick a `kenBurns` effect +- When no image, omit `bgSrc` — pipeline uses gradient fallback + +**TemplateData rules (6 available templates):** + +| Template | When to pick | Required fields | +|---|---|---| +| `hook` | First scene (3-5s) | `headline` (max 40), `subhead?` (max 40), `bgSrc?`, `kenBurns?` | +| `comparison` | "X vs Y" / "exceeds" / "compared to" | `left: {label, value, color}`, `right: {label, value, color, winner?}` | +| `stat-hero` | Key number / % stat | `value` (max 20), `label` (max 40), `context?` (max 50) | +| `feature-list` | Listing features (1-4 bullets) | `title` (max 40), `bullets[]` (max 50 each), `icon?` | +| `callout` | Statement / warning / quote | `statement` (max 80), `tag?` (max 20) | +| `outro` | Last scene (3-5s) | `ctaTop` (max 30), `channelName` (max 30), `source` (max 40) | + +- Pick templates based on content signal, not arbitrarily — each story beat dictates its template +- Vary `kenBurns` across hook scenes (values: `zoom-in`, `zoom-out`, `pan-left`, `pan-right`; default `zoom-in`) + +**Outro (always fixed format):** +```json +{ + "id": "outro", + "type": "outro", + "voiceText": "Theo dõi Công nghệ 24h để xem bản tin mới mỗi ngày.", + "templateData": { + "template": "outro", + "ctaTop": "Xem bản tin mới mỗi ngày", + "channelName": "Công nghệ 24h", + "source": "" + } +} +``` +Replace `` with the actual domain string (e.g. `"vnexpress.net"`). `ctaTop` max 30 chars — shorten the full CTA if needed. + +### Step 5: Self-validate before writing + +Check: +- Total voiceText words: ~150-200 +- 5-8 scenes total (1 hook + 3-6 body + 1 outro) +- scenes[0].type === "hook", scenes[last].type === "outro" +- Every templateData has required fields for its template (see table above) +- voiceText: numbers spelled phonetically, no emoji, no URLs, no markdown +- `voice.provider`: "lucylab" or "elevenlabs" +- Hook with og:image → set `bgSrc: "$source.image"`; no image → omit `bgSrc` + +If invalid, fix yourself silently. Up to 2 self-correction passes. After that, write anyway — the CLI's Zod validation will produce a precise error message that the user can act on. + +### Step 6: Write script.json + +Use the Write tool (not Bash) to write the validated JSON to `/script.json`. + +### Step 7: Run the pipeline + +Use Bash, **foreground** (not background), stream output: + +```bash +npm run pipeline -- /script.json +``` + +If exit code != 0: +- Report the error message clearly +- Tell user the output dir path so they can inspect intermediate files + +### Step 8: Report success + +If successful, report to user with markdown links: + +```markdown +✓ Video: [video.mp4](output/-/video.mp4) +✓ Audio: [voice.mp3](output/-/voice.mp3) — for CapCut +✓ Script: [script.txt](output/-/script.txt) — for CapCut auto-caption +Tổng thời lượng: XX.Xs +``` + +## Examples + +### Example 1: URL with image (vnexpress) + +User: `/create-news-video https://vnexpress.net/iphone-17-200mp` + +Generated `script.json`: +```json +{ + "version": "1.0", + "metadata": { + "title": "Apple ra mắt iPhone 17 với camera 200MP", + "source": { + "url": "https://vnexpress.net/iphone-17-200mp", + "domain": "vnexpress.net", + "image": "https://i1-vnexpress.vnecdn.net/iphone17.jpg" + }, + "channel": "Công nghệ 24h" + }, + "voice": { "provider": "lucylab", "voiceId": "${VIETNAMESE_VOICEID}", "speed": 1.0 }, + "scenes": [ + { + "id": "hook", "type": "hook", + "voiceText": "Apple vừa ra mắt iPhone 17 với camera hai trăm megapixel.", + "templateData": { + "template": "hook", + "headline": "iPhone 17", + "subhead": "Camera 200MP!", + "bgSrc": "$source.image", + "kenBurns": "zoom-in" + }, + "sfx": { "name": "cinematic/impact", "volume": 0.5 } + }, + { + "id": "body-1", "type": "body", + "voiceText": "Cảm biến hoàn toàn mới cho zoom quang học gấp mười lần, vượt mọi đối thủ Android.", + "templateData": { + "template": "stat-hero", + "value": "200MP", + "label": "Cảm biến mới", + "context": "Zoom quang học 10x" + } + }, + { + "id": "body-2", "type": "body", + "voiceText": "Pin năm nghìn miliampe giờ, tăng ba mươi phần trăm so với đời cũ. Sạc nhanh sáu mươi lăm watt.", + "templateData": { + "template": "feature-list", + "title": "Nâng cấp lớn", + "bullets": ["Pin 5000mAh", "Tăng 30%", "Sạc nhanh 65W"], + "icon": "spark" + } + }, + { + "id": "body-3", "type": "body", + "voiceText": "Giá khởi điểm hai mươi mốt triệu đồng, dự kiến mở bán tại Việt Nam vào tháng sau.", + "templateData": { + "template": "callout", + "statement": "Giá từ 21 triệu đồng, mở bán tháng 5.", + "tag": "Giá bán" + } + }, + { + "id": "outro", "type": "outro", + "voiceText": "Theo dõi Công nghệ 24h để xem bản tin mới mỗi ngày.", + "templateData": { + "template": "outro", + "ctaTop": "Theo dõi ngay", + "channelName": "Công nghệ 24h", + "source": "vnexpress.net" + } + } + ] +} +``` + +### Example 2: .txt file with no image (local) + +User: `/create-news-video news/agi-update.txt` + +Generated `script.json`: +```json +{ + "version": "1.0", + "metadata": { + "title": "OpenAI công bố mô hình mới với khả năng lập luận", + "source": { "url": "local", "domain": "local", "image": null }, + "channel": "Công nghệ 24h" + }, + "voice": { "provider": "lucylab", "voiceId": "${VIETNAMESE_VOICEID}", "speed": 1.0 }, + "scenes": [ + { + "id": "hook", "type": "hook", + "voiceText": "OpenAI vừa công bố mô hình mới có khả năng lập luận như con người.", + "templateData": { + "template": "hook", + "headline": "Mô hình mới", + "subhead": "Lập luận như người" + } + }, + { + "id": "body-1", "type": "body", + "voiceText": "Mô hình đạt chín mươi hai phẩy bảy phần trăm trên benchmark, vượt xa phiên bản cũ.", + "templateData": { + "template": "stat-hero", + "value": "92.7%", + "label": "Benchmark", + "context": "Vượt phiên bản cũ 75.1%" + } + }, + { + "id": "body-2", "type": "body", + "voiceText": "Hệ thống có thể tự suy luận đa bước, kiểm tra logic và sửa sai trước khi trả lời.", + "templateData": { + "template": "feature-list", + "title": "Khả năng mới", + "bullets": ["Suy luận đa bước", "Tự kiểm tra logic", "Tự sửa lỗi"] + } + }, + { + "id": "outro", "type": "outro", + "voiceText": "Theo dõi Công nghệ 24h để xem bản tin mới mỗi ngày.", + "templateData": { + "template": "outro", + "ctaTop": "Xem bản tin mới mỗi ngày", + "channelName": "Công nghệ 24h", + "source": "local" + } + } + ] +} +``` +Note: when source has no image, omit `bgSrc` from the hook — the pipeline uses a gradient fallback automatically. + +## Sound Effects (SFX) + +**You almost never need to set the `sfx` field.** The pipeline has a smart 3-tier selector that picks the right SFX for each scene automatically: + +1. **If `scene.sfx` is set** → use exactly that (override). +2. **Else, scan `voiceText` for semantic keywords**: + - `cảnh báo / rủi ro / nguy hiểm / warning` → `alert/` + - `kỷ lục / vượt / xuất sắc / breakthrough / success` → `success/` + - `thất bại / sai / lỗi / fail / wrong` → `fail/` + - `ra mắt / công bố / lần đầu / launch / unveil` → `reveal/` + - `đếm ngược / tích tắc / countdown` → `countdown/` + - `hùng vĩ / hoành tráng / cinematic / epic` → `cinematic/` + - `hồi hộp / chờ đợi / drumroll / suspense` → `drumroll/` +3. **Else, fall back to template default category**: + - `hook` → `transition/` or `cinematic/` + - `comparison` → `transition/` or `emphasis/` + - `stat-hero` → `emphasis/` or `success/` + - `feature-list` → `transition/` or `emphasis/` + - `callout` → `alert/` or `drumroll/` + - `outro` → `outro/` or `success/` + +Within a category, the actual file is picked **deterministically** by hashing the scene id — same script gives same SFX (idempotent), but different scenes in the same video get different files (variety). + +**This means:** in 95% of cases you should OMIT the `sfx` field entirely. Just write good Vietnamese voiceText with natural keywords (warning, breakthrough, launch, etc.) and the pipeline will pick the right sound. + +### When to add explicit `sfx` override + +Only when you want to FORCE a specific sound that the keyword matcher won't infer: +- Scene needs a particular signature sound (e.g., always a gong on important scenes) +- Disable SFX for a particular scene: `"sfx": { "name": "none" }` +- Use a specific file: `"sfx": { "name": "transition/whoosh-sfx", "volume": 0.4 }` + +Example (rarely needed): +```json +{ + "id": "body-3", + "voiceText": "...", + "templateData": { ... }, + "sfx": { "name": "drumroll/snare-roll", "volume": 0.5, "startOffsetSec": 0.2 } +} +``` + +The pipeline auto-mixes a sound effect at each scene start based on the template type: + +| Template | Default SFX | Sound character | +|---|---|---| +| `hook` | `transition/whoosh-soft` | Dramatic entrance | +| `comparison` | `transition/swoosh` | Side-by-side reveal | +| `stat-hero` | `emphasis/ding` | Number reveal | +| `feature-list` | `transition/pop` | Bullet appearance | +| `callout` | `alert/notification` | Important info | +| `outro` | `outro/tada` | Ending signature | + +**You usually do NOT need to add a `sfx` field** — defaults work for 95% of cases. + +**ONLY add an explicit `sfx` override when content STRONGLY suggests a different mood:** + +| Content cue (in voiceText) | Override | +|---|---| +| "cảnh báo", "rủi ro", "đáng lo", "nguy hiểm" | `{ "name": "alert/notification", "volume": 0.4 }` | +| "vượt", "kỷ lục", "xuất sắc", "tăng mạnh" (positive stat) | `{ "name": "emphasis/chime", "volume": 0.35 }` | +| Want to disable SFX for this scene | `{ "name": "none" }` | + +Place `sfx` at the same level as `voiceText` and `templateData`: + +```json +{ + "id": "body-3", + "type": "body", + "voiceText": "Cảnh báo: AI tự chủ có thể đặt ra rủi ro về an ninh mạng.", + "templateData": { "template": "callout", ... }, + "sfx": { "name": "alert/notification", "volume": 0.4 } +} +``` + +Available SFX categories (any `` subfolder in `assets/sfx//.mp3`): +- `transition/` — whoosh, swoosh, swish, pop, punch, page-flip, slide, riser +- `emphasis/` — ding, tick, chime, ping, bong, pop, punch +- `alert/` — notification, alert, alarm, warning +- `success/` — applepay, achievement, win, xbox, steam, jet-set +- `fail/` — wrong-answer-buzzer, incorrect, error, dank-meme +- `outro/` — tada, win31, noooo +- `reveal/` — magic-fairy, anime-girl, hey-female-voice +- `drumroll/` — snare, drum-roll, boom +- `countdown/` — beep, timer +- `cinematic/` — rise, impact + +Browse `assets/sfx//` to see exact filenames. Reference WITHOUT the `.mp3` extension. Example: +```json +{ "sfx": { "name": "success/xbox-360-achievement-sound", "volume": 0.4 } } +``` + +## Edge cases + +| Situation | Action | +|---|---| +| URL paywall / JS-rendered → WebFetch returns no content | Tell user: "Không đọc được URL (có thể do paywall hoặc JS). Hãy lưu nội dung vào file .txt rồi gọi lại." Stop. | +| URL content < 200 words | Warn "Tin gốc ngắn, video có thể không đủ chất liệu", continue anyway | +| URL content > 2000 words | Summarize to key points, fit ~150-200 words script | +| File mode + file empty/missing | Error message, don't create output dir | +| Pipeline fails | Report error message + output dir path; user can re-try `npm run pipeline -- ` after fixing | diff --git a/.claude/skills/create-news-video/SKILL.md b/.claude/skills/create-news-video/SKILL.md index 287e4f6..e4c07fc 100644 --- a/.claude/skills/create-news-video/SKILL.md +++ b/.claude/skills/create-news-video/SKILL.md @@ -48,7 +48,7 @@ Single argument: a news article URL (starts with `http://` or `https://`) OR a p ### Step 4: Generate script.json -Following the schema in `docs/superpowers/specs/2026-04-29-auto-news-video-design.md` Section 4. Key rules: +Following the schema in `src/render/script-schema.ts` (Zod discriminated union, 6 templates). Key rules: **Script content (Vietnamese):** - Total voiceText: ~150–200 words → ~55–65s spoken at speed 1.0 @@ -129,14 +129,22 @@ RIGHT (natural): **Hook (most important — gets first 3 seconds of viewer attention):** - Must contain a claim, statistic, or curious question - NEVER generic ("Hôm nay chúng ta sẽ nói về..." is wrong) -- ALWAYS include at least 1 effect: `flash-white-3f` or `particle-burst` +- When source has og:image, set `bgSrc: "$source.image"` and pick a `kenBurns` effect +- When no image, omit `bgSrc` — pipeline uses gradient fallback -**Visual rules:** -- For image scenes: `background.src = "$source.image"` (literal — CLI substitutes) -- Vary `kenBurns` across scenes (don't use `zoom-in` for every scene) -- Vary text `animation` (don't use `slide-up` for every line) -- Each line ≤ 25 characters -- Each scene 1-3 lines +**TemplateData rules (6 available templates):** + +| Template | When to pick | Required fields | +|---|---|---| +| `hook` | First scene (3-5s) | `headline` (max 40), `subhead?` (max 40), `bgSrc?`, `kenBurns?` | +| `comparison` | "X vs Y" / "exceeds" / "compared to" | `left: {label, value, color}`, `right: {label, value, color, winner?}` | +| `stat-hero` | Key number / % stat | `value` (max 20), `label` (max 40), `context?` (max 50) | +| `feature-list` | Listing features (1-4 bullets) | `title` (max 40), `bullets[]` (max 50 each), `icon?` | +| `callout` | Statement / warning / quote | `statement` (max 80), `tag?` (max 20) | +| `outro` | Last scene (3-5s) | `ctaTop` (max 30), `channelName` (max 30), `source` (max 40) | + +- Pick templates based on content signal, not arbitrarily — each story beat dictates its template +- Vary `kenBurns` across hook scenes (values: `zoom-in`, `zoom-out`, `pan-left`, `pan-right`; default `zoom-in`) **Outro (always fixed format):** ```json @@ -144,31 +152,26 @@ RIGHT (natural): "id": "outro", "type": "outro", "voiceText": "Theo dõi Công nghệ 24h để xem bản tin mới mỗi ngày.", - "visual": { - "background": { "type": "gradient", "preset": "outro-purple" }, - "text": { - "position": "center", - "style": "outro-card", - "lines": [ - { "content": "Xem bản tin mới mỗi ngày", "emphasis": "primary", "animation": "fade-in" }, - { "content": "Công nghệ 24h", "emphasis": "channel", "animation": "scale-pop" }, - { "content": "Nguồn: ", "emphasis": "muted", "animation": "fade-in-late" } - ] - } + "templateData": { + "template": "outro", + "ctaTop": "Xem bản tin mới mỗi ngày", + "channelName": "Công nghệ 24h", + "source": "" } } ``` -Replace `` with the actual domain string. Note: outro line 1 is shortened to fit 25-char schema rule (full CTA "Theo dõi để xem bản tin mới mỗi ngày" is 36 chars). +Replace `` with the actual domain string (e.g. `"vnexpress.net"`). `ctaTop` max 30 chars — shorten the full CTA if needed. ### Step 5: Self-validate before writing Check: -- Total word count ~150-200 -- Every line.content ≤ 25 chars -- 5-8 scenes total -- scenes[0].type === "hook" -- last scene type === "outro" -- All enum values valid (see spec Section 4.2) +- Total voiceText words: ~150-200 +- 5-8 scenes total (1 hook + 3-6 body + 1 outro) +- scenes[0].type === "hook", scenes[last].type === "outro" +- Every templateData has required fields for its template (see table above) +- voiceText: numbers spelled phonetically, no emoji, no URLs, no markdown +- `voice.provider`: "lucylab" or "elevenlabs" +- Hook with og:image → set `bgSrc: "$source.image"`; no image → omit `bgSrc` If invalid, fix yourself silently. Up to 2 self-correction passes. After that, write anyway — the CLI's Zod validation will produce a precise error message that the user can act on. @@ -205,7 +208,7 @@ Tổng thời lượng: XX.Xs User: `/create-news-video https://vnexpress.net/iphone-17-200mp` -Generated `script.json` (excerpt): +Generated `script.json`: ```json { "version": "1.0", @@ -223,20 +226,54 @@ Generated `script.json` (excerpt): { "id": "hook", "type": "hook", "voiceText": "Apple vừa ra mắt iPhone 17 với camera hai trăm megapixel.", - "visual": { - "background": { "type": "image", "src": "$source.image", "kenBurns": "zoom-in" }, - "overlay": { "darkness": 0.4 }, - "text": { - "position": "center", "style": "hook-large", - "lines": [ - { "content": "iPhone 17", "emphasis": "primary", "animation": "scale-pop" }, - { "content": "Camera 200MP!", "emphasis": "accent", "animation": "slide-up-bounce" } - ] - }, - "effects": ["flash-white-3f", "particle-burst"] + "templateData": { + "template": "hook", + "headline": "iPhone 17", + "subhead": "Camera 200MP!", + "bgSrc": "$source.image", + "kenBurns": "zoom-in" + }, + "sfx": { "name": "cinematic/impact", "volume": 0.5 } + }, + { + "id": "body-1", "type": "body", + "voiceText": "Cảm biến hoàn toàn mới cho zoom quang học gấp mười lần, vượt mọi đối thủ Android.", + "templateData": { + "template": "stat-hero", + "value": "200MP", + "label": "Cảm biến mới", + "context": "Zoom quang học 10x" + } + }, + { + "id": "body-2", "type": "body", + "voiceText": "Pin năm nghìn miliampe giờ, tăng ba mươi phần trăm so với đời cũ. Sạc nhanh sáu mươi lăm watt.", + "templateData": { + "template": "feature-list", + "title": "Nâng cấp lớn", + "bullets": ["Pin 5000mAh", "Tăng 30%", "Sạc nhanh 65W"], + "icon": "spark" + } + }, + { + "id": "body-3", "type": "body", + "voiceText": "Giá khởi điểm hai mươi mốt triệu đồng, dự kiến mở bán tại Việt Nam vào tháng sau.", + "templateData": { + "template": "callout", + "statement": "Giá từ 21 triệu đồng, mở bán tháng 5.", + "tag": "Giá bán" + } + }, + { + "id": "outro", "type": "outro", + "voiceText": "Theo dõi Công nghệ 24h để xem bản tin mới mỗi ngày.", + "templateData": { + "template": "outro", + "ctaTop": "Theo dõi ngay", + "channelName": "Công nghệ 24h", + "source": "vnexpress.net" } } - /* ... 3 body scenes + outro ... */ ] } ``` @@ -245,35 +282,59 @@ Generated `script.json` (excerpt): User: `/create-news-video news/agi-update.txt` -Generated `script.json` (excerpt): +Generated `script.json`: ```json { + "version": "1.0", "metadata": { "title": "OpenAI công bố mô hình mới với khả năng lập luận", "source": { "url": "local", "domain": "local", "image": null }, "channel": "Công nghệ 24h" }, + "voice": { "provider": "lucylab", "voiceId": "${VIETNAMESE_VOICEID}", "speed": 1.0 }, "scenes": [ { "id": "hook", "type": "hook", "voiceText": "OpenAI vừa công bố mô hình mới có khả năng lập luận như con người.", - "visual": { - "background": { "type": "gradient", "preset": "news-dark" }, - "text": { - "position": "center", "style": "hook-large", - "lines": [ - { "content": "Mô hình mới", "emphasis": "primary", "animation": "scale-pop" }, - { "content": "Lập luận!", "emphasis": "accent", "animation": "slide-up-bounce" } - ] - }, - "effects": ["flash-white-3f"] + "templateData": { + "template": "hook", + "headline": "Mô hình mới", + "subhead": "Lập luận như người" + } + }, + { + "id": "body-1", "type": "body", + "voiceText": "Mô hình đạt chín mươi hai phẩy bảy phần trăm trên benchmark, vượt xa phiên bản cũ.", + "templateData": { + "template": "stat-hero", + "value": "92.7%", + "label": "Benchmark", + "context": "Vượt phiên bản cũ 75.1%" + } + }, + { + "id": "body-2", "type": "body", + "voiceText": "Hệ thống có thể tự suy luận đa bước, kiểm tra logic và sửa sai trước khi trả lời.", + "templateData": { + "template": "feature-list", + "title": "Khả năng mới", + "bullets": ["Suy luận đa bước", "Tự kiểm tra logic", "Tự sửa lỗi"] + } + }, + { + "id": "outro", "type": "outro", + "voiceText": "Theo dõi Công nghệ 24h để xem bản tin mới mỗi ngày.", + "templateData": { + "template": "outro", + "ctaTop": "Xem bản tin mới mỗi ngày", + "channelName": "Công nghệ 24h", + "source": "local" } } - /* ... outro line 3 = "Nguồn: local" ... */ ] } ``` -Note: when source has no image, every scene uses `background.type = "gradient"` (no image fallback at composer level needed). +Note: when source has no image, omit `bgSrc` from the hook — the pipeline uses a gradient fallback automatically. ## Sound Effects (SFX) diff --git a/.env.example b/.env.example index d225484..f5851d5 100644 --- a/.env.example +++ b/.env.example @@ -53,3 +53,13 @@ TIKTOK_FOLLOWERS=11.5k followers # ── Pipeline tuning ───────────────────────────────────────────────────────── # TTS_CONCURRENCY: 1 for LucyLab (API limit), can increase for ElevenLabs TTS_CONCURRENCY=1 + +# ── LLM Provider ──────────────────────────────────────────────────────────── +# Choose ONE: "anthropic", "openai", or "deepseek" +# - anthropic : Claude (haiku for cost, sonnet for quality) +# - openai : GPT-4o / GPT-4.1 +# - deepseek : OpenAI-compatible, set LLM_ENDPOINT +LLM_PROVIDER=anthropic +LLM_API_KEY=sk-ant-... +LLM_MODEL=claude-haiku-4-5-20251001 +# LLM_ENDPOINT=https://api.deepseek.com/v1 # only needed for deepseek diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43540ba --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,19 @@ +# AutoCreateVideo + +## Skill routing + +When the user's request matches an available skill, invoke it via the Skill tool. When in doubt, invoke the skill. + +Key routing rules: +- Product ideas/brainstorming → invoke /office-hours +- Strategy/scope → invoke /plan-ceo-review +- Architecture → invoke /plan-eng-review +- Design system/plan review → invoke /design-consultation or /plan-design-review +- Full review pipeline → invoke /autoplan +- Bugs/errors → invoke /investigate +- QA/testing site behavior → invoke /qa or /qa-only +- Code review/diff check → invoke /review +- Visual polish → invoke /design-review +- Ship/deploy/PR → invoke /ship or /land-and-deploy +- Save progress → invoke /context-save +- Resume context → invoke /context-restore diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..86de505 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,32 @@ +# CONTEXT — Auto News Video + +## Glossary + +**script.json** — The contract between Claude Code (skill) and the Node CLI (pipeline). Claude writes it, the CLI validates it with Zod, then renders a video from it. Contains metadata, voice config, and an array of scenes. + +**templateData** — The content payload Claude provides per scene, discriminated by `template` field. Claude picks the template type (creative decision) and fills in the fields (content). The CLI reads `templateData` to compose HTML. NOT the same as `visual` (a stale term from the April 2026 design spec that never shipped in this form). + +**Scene** — One segment of the video. Has `id`, `type` (hook|body|outro), `voiceText` (Vietnamese, TTS-safe), `templateData` (the chosen template + content), and optional `sfx` override. + +**Skill** — A Claude Code slash command (`.claude/skills/create-news-video/SKILL.md`) that orchestrates: fetch content → analyze → write script.json → run pipeline. The "creative" half of the architecture. + +**Pipeline** — The deterministic Node/TS half (`src/pipeline.ts`): validate script.json → TTS per scene → concat voice with SFX → compose HTML → render with HyperFrames → output video.mp4. Same input always produces identical frames. + +**voiceText** — Per-scene Vietnamese text for TTS. Dual role: (1) fed verbatim to LucyLab/ElevenLabs for speech synthesis — numbers MUST be spelled out phonetically ("năm phần trăm" not "5%"), and (2) scanned by the 3-tier SFX picker for semantic keywords to auto-select sound effects. This coupling is intentional — news writing naturally uses emotional language that maps to SFX categories. + +**SFX picker** — 3-tier per-scene sound effect selection: (1) explicit `scene.sfx` override, (2) semantic keyword match on `voiceText` (Vietnamese + English), (3) template default category. Within a category, files are picked deterministically by hashing `scene.id`. Anti-repetition window (last 2 scenes) prevents back-to-back duplicates. + +**Channel** — The brand identity: "Công nghệ 24h". Appears on the outro card and can be customized via `metadata.channel`. + +**Doc maintenance** — SKILL.md is the authoritative document (it's what Claude reads). The design spec (`docs/superpowers/specs/`) is a pre-implementation artifact and may drift. Code is the implementation but the skill file defines the contract Claude follows. When they diverge, SKILL.md wins — update it first, then align code to match. + +**Template selection** — Claude picks templates per scene based on content signals (the "When it's picked" column in README), not randomly. Hook always first, outro always last. Body templates match the story beat: a stat → `stat-hero`, a comparison → `comparison`, a list → `feature-list`, a warning → `callout`. Following content signals naturally produces variety — no mechanical "don't repeat" rule needed. + +**Template count** — 6 templates are implemented (hook, comparison, stat-hero, feature-list, callout, outro). README lists 6 more (quote-card, icon-grid, timeline, big-text, chart-bars, kinetic-quote) — these are documented aspirations, planned for future implementation. SKILL.md should only reference the 6 that actually render. + +**Dashboard** — The web UI + HTTP server at `localhost:4317` (`src/server.ts` + `src/ui/`). Browses outputs, triggers video generation from an article URL, streams job progress via SSE. The third architectural component alongside Skill and Pipeline. + +**Job** — An async process kicked off by the dashboard. Has an id, status (`running` | `success` | `failed`), logs, and an SSE event stream. Only one job runs at a time (V1). A pipeline job runs the full generate+render flow; a generate job produces script.json via LLM first, then chains into pipeline. + +**Generate** — The LLM-powered step that turns an article URL into `script.json`. The creative half (formerly exclusive to the Skill slash command), now callable from the dashboard via `POST /api/generate`. The server reads SKILL.md as the system prompt and provides a `web_fetch` tool so the LLM can fetch the article. Supports Anthropic, OpenAI, and DeepSeek providers via `LLM_PROVIDER` env var. + diff --git a/package-lock.json b/package-lock.json index ae4b99e..70052a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,27 +1,50 @@ { - "name": "auto_create_video", - "version": "1.0.0", + "name": "auto-news-video", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "auto_create_video", - "version": "1.0.0", - "license": "ISC", + "name": "auto-news-video", + "version": "2.0.0", + "license": "MIT", "dependencies": { - "axios": "^1.15.2", + "@anthropic-ai/sdk": "^0.97.0", + "axios": "^1.16.1", "dotenv": "^17.4.2", - "hyperframes": "^0.4.34", + "hyperframes": "^0.4.45", + "openai": "^6.38.0", "p-limit": "^7.3.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "devDependencies": { - "@types/node": "^25.6.0", - "@vitest/coverage-v8": "^4.1.5", - "nock": "^14.0.13", - "tsx": "^4.21.0", + "@types/node": "^25.9.0", + "@vitest/coverage-v8": "^4.1.6", + "nock": "^14.0.15", + "tsx": "^4.22.2", "typescript": "^6.0.3", - "vitest": "^4.1.5" + "vitest": "^4.1.6" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.97.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.97.0.tgz", + "integrity": "sha512-124Ebx14aEFlyr2/wRtQjcQ3IRbuON6GgoRKEPGffVV0Nu5DCXs7nlkocVQ4NLI8MxDT+7ezXCwRWXoBpWEuVw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } } }, "node_modules/@babel/helper-string-parser": { @@ -60,6 +83,15 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/types": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", @@ -100,7 +132,6 @@ "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -119,9 +150,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], @@ -136,9 +167,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], @@ -153,9 +184,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], @@ -170,9 +201,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], @@ -187,9 +218,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], @@ -204,9 +235,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], @@ -221,9 +252,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], @@ -238,9 +269,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], @@ -255,9 +286,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], @@ -272,9 +303,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], @@ -289,9 +320,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], @@ -306,9 +337,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], @@ -323,9 +354,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], @@ -340,9 +371,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], @@ -357,9 +388,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], @@ -374,9 +405,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], @@ -391,9 +422,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], @@ -408,9 +439,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ "arm64" ], @@ -425,9 +456,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -442,9 +473,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ "arm64" ], @@ -459,9 +490,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], @@ -476,9 +507,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "cpu": [ "arm64" ], @@ -493,9 +524,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], @@ -510,9 +541,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], @@ -523,77 +554,590 @@ "win32" ], "engines": { - "node": ">=18" + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "1.50.1", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.50.1.tgz", + "integrity": "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], - "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=18" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], - "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ "win32" ], "engines": { - "node": ">=18" - } - }, - "node_modules/@google/genai": { - "version": "1.50.1", - "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.50.1.tgz", - "integrity": "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "google-auth-library": "^10.3.0", - "p-retry": "^4.6.2", - "protobufjs": "^7.5.4", - "ws": "^8.18.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.2" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/sdk": { - "optional": true - } - } - }, - "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", - "license": "MIT", - "engines": { - "node": ">=18.14.1" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, - "peerDependencies": { - "hono": "^4" + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@jridgewell/resolve-uri": { @@ -687,9 +1231,9 @@ "license": "MIT" }, "node_modules/@oxc-project/types": { - "version": "0.127.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz", - "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", "dev": true, "license": "MIT", "funding": { @@ -792,9 +1336,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", "cpu": [ "arm64" ], @@ -809,9 +1353,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", "cpu": [ "arm64" ], @@ -826,9 +1370,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", "cpu": [ "x64" ], @@ -843,9 +1387,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", - "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", "cpu": [ "x64" ], @@ -860,9 +1404,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", - "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", "cpu": [ "arm" ], @@ -877,13 +1421,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -894,13 +1441,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -911,13 +1461,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -928,13 +1481,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -945,13 +1501,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", - "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -962,13 +1521,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", - "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -979,9 +1541,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", - "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", "cpu": [ "arm64" ], @@ -996,9 +1558,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", - "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", "cpu": [ "wasm32" ], @@ -1015,9 +1577,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", "cpu": [ "arm64" ], @@ -1032,9 +1594,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz", - "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", "cpu": [ "x64" ], @@ -1049,12 +1611,18 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz", - "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1069,9 +1637,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -1105,13 +1673,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.6.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", - "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "version": "25.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz", + "integrity": "sha512-AOQwYUNolgy3VosiRqXrACUXTN8nJUtPl7FJXMqZVyxiiCLhQuG3jXKvCS1ALr+Y2OmZhzzLVlYPEqJaiqkaJQ==", "devOptional": true, "license": "MIT", "dependencies": { - "undici-types": "~7.19.0" + "undici-types": ">=7.24.0 <7.24.7" } }, "node_modules/@types/retry": { @@ -1132,14 +1700,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz", - "integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz", + "integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.6", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1153,8 +1721,8 @@ "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.5", - "vitest": "4.1.5" + "@vitest/browser": "4.1.6", + "vitest": "4.1.6" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1163,16 +1731,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz", + "integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -1181,13 +1749,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz", + "integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.5", + "@vitest/spy": "4.1.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1208,9 +1776,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz", + "integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==", "dev": true, "license": "MIT", "dependencies": { @@ -1221,13 +1789,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz", + "integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.5", + "@vitest/utils": "4.1.6", "pathe": "^2.0.3" }, "funding": { @@ -1235,14 +1803,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz", + "integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/pretty-format": "4.1.6", + "@vitest/utils": "4.1.6", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1251,9 +1819,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz", + "integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==", "dev": true, "license": "MIT", "funding": { @@ -1261,13 +1829,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz", + "integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.5", + "@vitest/pretty-format": "4.1.6", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -1358,16 +1926,42 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", - "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/b4a": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", @@ -1707,6 +2301,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/define-lazy-prop": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", @@ -1719,6 +2330,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/degenerator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", @@ -1746,7 +2374,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -1862,9 +2489,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -1875,32 +2502,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, "node_modules/escalade": { @@ -1912,6 +2539,18 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/escodegen": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", @@ -2026,6 +2665,12 @@ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -2241,19 +2886,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-tsconfig": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", - "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-pkg-maps": "^1.0.0" - }, - "funding": { - "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" - } - }, "node_modules/get-uri": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz", @@ -2286,6 +2918,37 @@ "giget": "dist/cli.mjs" } }, + "node_modules/global-agent": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-4.1.3.tgz", + "integrity": "sha512-KUJEViiuFT3I97t+GYMikLPJS2Lfo/S2F+DQuBWzuzaMPnvt5yyZePzArx36fBzpGTxZjIpDbXLeySLgh+k76g==", + "license": "BSD-3-Clause", + "dependencies": { + "globalthis": "^1.0.2", + "matcher": "^4.0.0", + "semver": "^7.3.5", + "serialize-error": "^8.1.0" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/google-auth-library": { "version": "10.6.2", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", @@ -2336,6 +2999,18 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2376,9 +3051,9 @@ } }, "node_modules/hono": { - "version": "4.12.15", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.15.tgz", - "integrity": "sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==", + "version": "4.12.19", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz", + "integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -2418,9 +3093,9 @@ } }, "node_modules/hyperframes": { - "version": "0.4.34", - "resolved": "https://registry.npmjs.org/hyperframes/-/hyperframes-0.4.34.tgz", - "integrity": "sha512-EARh9UTYxi5G/sTURkcEbhzrmemHKyUwz6JwaWg8P9qqMJusYh2ztz4/KVHONxcRH0hf6VWqwCRig1brKj7y6w==", + "version": "0.4.45", + "resolved": "https://registry.npmjs.org/hyperframes/-/hyperframes-0.4.45.tgz", + "integrity": "sha512-TQWBczoBGov9vIrnxGFjUBoz5cms1P98H9O0Yc98g7mVdFk/xOMW4kJlc3cT//a8iq2xscPZd4OPkhX0JMnmcQ==", "dependencies": { "@hono/node-server": "^1.8.0", "@puppeteer/browsers": "^2.13.0", @@ -2431,10 +3106,12 @@ "giget": "^3.2.0", "hono": "^4.0.0", "mime-types": "^3.0.2", + "onnxruntime-node": "^1.20.0", "open": "^10.0.0", "postcss": "^8.5.8", "prettier": "^3.8.1", - "puppeteer-core": "^24.39.1" + "puppeteer-core": "^24.39.1", + "sharp": "^0.34.0" }, "bin": { "hyperframes": "dist/cli.js" @@ -3057,6 +3734,19 @@ "bignumber.js": "^9.0.0" } }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -3230,6 +3920,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3251,6 +3944,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3272,6 +3968,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3293,6 +3992,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3402,6 +4104,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/matcher": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz", + "integrity": "sha512-S6x5wmcDmsDRRU/c2dkccDwQPXoFczc5+HpQ2lON8pnvHlnvHAHj5WlLVvw6n6vNyHuVugYrFohYxbS+pvFpKQ==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3472,9 +4189,9 @@ } }, "node_modules/nock": { - "version": "14.0.13", - "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.13.tgz", - "integrity": "sha512-SCPsQmGVNY8h1rfS3aU0MzOGYY+wKIFukHEsoSIwPRCYocZkya7MFIlWIEYPWQZj+Gaksg6EyUaY255ZDqpQuA==", + "version": "14.0.15", + "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.15.tgz", + "integrity": "sha512-S0a47C9pLvcYx/Ugf0H30BVBEcUgMMBDk9VJIDlJ8XGrfH2QDUD4Tgdp45qDIiHttokBG+IbsOtsvIjGR/j3bg==", "dev": true, "license": "MIT", "dependencies": { @@ -3526,6 +4243,15 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -3546,6 +4272,29 @@ "wrappy": "1" } }, + "node_modules/onnxruntime-common": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.26.0.tgz", + "integrity": "sha512-qVyMR4lcWgbkc4getFV+GQijsTnbg/siteoqcDwa3sI/LxbrMSNw4ePyvCq/ymdQaRomCA7YuWmhzsswxvymdw==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.26.0.tgz", + "integrity": "sha512-OHl6PiOEOqxaLHL0N9eFrbzS7IGmu3BtJNH3RTEnRAheCIkfc3gjcjl4sGcjp9C22ZC9YTquDOxSdT/stBQ6BQ==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "adm-zip": "^0.5.16", + "global-agent": "^4.1.3", + "onnxruntime-common": "1.26.0" + } + }, "node_modules/open": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", @@ -3564,6 +4313,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "6.38.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.38.0.tgz", + "integrity": "sha512-AoMplt2UalrpgUDMh3L09QWjNRlgJPipclQvA6sYAaeF6nHNBMgmikAZGmcYLn8on4d9sQY9Q8bOLfrBS7Lc8g==", + "license": "Apache-2.0", + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/outvariant": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", @@ -3665,9 +4435,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "funding": [ { "type": "opencollective", @@ -3822,16 +4592,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve-pkg-maps": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", - "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" - } - }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -3843,14 +4603,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.17", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz", - "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.127.0", - "@rolldown/pluginutils": "1.0.0-rc.17" + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -3859,21 +4619,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", - "@rolldown/binding-darwin-x64": "1.0.0-rc.17", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, "node_modules/run-applescript": { @@ -3921,6 +4681,65 @@ "node": ">=10" } }, + "node_modules/serialize-error": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", + "integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -3992,6 +4811,16 @@ "dev": true, "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/std-env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", @@ -4108,9 +4937,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", - "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", "dev": true, "license": "MIT", "engines": { @@ -4144,6 +4973,12 @@ "node": ">=14.0.0" } }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -4151,14 +4986,13 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.2.tgz", + "integrity": "sha512-6w9FwtT8WQqRAyTNR+Z+86kghRqpmOLjXUrBlBT6T+CQGDuIMm0VmAqaFUFBIeKDTGobE6/YSigZYLeomzBaRg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -4170,6 +5004,18 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/typed-query-selector": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", @@ -4191,23 +5037,23 @@ } }, "node_modules/undici-types": { - "version": "7.19.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", - "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "devOptional": true, "license": "MIT" }, "node_modules/vite": { - "version": "8.0.10", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", - "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.10", - "rolldown": "1.0.0-rc.17", + "postcss": "^8.5.14", + "rolldown": "1.0.1", "tinyglobby": "^0.2.16" }, "bin": { @@ -4224,7 +5070,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", + "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -4276,19 +5122,19 @@ } }, "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz", + "integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", + "@vitest/expect": "4.1.6", + "@vitest/mocker": "4.1.6", + "@vitest/pretty-format": "4.1.6", + "@vitest/runner": "4.1.6", + "@vitest/snapshot": "4.1.6", + "@vitest/spy": "4.1.6", + "@vitest/utils": "4.1.6", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -4316,12 +5162,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", + "@vitest/browser-playwright": "4.1.6", + "@vitest/browser-preview": "4.1.6", + "@vitest/browser-webdriverio": "4.1.6", + "@vitest/coverage-istanbul": "4.1.6", + "@vitest/coverage-v8": "4.1.6", + "@vitest/ui": "4.1.6", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -4422,9 +5268,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -4516,9 +5362,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 42d9129..5cb1c37 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test:watch": "vitest", "pipeline": "tsx src/cli.ts", "rerender": "tsx rerender.ts", + "ui": "tsx src/server.ts", "typecheck": "tsc --noEmit", "build": "tsc", "sfx:download": "tsx scripts/download-sfx.ts", @@ -32,18 +33,20 @@ "author": "", "license": "MIT", "dependencies": { - "axios": "^1.15.2", + "@anthropic-ai/sdk": "^0.97.0", + "axios": "^1.16.1", "dotenv": "^17.4.2", - "hyperframes": "^0.4.34", + "hyperframes": "^0.4.45", + "openai": "^6.38.0", "p-limit": "^7.3.0", - "zod": "^4.3.6" + "zod": "^4.4.3" }, "devDependencies": { - "@types/node": "^25.6.0", - "@vitest/coverage-v8": "^4.1.5", - "nock": "^14.0.13", - "tsx": "^4.21.0", + "@types/node": "^25.9.0", + "@vitest/coverage-v8": "^4.1.6", + "nock": "^14.0.15", + "tsx": "^4.22.2", "typescript": "^6.0.3", - "vitest": "^4.1.5" + "vitest": "^4.1.6" } } diff --git a/rerender.ts b/rerender.ts index c7ee219..8e4b8e8 100644 --- a/rerender.ts +++ b/rerender.ts @@ -6,6 +6,7 @@ import { readFile, writeFile, copyFile } from "node:fs/promises"; import { join, dirname, basename } from "node:path"; import { fileURLToPath } from "node:url"; +import { config } from "dotenv"; import { ScriptSchema } from "./src/render/script-schema.js"; import { loadConfig } from "./src/config.js"; import { getDurationSec, concatWithSilence, mixSfxOntoVoice, type SfxMixSpec } from "./src/assets/audio-tools.js"; @@ -14,6 +15,8 @@ import { existsSync } from "node:fs"; import { composeHtml } from "./src/render/html-composer.js"; import { renderWithHyperframes } from "./src/render/hyperframes-runner.js"; +config({ path: ".env.local" }); + const __dirname = dirname(fileURLToPath(import.meta.url)); const TPL_DIR = join(__dirname, "src", "render", "templates"); const SFX_DIR = join(__dirname, "assets", "sfx"); diff --git a/src/config.test.ts b/src/config.test.ts index 94b929e..ba362ac 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -13,6 +13,10 @@ const ENV_KEYS = [ "ELEVENLABS_MODEL_ID", "ELEVENLABS_ENDPOINT", "TTS_CONCURRENCY", + "LLM_PROVIDER", + "LLM_API_KEY", + "LLM_MODEL", + "LLM_ENDPOINT", ]; describe("loadConfig", () => { @@ -21,6 +25,7 @@ describe("loadConfig", () => { beforeEach(() => { saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); ENV_KEYS.forEach((k) => delete process.env[k]); + process.env.LLM_API_KEY = "sk-test-llm-key"; }); afterEach(() => { diff --git a/src/config.ts b/src/config.ts index 7af7d78..fcd3219 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,14 @@ -import "dotenv/config"; +import { config } from "dotenv"; +import { existsSync } from "node:fs"; + +// Load .env first, then .env.local overrides (if present) +config(); +if (existsSync(".env.local")) { + config({ path: ".env.local", override: true }); +} export type TtsProvider = "lucylab" | "elevenlabs"; +export type LlmProvider = "anthropic" | "openai" | "deepseek"; export interface TiktokConfig { displayName: string; @@ -13,6 +21,12 @@ export interface TiktokConfig { export interface Config { ttsProvider: TtsProvider; + // LLM + llmProvider: LlmProvider; + llmApiKey: string; + llmModel: string; + llmEndpoint?: string; + // LucyLab lucylabApiKey?: string; lucylabVoiceId?: string; @@ -46,6 +60,14 @@ export function loadConfig(): Config { throw new Error(`TTS_PROVIDER must be "lucylab" or "elevenlabs", got "${provider}"`); } + const llmProvider = (process.env.LLM_PROVIDER ?? "anthropic") as LlmProvider; + if (!["anthropic", "openai", "deepseek"].includes(llmProvider)) { + throw new Error(`LLM_PROVIDER must be "anthropic", "openai", or "deepseek", got "${llmProvider}"`); + } + if (!process.env.LLM_API_KEY || process.env.LLM_API_KEY.trim() === "") { + throw new Error("Missing LLM_API_KEY"); + } + // Validate provider-specific required vars if (provider === "lucylab") { if (!process.env.VIETNAMESE_API_KEY || process.env.VIETNAMESE_API_KEY.trim() === "") { @@ -77,6 +99,10 @@ export function loadConfig(): Config { return { ttsProvider: provider, + llmProvider, + llmApiKey: process.env.LLM_API_KEY!.trim(), + llmModel: process.env.LLM_MODEL ?? "claude-haiku-4-5-20251001", + llmEndpoint: process.env.LLM_ENDPOINT || undefined, lucylabApiKey: process.env.VIETNAMESE_API_KEY, lucylabVoiceId: process.env.VIETNAMESE_VOICEID, lucylabEndpoint: process.env.LUCYLAB_ENDPOINT ?? "https://api.lucylab.io/json-rpc", diff --git a/src/llm/anthropic-client.ts b/src/llm/anthropic-client.ts new file mode 100644 index 0000000..9b576c1 --- /dev/null +++ b/src/llm/anthropic-client.ts @@ -0,0 +1,125 @@ +import Anthropic from "@anthropic-ai/sdk"; +import type { Config } from "../config.js"; +import type { LlmClient } from "./llm-client.js"; +import { loadSkillPrompt } from "./llm-client.js"; +import { fetchUrl } from "./web-fetcher.js"; + +const WEB_FETCH_TOOL: Anthropic.Tool = { + name: "web_fetch", + description: "Fetch content from a URL. Use this to read the article content and extract the og:image URL.", + input_schema: { + type: "object" as const, + properties: { + url: { type: "string", description: "The URL to fetch" }, + }, + required: ["url"], + }, +}; + +export class AnthropicClient implements LlmClient { + private client: Anthropic; + + constructor(private cfg: Config) { + this.client = new Anthropic({ apiKey: cfg.llmApiKey, timeout: 120_000 }); + } + + async generateScript( + articleUrl: string, + onProgress: (msg: string) => void, + ): Promise> { + const skillPrompt = loadSkillPrompt(); + const systemPrompt = `${skillPrompt} + +--- +## API CONTEXT + +You are an API endpoint, not a Claude Code session. You are called by a server to generate script.json from an article URL. + +Important differences from the instructions above: +- You do NOT have Write, Bash, or WebFetch tools. You only have the web_fetch tool defined below. +- Instead of writing script.json to disk, respond with ONLY the JSON object (the script.json content). +- Steps 1-2 (detect input type, fetch content) are done via the web_fetch tool. +- Step 3 (slug + output dir) is handled by the server — skip it. +- Step 7 (run pipeline) is handled by the server — skip it. +- Step 8 (report success) is handled by the server — skip it. +- When generating script.json, follow Steps 4-6 exactly from the instructions above. +- Output ONLY valid JSON, no markdown fences, no surrounding text.`; + + onProgress("Analyzing article..."); + + const messages: Anthropic.MessageParam[] = [ + { + role: "user", + content: `Generate a Vietnamese news video script for this article URL: ${articleUrl} + +First, use the web_fetch tool to fetch the article content. If the fetch fails, try alternative approaches to get the content. Then follow Steps 4-6 of the skill to generate script.json. + +Remember: output ONLY the raw JSON object, no markdown fences.`, + }, + ]; + + // eslint-disable-next-line no-constant-condition + while (true) { + const response = await this.client.messages.create({ + model: this.cfg.llmModel, + max_tokens: 8192, + system: systemPrompt, + messages, + tools: [WEB_FETCH_TOOL], + }); + + const toolCalls: Anthropic.ToolUseBlock[] = []; + let text = ""; + + for (const block of response.content) { + if (block.type === "tool_use") { + toolCalls.push(block); + } else if (block.type === "text") { + text += block.text; + } + } + + if (toolCalls.length > 0) { + messages.push({ role: "assistant", content: response.content }); + + const toolResults: Anthropic.ToolResultBlockParam[] = []; + for (const toolCall of toolCalls) { + if (toolCall.name === "web_fetch") { + const url = (toolCall.input as { url: string }).url; + onProgress(`Fetching ${url}...`); + const result = await fetchUrl(url); + toolResults.push({ + type: "tool_result", + tool_use_id: toolCall.id, + content: result.content, + }); + } else { + toolResults.push({ + type: "tool_result", + tool_use_id: toolCall.id, + content: `Unknown tool: ${toolCall.name}`, + }); + } + } + messages.push({ role: "user", content: toolResults }); + continue; + } + + onProgress("Parsing script.json..."); + const jsonText = text.trim(); + const jsonMatch = jsonText.match(/(?:```(?:json)?\s*)?([\s\S]*?)(?:\s*```)?$/); + const cleanJson = jsonMatch ? jsonMatch[1].trim() : jsonText; + try { + return JSON.parse(cleanJson) as Record; + } catch { + onProgress("Retrying after JSON parse error..."); + messages.push({ role: "assistant", content: response.content }); + messages.push({ + role: "user", + content: `The previous response was not valid JSON. Parse error. Please output ONLY the raw JSON object for script.json, with no markdown fences or surrounding text.`, + }); + continue; + } + } + } +} diff --git a/src/llm/llm-client.ts b/src/llm/llm-client.ts new file mode 100644 index 0000000..f8f1061 --- /dev/null +++ b/src/llm/llm-client.ts @@ -0,0 +1,38 @@ +import { readFileSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Config } from "../config.js"; +import { AnthropicClient } from "./anthropic-client.js"; +import { OpenAICompatibleClient } from "./openai-compatible-client.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SKILL_PATH = join(__dirname, "..", "..", ".claude", "skills", "create-news-video", "SKILL.md"); + +let _skillMd: string | null = null; + +export function loadSkillPrompt(): string { + if (_skillMd) return _skillMd; + if (!existsSync(SKILL_PATH)) { + throw new Error(`SKILL.md not found at ${SKILL_PATH}`); + } + _skillMd = readFileSync(SKILL_PATH, "utf8"); + return _skillMd; +} + +export interface LlmClient { + generateScript(articleUrl: string, onProgress: (msg: string) => void): Promise>; +} + +export function createLlmClient(cfg: Config): LlmClient { + switch (cfg.llmProvider) { + case "anthropic": + return new AnthropicClient(cfg); + case "openai": + case "deepseek": + return new OpenAICompatibleClient(cfg); + default: { + const _never: never = cfg.llmProvider; + throw new Error(`Unknown LLM provider: ${_never}`); + } + } +} diff --git a/src/llm/openai-compatible-client.ts b/src/llm/openai-compatible-client.ts new file mode 100644 index 0000000..33308e7 --- /dev/null +++ b/src/llm/openai-compatible-client.ts @@ -0,0 +1,147 @@ +import OpenAI from "openai"; +import type { Config } from "../config.js"; +import type { LlmClient } from "./llm-client.js"; +import { loadSkillPrompt } from "./llm-client.js"; +import { fetchUrl } from "./web-fetcher.js"; + +function isFunctionCall(tc: OpenAI.ChatCompletionMessageToolCall): tc is OpenAI.ChatCompletionMessageFunctionToolCall { + return (tc as OpenAI.ChatCompletionMessageFunctionToolCall).function !== undefined; +} + +const WEB_FETCH_TOOL: OpenAI.ChatCompletionTool = { + type: "function", + function: { + name: "web_fetch", + description: "Fetch content from a URL. Use this to read the article content and extract the og:image URL.", + parameters: { + type: "object", + properties: { + url: { type: "string", description: "The URL to fetch" }, + }, + required: ["url"], + }, + }, +}; + +export class OpenAICompatibleClient implements LlmClient { + private client: OpenAI; + + constructor(private cfg: Config) { + this.client = new OpenAI({ + apiKey: cfg.llmApiKey, + baseURL: cfg.llmProvider === "deepseek" + ? (cfg.llmEndpoint ?? "https://api.deepseek.com/v1") + : (cfg.llmEndpoint || undefined), + timeout: 120_000, + maxRetries: 1, + }); + } + + async generateScript( + articleUrl: string, + onProgress: (msg: string) => void, + ): Promise> { + const skillPrompt = loadSkillPrompt(); + const systemPrompt = `${skillPrompt} + +--- +## API CONTEXT + +You are an API endpoint, not a Claude Code session. You are called by a server to generate script.json from an article URL. + +Important differences from the instructions above: +- You do NOT have Write, Bash, or WebFetch tools. You only have the web_fetch function defined below. +- Instead of writing script.json to disk, respond with ONLY the JSON object (the script.json content). +- Steps 1-2 (detect input type, fetch content) are done via the web_fetch function. +- Step 3 (slug + output dir) is handled by the server — skip it. +- Step 7 (run pipeline) is handled by the server — skip it. +- Step 8 (report success) is handled by the server — skip it. +- When generating script.json, follow Steps 4-6 exactly from the instructions above. +- Output ONLY valid JSON, no markdown fences, no surrounding text.`; + + onProgress("Analyzing article..."); + + const messages: OpenAI.ChatCompletionMessageParam[] = [ + { + role: "user", + content: `Generate a Vietnamese news video script for this article URL: ${articleUrl} + +First, use the web_fetch function to fetch the article content. If the fetch fails, try alternative approaches to get the content. Then follow Steps 4-6 of the skill to generate script.json. + +Remember: output ONLY the raw JSON object, no markdown fences.`, + }, + ]; + + // eslint-disable-next-line no-constant-condition + while (true) { + const response = await this.client.chat.completions.create({ + model: this.cfg.llmModel, + max_tokens: 8192, + messages: [ + { role: "system", content: systemPrompt }, + ...messages, + ], + tools: [WEB_FETCH_TOOL], + }); + + const choice = response.choices[0]; + if (!choice) { + throw new Error("No response from LLM"); + } + + // Persist reasoning_content for DeepSeek thinking models + const rawMsg = choice.message as unknown as Record; + const reasoning = rawMsg.reasoning_content as string | undefined; + + if (choice.message.tool_calls?.length) { + const assistantMsg: Record = { + role: "assistant", + content: choice.message.content, + tool_calls: choice.message.tool_calls, + }; + if (reasoning) assistantMsg.reasoning_content = reasoning; + messages.push(assistantMsg as unknown as OpenAI.ChatCompletionAssistantMessageParam); + + for (const toolCall of choice.message.tool_calls) { + if (!isFunctionCall(toolCall)) continue; + if (toolCall.function.name === "web_fetch") { + const { url } = JSON.parse(toolCall.function.arguments); + onProgress(`Fetching ${url}...`); + const result = await fetchUrl(url); + messages.push({ + role: "tool", + tool_call_id: toolCall.id, + content: result.content, + }); + } else { + messages.push({ + role: "tool", + tool_call_id: toolCall.id, + content: `Unknown function: ${toolCall.function.name}`, + }); + } + } + continue; + } + + const text = choice.message.content ?? ""; + onProgress("Parsing script.json..."); + const jsonText = text.trim(); + const jsonMatch = jsonText.match(/(?:```(?:json)?\s*)?([\s\S]*?)(?:\s*```)?$/); + const cleanJson = jsonMatch ? jsonMatch[1].trim() : jsonText; + try { + return JSON.parse(cleanJson) as Record; + } catch { + onProgress("Retrying after JSON parse error..."); + const retryMsg: Record = { role: "assistant", content: text }; + if (reasoning) retryMsg.reasoning_content = reasoning; + messages.push(retryMsg as unknown as OpenAI.ChatCompletionAssistantMessageParam); + messages.push({ + role: "user", + content: `The previous response was not valid JSON. Parse error. Please output ONLY the raw JSON object for script.json, with no markdown fences or surrounding text.`, + }); + continue; + } + } + } +} diff --git a/src/llm/web-fetcher.ts b/src/llm/web-fetcher.ts new file mode 100644 index 0000000..c15d356 --- /dev/null +++ b/src/llm/web-fetcher.ts @@ -0,0 +1,54 @@ +export function extractTextFromHtml(html: string): string { + const text = html + .replace(/]*>[\s\S]*?<\/script>/gi, "") + .replace(/]*>[\s\S]*?<\/style>/gi, "") + .replace(/]*>[\s\S]*?<\/head>/gi, "") + .replace(/]*>[\s\S]*?<\/nav>/gi, "") + .replace(/]*>[\s\S]*?<\/footer>/gi, "") + .replace(/<[^>]+>/g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/’/g, "'") + .replace(/‘/g, "'") + .replace(/”/g, '"') + .replace(/“/g, '"') + .replace(/—/g, "—") + .replace(/–/g, "–") + .replace(///g, "/") + .replace(/'/g, "'") + .replace(/\s+/g, " ") + .trim(); + + return text; +} + +export async function fetchUrl(url: string): Promise<{ content: string }> { + try { + const resp = await fetch(url, { + headers: { "User-Agent": "AutoCreateVideo/1.0" }, + signal: AbortSignal.timeout(15_000), + }); + if (!resp.ok) { + return { content: `Failed to fetch: HTTP ${resp.status} ${resp.statusText}` }; + } + const html = await resp.text(); + const text = extractTextFromHtml(html); + const ogImage = html.match(/]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)?.[1] + ?? html.match(/]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i)?.[1] + ?? null; + + let result = `URL: ${url}\nContent (text extracted from HTML):\n${text.slice(0, 8000)}`; + if (ogImage) { + result += `\n\nog:image URL: ${ogImage}`; + } + if (text.length > 8000) { + result += `\n\n[Content truncated at 8000 chars, original was ${text.length} chars]`; + } + return { content: result }; + } catch (e) { + return { content: `Fetch error: ${e instanceof Error ? e.message : String(e)}` }; + } +} diff --git a/src/render/script-schema.ts b/src/render/script-schema.ts index 5895e86..844bba6 100644 --- a/src/render/script-schema.ts +++ b/src/render/script-schema.ts @@ -106,7 +106,7 @@ export const ScriptSchema = z.object({ channel: z.string().min(1), }), voice: z.object({ - provider: z.literal("lucylab"), + provider: z.enum(["lucylab", "elevenlabs"]), voiceId: z.string().min(1), speed: z.number().min(0.5).max(2.0), }), diff --git a/src/server.test.ts b/src/server.test.ts new file mode 100644 index 0000000..9324cf4 --- /dev/null +++ b/src/server.test.ts @@ -0,0 +1,73 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + OUTPUT_ROOT, + assertExistingScriptPath, + listOutputs, + safeOutputPath, + toOutputRelative, +} from "./server.js"; + +const FIXTURE_NAME = "ui-server-test-fixture"; +const FIXTURE_DIR = join(OUTPUT_ROOT, FIXTURE_NAME); + +describe("local UI server helpers", () => { + beforeEach(async () => { + await rm(FIXTURE_DIR, { recursive: true, force: true }); + await mkdir(FIXTURE_DIR, { recursive: true }); + }); + + afterEach(async () => { + await rm(FIXTURE_DIR, { recursive: true, force: true }); + }); + + it("rejects paths outside output", () => { + expect(() => safeOutputPath("../package.json")).toThrow(/inside output/); + expect(() => safeOutputPath("src/server.ts")).toThrow(/inside output/); + }); + + it("accepts an existing output script path", async () => { + await writeFile(join(FIXTURE_DIR, "script.json"), "{}"); + await expect(assertExistingScriptPath(`output/${FIXTURE_NAME}/script.json`)) + .resolves.toBe(`output/${FIXTURE_NAME}/script.json`); + }); + + it("rejects bogus script path filenames", async () => { + await expect(assertExistingScriptPath(`output/${FIXTURE_NAME}/other.json`)) + .rejects.toThrow(/script\.json/); + }); + + it("converts absolute output paths to output/ relative", () => { + const abs = join(OUTPUT_ROOT, FIXTURE_NAME, "script.json"); + expect(toOutputRelative(abs)).toBe(`output/${FIXTURE_NAME}/script.json`); + }); + + it("lists output folders with artifact flags and metadata", async () => { + await writeFile(join(FIXTURE_DIR, "script.json"), JSON.stringify({ + metadata: { title: "Fixture Script" }, + })); + await writeFile(join(FIXTURE_DIR, "script.txt"), "hello"); + await writeFile(join(FIXTURE_DIR, "voice.mp3"), "audio"); + + const outputs = await listOutputs(); + const fixture = outputs.find((item) => item.name === FIXTURE_NAME); + + expect(fixture).toMatchObject({ + outputDir: `output/${FIXTURE_NAME}`, + title: "Fixture Script", + artifacts: { + scriptJson: true, + scriptTxt: true, + voiceMp3: true, + videoMp4: false, + }, + paths: { + scriptJson: `output/${FIXTURE_NAME}/script.json`, + }, + urls: { + videoMp4: `/outputs/${FIXTURE_NAME}/video.mp4`, + }, + }); + }); +}); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..93f135b --- /dev/null +++ b/src/server.ts @@ -0,0 +1,456 @@ +#!/usr/bin/env node +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { spawn } from "node:child_process"; +import { createReadStream } from "node:fs"; +import { access, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; +import { dirname, extname, join, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { ScriptSchema } from "./render/script-schema.js"; +import { loadConfig } from "./config.js"; +import { createLlmClient } from "./llm/llm-client.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +export const PROJECT_ROOT = resolve(__dirname, ".."); +export const OUTPUT_ROOT = join(PROJECT_ROOT, "output"); +const UI_ROOT = join(PROJECT_ROOT, "src", "ui"); +const MAX_BODY_BYTES = 64 * 1024; + +type JobStatus = "running" | "success" | "failed"; +type JobEvent = "log" | "status" | "progress"; + +interface OutputArtifacts { + scriptJson: boolean; + scriptTxt: boolean; + voiceMp3: boolean; + videoMp4: boolean; +} + +export interface OutputItem { + name: string; + outputDir: string; + title: string; + createdAt?: string; + modifiedAt: string; + artifacts: OutputArtifacts; + paths: { + scriptJson: string; + scriptTxt: string; + voiceMp3: string; + videoMp4: string; + }; + urls: { + scriptJson: string; + scriptTxt: string; + voiceMp3: string; + videoMp4: string; + }; +} + +interface Job { + id: string; + input: string; + status: JobStatus; + exitCode?: number | null; + startedAt: string; + finishedAt?: string; + logs: string[]; + listeners: Set<(event: JobEvent, data: unknown) => void>; + outputDir?: string; +} + +const jobs = new Map(); +let runningJob: Job | null = null; + +export function safeOutputPath(input: string): string { + if (!input || typeof input !== "string") { + throw new Error("Path is required"); + } + + const raw = input.trim(); + const resolved = resolve(PROJECT_ROOT, raw); + const relToOutput = relative(OUTPUT_ROOT, resolved); + if (relToOutput === "" || relToOutput.startsWith("..") || relToOutput.split(sep).includes("..")) { + throw new Error("Path must stay inside output/"); + } + + return resolved; +} + +export function toOutputRelative(absPath: string): string { + return `output/${relative(OUTPUT_ROOT, absPath).split(sep).join("/")}`; +} + +export async function assertExistingScriptPath(input: string): Promise { + const resolved = safeOutputPath(input); + if (extname(resolved) !== ".json" || resolved.split(sep).pop() !== "script.json") { + throw new Error("scriptPath must point to an output/*/script.json file"); + } + await access(resolved); + return toOutputRelative(resolved); +} + +export async function listOutputs(): Promise { + let entries: string[]; + try { + entries = await readdir(OUTPUT_ROOT); + } catch { + return []; + } + + const items: Array = await Promise.all(entries.map(async (name): Promise => { + const dir = join(OUTPUT_ROOT, name); + const s = await stat(dir).catch(() => null); + if (!s?.isDirectory()) return null; + + const artifacts: OutputArtifacts = { + scriptJson: await exists(join(dir, "script.json")), + scriptTxt: await exists(join(dir, "script.txt")), + voiceMp3: await exists(join(dir, "voice.mp3")), + videoMp4: await exists(join(dir, "video.mp4")), + }; + + let title = name; + let createdAt: string | undefined; + try { + const meta = JSON.parse(await readFile(join(dir, "meta.json"), "utf8")); + title = String(meta.name || meta.title || title); + createdAt = typeof meta.createdAt === "string" ? meta.createdAt : undefined; + } catch { + try { + const script = JSON.parse(await readFile(join(dir, "script.json"), "utf8")); + title = String(script.metadata?.title || title); + } catch { + // Keep folder name. + } + } + + const outputDir = `output/${name}`; + return { + name, + outputDir, + title, + createdAt, + modifiedAt: s.mtime.toISOString(), + artifacts, + paths: { + scriptJson: `${outputDir}/script.json`, + scriptTxt: `${outputDir}/script.txt`, + voiceMp3: `${outputDir}/voice.mp3`, + videoMp4: `${outputDir}/video.mp4`, + }, + urls: { + scriptJson: `/outputs/${encodeURIComponent(name)}/script.json`, + scriptTxt: `/outputs/${encodeURIComponent(name)}/script.txt`, + voiceMp3: `/outputs/${encodeURIComponent(name)}/voice.mp3`, + videoMp4: `/outputs/${encodeURIComponent(name)}/video.mp4`, + }, + }; + })); + + return items + .filter((item): item is OutputItem => item !== null) + .sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt)); +} + +function createJob(input: string): Job { + if (runningJob) { + throw new Error(`Job ${runningJob.id} is still running`); + } + + const job: Job = { + id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + input, + status: "running", + startedAt: new Date().toISOString(), + logs: [], + listeners: new Set(), + }; + jobs.set(job.id, job); + runningJob = job; + return job; +} + +function emit(job: Job, event: JobEvent, data: unknown): void { + for (const listener of job.listeners) { + listener(event, data); + } +} + +function appendLog(job: Job, text: string): void { + for (const line of text.replace(/\r/g, "").split("\n")) { + if (!line) continue; + job.logs.push(line); + emit(job, "log", { line }); + } +} + +function emitProgress(job: Job, message: string): void { + job.logs.push(`[progress] ${message}`); + emit(job, "progress", { message }); +} + +function spawnPipeline(job: Job, scriptPath: string): void { + const relPath = toOutputRelative(scriptPath); + appendLog(job, `$ npm run pipeline -- ${relPath}`); + const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"; + const child = spawn(npmCmd, ["run", "pipeline", "--", relPath], { + cwd: PROJECT_ROOT, + env: process.env, + stdio: ["ignore", "pipe", "pipe"], + }); + + child.stdout.on("data", (chunk) => appendLog(job, chunk.toString())); + child.stderr.on("data", (chunk) => appendLog(job, chunk.toString())); + child.on("error", (err) => { + appendLog(job, `Failed to start process: ${err.message}`); + finishJob(job, "failed", null); + }); + child.on("close", (code) => { + finishJob(job, code === 0 ? "success" : "failed", code); + }); +} + +function finishJob(job: Job, status: JobStatus, exitCode: number | null): void { + if (job.status !== "running") return; + job.status = status; + job.exitCode = exitCode; + job.finishedAt = new Date().toISOString(); + if (runningJob?.id === job.id) { + runningJob = null; + } + emit(job, "status", serializeJob(job)); +} + +function serializeJob(job: Job) { + return { + id: job.id, + input: job.input, + status: job.status, + exitCode: job.exitCode, + startedAt: job.startedAt, + finishedAt: job.finishedAt, + outputDir: job.outputDir, + logs: job.logs, + }; +} + +function sendJson(res: ServerResponse, statusCode: number, data: unknown): void { + const body = JSON.stringify(data); + res.writeHead(statusCode, { + "Content-Type": "application/json; charset=utf-8", + "Content-Length": Buffer.byteLength(body), + }); + res.end(body); +} + +function sendError(res: ServerResponse, statusCode: number, message: string): void { + sendJson(res, statusCode, { error: message }); +} + +async function readJsonBody(req: IncomingMessage): Promise { + let size = 0; + const chunks: Buffer[] = []; + for await (const chunk of req) { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + size += buf.length; + if (size > MAX_BODY_BYTES) throw new Error("Request body too large"); + chunks.push(buf); + } + if (chunks.length === 0) return {}; + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +async function serveStatic(res: ServerResponse, root: string, relPath: string): Promise { + const decoded = decodeURIComponent(relPath); + const target = resolve(root, decoded); + const rel = relative(root, target); + if (rel.startsWith("..") || rel.split(sep).includes("..")) { + sendError(res, 403, "Forbidden"); + return; + } + const s = await stat(target).catch(() => null); + if (!s?.isFile()) { + sendError(res, 404, "Not found"); + return; + } + + res.writeHead(200, { + "Content-Type": contentType(target), + "Content-Length": s.size, + "Cache-Control": root === OUTPUT_ROOT ? "no-store" : "public, max-age=60", + }); + createReadStream(target).pipe(res); +} + +function contentType(path: string): string { + switch (extname(path).toLowerCase()) { + case ".html": return "text/html; charset=utf-8"; + case ".css": return "text/css; charset=utf-8"; + case ".js": return "text/javascript; charset=utf-8"; + case ".json": return "application/json; charset=utf-8"; + case ".txt": return "text/plain; charset=utf-8"; + case ".mp3": return "audio/mpeg"; + case ".mp4": return "video/mp4"; + case ".png": return "image/png"; + case ".jpg": + case ".jpeg": return "image/jpeg"; + case ".svg": return "image/svg+xml"; + default: return "application/octet-stream"; + } +} + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +function slugFromUrl(url: string): string { + try { + const u = new URL(url); + const host = u.hostname.replace("www.", ""); + const path = u.pathname.replace(/\/+$/, "").split("/").pop() || ""; + const raw = path + ? `${host}-${path}` + : host; + return raw + .normalize("NFD") + .replace(/[̀-ͯ]/g, "") + .replace(/đ/g, "d") + .replace(/Đ/g, "D") + .replace(/[^a-zA-Z0-9]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 40) + .toLowerCase() || "video"; + } catch { + return "video"; + } +} + +function timestamp(): string { + const d = new Date(); + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`; +} + +async function runGenerateJob(job: Job, url: string, res: ServerResponse): Promise { + try { + emitProgress(job, "Creating output directory..."); + const slug = slugFromUrl(url); + const ts = timestamp(); + const outputDir = join(OUTPUT_ROOT, `${slug}-${ts}`); + await mkdir(outputDir, { recursive: true }); + job.outputDir = toOutputRelative(outputDir); + + emitProgress(job, "Loading configuration..."); + const cfg = loadConfig(); + + emitProgress(job, "Starting LLM script generation..."); + const llmClient = createLlmClient(cfg); + const rawScript = await llmClient.generateScript(url, (msg) => { + emitProgress(job, msg); + }); + + emitProgress(job, "Validating generated script..."); + const script = ScriptSchema.parse(rawScript); + + const scriptPath = join(outputDir, "script.json"); + await writeFile(scriptPath, JSON.stringify(script, null, 2)); + + emitProgress(job, `Script written to ${toOutputRelative(scriptPath)}`); + emitProgress(job, "Starting pipeline..."); + + spawnPipeline(job, scriptPath); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + appendLog(job, `Generate failed: ${message}`); + finishJob(job, "failed", null); + } +} + +export async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise { + const url = new URL(req.url ?? "/", "http://localhost"); + + try { + if (req.method === "GET" && url.pathname === "/api/outputs") { + sendJson(res, 200, { outputs: await listOutputs() }); + return; + } + + if (req.method === "POST" && url.pathname === "/api/generate") { + const body = await readJsonBody(req); + const articleUrl = String(body.url || "").trim(); + if (!articleUrl || !/^https?:\/\/.+/.test(articleUrl)) { + sendError(res, 400, "A valid HTTP(S) URL is required"); + return; + } + const job = createJob(articleUrl); + sendJson(res, 202, { job: serializeJob(job) }); + // Fire-and-forget: don't await, let it run async + runGenerateJob(job, articleUrl, res).catch(() => {}); + return; + } + + if (req.method === "POST" && url.pathname === "/api/pipeline") { + const body = await readJsonBody(req); + const scriptPath = await assertExistingScriptPath(body.scriptPath); + const job = createJob(scriptPath); + sendJson(res, 202, { job: serializeJob(job) }); + spawnPipeline(job, join(PROJECT_ROOT, scriptPath)); + return; + } + + const eventsMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/events$/); + if (req.method === "GET" && eventsMatch) { + const job = jobs.get(eventsMatch[1]); + if (!job) { + sendError(res, 404, "Job not found"); + return; + } + res.writeHead(200, { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + }); + const send = (event: JobEvent | "snapshot", data: unknown) => { + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + send("snapshot", serializeJob(job)); + const listener = (event: JobEvent, data: unknown) => send(event, data); + job.listeners.add(listener); + req.on("close", () => job.listeners.delete(listener)); + return; + } + + if (req.method === "GET" && url.pathname.startsWith("/outputs/")) { + await serveStatic(res, OUTPUT_ROOT, url.pathname.slice("/outputs/".length)); + return; + } + + if (req.method === "GET") { + const staticPath = url.pathname === "/" ? "index.html" : url.pathname.slice(1); + await serveStatic(res, UI_ROOT, staticPath); + return; + } + + sendError(res, 405, "Method not allowed"); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + const status = message.includes("still running") ? 409 : 400; + sendError(res, status, message); + } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const port = Number(process.env.PORT ?? 4317); + createServer((req, res) => { + handleRequest(req, res).catch((e) => sendError(res, 500, e instanceof Error ? e.message : String(e))); + }).listen(port, "127.0.0.1", () => { + console.log(`Auto News Video UI: http://127.0.0.1:${port}`); + }); +} diff --git a/src/ui/app.js b/src/ui/app.js new file mode 100644 index 0000000..93cb7aa --- /dev/null +++ b/src/ui/app.js @@ -0,0 +1,194 @@ +const state = { + outputs: [], + events: null, + activeJob: null, +}; + +const $ = (selector) => document.querySelector(selector); + +const outputsList = $("#outputsList"); +const outputCount = $("#outputCount"); +const articleUrl = $("#articleUrl"); +const scriptPath = $("#scriptPath"); +const jobLog = $("#jobLog"); +const jobStatus = $("#jobStatus"); +const artifactLinks = $("#artifactLinks"); + +$("#refreshOutputs").addEventListener("click", loadOutputs); + +$("#generateForm").addEventListener("submit", (event) => { + event.preventDefault(); + startJob("/api/generate", { url: articleUrl.value.trim() }); +}); + +$("#pipelineForm").addEventListener("submit", (event) => { + event.preventDefault(); + startJob("/api/pipeline", { scriptPath: scriptPath.value.trim() }); +}); + +async function loadOutputs() { + outputCount.textContent = "Loading result folders..."; + outputsList.innerHTML = ""; + try { + const response = await fetch("/api/outputs"); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Failed to load outputs"); + state.outputs = data.outputs; + renderOutputs(); + } catch (error) { + outputCount.textContent = "Could not load outputs"; + outputsList.innerHTML = `
${escapeHtml(error.message)}
`; + } +} + +function renderOutputs() { + outputCount.textContent = `${state.outputs.length} result folder${state.outputs.length === 1 ? "" : "s"} found`; + if (state.outputs.length === 0) { + outputsList.innerHTML = '
No result folders found under output/.
'; + return; + } + + outputsList.innerHTML = state.outputs.map((item) => { + const badges = [ + ["script", item.artifacts.scriptJson], + ["video", item.artifacts.videoMp4], + ["voice", item.artifacts.voiceMp3], + ["text", item.artifacts.scriptTxt], + ].map(([label, ok]) => `${label}`).join(""); + + const links = [ + item.artifacts.videoMp4 ? `Video` : "", + item.artifacts.voiceMp3 ? `Audio` : "", + item.artifacts.scriptTxt ? `Script` : "", + item.artifacts.scriptJson ? `JSON` : "", + ].filter(Boolean).join(""); + + return ` +
+
+

${escapeHtml(item.title)}

+

${escapeHtml(item.outputDir)}

+
${badges}
+
+
+ + +
+
+ `; + }).join(""); + + outputsList.querySelectorAll("button").forEach((button) => { + button.addEventListener("click", () => { + const card = button.closest(".output-card"); + const selectedScriptPath = card.dataset.scriptPath; + scriptPath.value = selectedScriptPath; + if (button.dataset.action === "run") { + startJob("/api/pipeline", { scriptPath: selectedScriptPath }); + } + }); + }); +} + +async function startJob(endpoint, payload) { + setJobMessage("Starting job...", ""); + artifactLinks.innerHTML = ""; + + try { + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Could not start job"); + connectJob(data.job); + } catch (error) { + setJobMessage("Failed to start", error.message); + } +} + +function connectJob(job) { + state.activeJob = job; + if (state.events) state.events.close(); + + setJobMessage("running", job.logs.join("\n")); + const events = new EventSource(`/api/jobs/${job.id}/events`); + state.events = events; + + events.addEventListener("snapshot", (event) => { + const snapshot = JSON.parse(event.data); + setJobMessage(formatStatus(snapshot), snapshot.logs.join("\n")); + if (snapshot.status !== "running") finishJob(snapshot); + }); + + events.addEventListener("log", (event) => { + const { line } = JSON.parse(event.data); + jobLog.textContent += `${line}\n`; + jobLog.scrollTop = jobLog.scrollHeight; + }); + + events.addEventListener("progress", (event) => { + const { message } = JSON.parse(event.data); + jobLog.textContent += `> ${message}\n`; + jobLog.scrollTop = jobLog.scrollHeight; + }); + + events.addEventListener("status", (event) => { + const snapshot = JSON.parse(event.data); + finishJob(snapshot); + }); + + events.onerror = () => { + if (state.activeJob?.status === "running") { + jobStatus.textContent = "Log stream disconnected"; + } + }; +} + +function finishJob(job) { + state.activeJob = job; + jobStatus.textContent = formatStatus(job); + if (state.events) { + state.events.close(); + state.events = null; + } + renderArtifactLinks(job); + loadOutputs(); +} + +function renderArtifactLinks(job) { + const dir = job.outputDir; + if (!dir) { + artifactLinks.innerHTML = ""; + return; + } + const encoded = dir.replace(/^output\//, "").split("/").map(encodeURIComponent).join("/"); + artifactLinks.innerHTML = ` + video.mp4 + voice.mp3 + script.txt + `; +} + +function setJobMessage(status, log) { + jobStatus.textContent = status; + jobLog.textContent = log ? `${log}\n` : ""; + jobLog.scrollTop = jobLog.scrollHeight; +} + +function formatStatus(job) { + const exit = job.exitCode === undefined || job.exitCode === null ? "" : `, exit ${job.exitCode}`; + return `pipeline ${job.status}${exit}`; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +loadOutputs(); diff --git a/src/ui/index.html b/src/ui/index.html new file mode 100644 index 0000000..cff82f7 --- /dev/null +++ b/src/ui/index.html @@ -0,0 +1,68 @@ + + + + + + Auto Create Video + + + +
+
+
+

Local renderer

+

Auto Create Video

+
+ +
+ +
+
+
+

Generate Video

+

Paste an article URL. The server fetches it, generates a script, and renders the video.

+
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+ +
+
+
+

Outputs

+

Loading result folders...

+
+
+
+
+ +
+
+
+

Job Log

+

Idle

+
+ +
+

+      
+
+ + + + diff --git a/src/ui/styles.css b/src/ui/styles.css new file mode 100644 index 0000000..52cead4 --- /dev/null +++ b/src/ui/styles.css @@ -0,0 +1,317 @@ +:root { + color-scheme: dark; + --bg: #101316; + --panel: #191d22; + --panel-strong: #20262d; + --text: #f3f5f7; + --muted: #a5adb8; + --line: #303842; + --accent: #27d3a2; + --accent-strong: #1fb68c; + --warn: #f0b25a; + --danger: #ef6b73; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); +} + +button, +input { + font: inherit; +} + +a { + color: var(--accent); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.app-shell { + width: min(1180px, calc(100vw - 32px)); + margin: 0 auto; + padding: 28px 0; + display: grid; + grid-template-columns: minmax(320px, 420px) minmax(0, 1fr); + gap: 16px; +} + +.topbar { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line); + padding-bottom: 18px; +} + +.eyebrow { + margin: 0 0 6px; + color: var(--accent); + text-transform: uppercase; + font-size: 12px; + letter-spacing: 0; + font-weight: 700; +} + +h1, +h2, +h3, +p { + margin: 0; +} + +h1 { + font-size: 34px; + line-height: 1.05; +} + +h2 { + font-size: 18px; + line-height: 1.2; +} + +h3 { + font-size: 16px; + line-height: 1.25; +} + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 18px; +} + +.panel-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.panel-heading p, +.output-card p { + color: var(--muted); + font-size: 13px; + line-height: 1.45; + margin-top: 6px; +} + +.controls-panel { + align-self: start; +} + +.outputs-panel, +.logs-panel { + grid-column: 2; +} + +.action-form { + display: grid; + gap: 8px; + margin-top: 16px; +} + +.action-form label { + color: var(--muted); + font-size: 13px; + font-weight: 650; +} + +.field-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 132px; + gap: 8px; +} + +.derived-output { + color: var(--muted); + font-size: 13px; + line-height: 1.4; +} + +input { + width: 100%; + min-width: 0; + color: var(--text); + background: #0f1215; + border: 1px solid var(--line); + border-radius: 6px; + padding: 11px 12px; + outline: none; +} + +input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(39, 211, 162, 0.12); +} + +button { + border: 0; + border-radius: 6px; + color: #07110e; + background: var(--accent); + padding: 11px 14px; + font-weight: 750; + cursor: pointer; +} + +button:hover { + background: var(--accent-strong); +} + +.icon-button { + width: 42px; + height: 42px; + padding: 0; + display: grid; + place-items: center; + font-size: 22px; +} + +.outputs-list { + display: grid; + gap: 10px; +} + +.output-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + padding: 14px; + background: var(--panel-strong); + border: 1px solid var(--line); + border-radius: 8px; +} + +.badges, +.links, +.artifact-links { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.badges { + margin-top: 10px; +} + +.badge { + border: 1px solid var(--line); + border-radius: 999px; + padding: 4px 8px; + font-size: 12px; + line-height: 1; + color: var(--muted); +} + +.badge.ok { + color: var(--accent); + border-color: rgba(39, 211, 162, 0.35); +} + +.badge.missing { + color: var(--warn); + border-color: rgba(240, 178, 90, 0.35); +} + +.output-actions { + min-width: 168px; + display: grid; + justify-items: end; + align-content: start; + gap: 8px; +} + +.output-actions button { + width: 144px; + padding: 9px 10px; +} + +.output-actions button:first-child { + background: #d8dee8; +} + +.links { + justify-content: end; + font-size: 13px; +} + +.logs-panel { + min-height: 360px; +} + +.job-log { + width: 100%; + height: 320px; + overflow: auto; + margin: 0; + padding: 14px; + color: #d9e1ea; + background: #090b0e; + border: 1px solid var(--line); + border-radius: 8px; + white-space: pre-wrap; + font-size: 13px; + line-height: 1.55; +} + +.empty-state { + padding: 18px; + color: var(--muted); + background: var(--panel-strong); + border: 1px dashed var(--line); + border-radius: 8px; +} + +code { + color: #d9e1ea; + background: #0f1215; + border: 1px solid var(--line); + border-radius: 4px; + padding: 1px 5px; +} + +@media (max-width: 860px) { + .app-shell { + grid-template-columns: 1fr; + width: min(100vw - 20px, 640px); + padding: 18px 0; + } + + .outputs-panel, + .logs-panel { + grid-column: auto; + } + + .output-card, + .field-row { + grid-template-columns: 1fr; + } + + .output-actions { + justify-items: stretch; + } + + .output-actions button { + width: 100%; + } + + .links { + justify-content: flex-start; + } +} From 8907336616819e9dce50f81a3dd03dc875a9d314 Mon Sep 17 00:00:00 2001 From: Hong Phuc Date: Wed, 20 May 2026 12:38:57 +0700 Subject: [PATCH 02/14] Add TikTok UI settings Allow the local web UI to toggle TikTok info rendering and persist TikTok profile settings outside env-only configuration. Tested: npm test Tested: npm run typecheck Tested: npm run build Tested: npm audit --audit-level=moderate --- .env.example | 1 + package-lock.json | 27 +++--- rerender.ts | 29 ++++--- src/config.test.ts | 35 +++++++- src/config.ts | 17 ++-- src/llm/anthropic-client.ts | 2 +- src/llm/llm-client.ts | 4 + src/llm/openai-compatible-client.ts | 2 +- src/pipeline.ts | 23 ++--- src/render/html-composer.test.ts | 26 ++++++ src/render/html-composer.ts | 18 ++-- src/server.test.ts | 63 ++++++++++++++ src/server.ts | 126 ++++++++++++++++++++++++++-- src/ui/app.js | 73 ++++++++++++++++ src/ui/index.html | 25 ++++++ src/ui/styles.css | 65 +++++++++++++- 16 files changed, 478 insertions(+), 58 deletions(-) diff --git a/.env.example b/.env.example index f5851d5..7dd0630 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,7 @@ ELEVENLABS_ENDPOINT=https://api.elevenlabs.io/v1 # ════════════════════════════════════════════════════════════════════════════ # Customize the TikTok-style profile card that appears at the end of every video. # All fields optional — defaults work out of the box. +TIKTOK_ENABLED=true TIKTOK_DISPLAY_NAME=Quẹp Làm IT TIKTOK_HANDLE=@haiquep TIKTOK_FOLLOWERS=11.5k followers diff --git a/package-lock.json b/package-lock.json index 70052a3..2ac9bb9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1269,14 +1269,13 @@ "optional": true }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "optional": true, "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -1287,9 +1286,9 @@ "optional": true }, "node_modules/@protobufjs/inquire": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", - "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz", + "integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==", "license": "BSD-3-Clause", "optional": true }, @@ -4497,9 +4496,9 @@ } }, "node_modules/protobufjs": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz", - "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.0.tgz", + "integrity": "sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ==", "hasInstallScript": true, "license": "BSD-3-Clause", "optional": true, @@ -4508,14 +4507,14 @@ "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.1", + "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" diff --git a/rerender.ts b/rerender.ts index 8e4b8e8..281e7c7 100644 --- a/rerender.ts +++ b/rerender.ts @@ -106,20 +106,23 @@ async function main() { const bgImageRelPath = fs.existsSync(bgImagePath) ? "images/bg.jpg" : null; console.log(`bgImage: ${bgImageRelPath ?? "(none — gradient fallback)"}`); - // TikTok avatar — find bundled (jpg/jpeg/png/webp) and copy to output dir - let bundledAvatar: string | null = null; - for (const ext of ["jpg", "jpeg", "png", "webp"]) { - const p = join(__dirname, "assets", `avatar.${ext}`); - if (existsSync(p)) { bundledAvatar = p; break; } - } - if (!bundledAvatar) { - throw new Error("No bundled avatar found. Place an image at assets/avatar.{jpg,png,webp}"); + // TikTok avatar — only needed when the outro follow card is enabled. + let ttAvatarFile = ""; + if (cfg.tiktok.enabled) { + let bundledAvatar: string | null = null; + for (const ext of ["jpg", "jpeg", "png", "webp"]) { + const p = join(__dirname, "assets", `avatar.${ext}`); + if (existsSync(p)) { bundledAvatar = p; break; } + } + if (!bundledAvatar) { + throw new Error("No bundled avatar found. Place an image at assets/avatar.{jpg,png,webp}"); + } + const ttAvatarExt = bundledAvatar.split(".").pop()!.toLowerCase(); + ttAvatarFile = `tiktok-avatar.${ttAvatarExt}`; + const ttAvatarOut = join(outputDir, ttAvatarFile); + // Always re-copy in case bundled was updated + await copyFile(bundledAvatar, ttAvatarOut); } - const ttAvatarExt = bundledAvatar.split(".").pop()!.toLowerCase(); - const ttAvatarFile = `tiktok-avatar.${ttAvatarExt}`; - const ttAvatarOut = join(outputDir, ttAvatarFile); - // Always re-copy in case bundled was updated - await copyFile(bundledAvatar, ttAvatarOut); // Compose HTML const html = composeHtml({ diff --git a/src/config.test.ts b/src/config.test.ts index ba362ac..da67d0e 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -17,6 +17,11 @@ const ENV_KEYS = [ "LLM_API_KEY", "LLM_MODEL", "LLM_ENDPOINT", + "TIKTOK_ENABLED", + "TIKTOK_DISPLAY_NAME", + "TIKTOK_HANDLE", + "TIKTOK_FOLLOWERS", + "TIKTOK_AVATAR_URL", ]; describe("loadConfig", () => { @@ -25,7 +30,6 @@ describe("loadConfig", () => { beforeEach(() => { saved = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); ENV_KEYS.forEach((k) => delete process.env[k]); - process.env.LLM_API_KEY = "sk-test-llm-key"; }); afterEach(() => { @@ -58,6 +62,35 @@ describe("loadConfig", () => { expect(cfg.lucylabPollIntervalMs).toBe(2000); expect(cfg.lucylabPollTimeoutMs).toBe(120000); expect(cfg.ttsConcurrency).toBe(1); + expect(cfg.llmApiKey).toBeUndefined(); + expect(cfg.tiktok.enabled).toBe(true); + }); + + it("reads TikTok env overrides", () => { + process.env.VIETNAMESE_API_KEY = "k"; + process.env.VIETNAMESE_VOICEID = "v"; + process.env.TIKTOK_ENABLED = "false"; + process.env.TIKTOK_DISPLAY_NAME = "Custom Channel"; + process.env.TIKTOK_HANDLE = "@custom"; + process.env.TIKTOK_FOLLOWERS = "42 followers"; + process.env.TIKTOK_AVATAR_URL = "https://example.com/avatar.jpg"; + + const cfg = loadConfig(); + + expect(cfg.tiktok).toEqual({ + enabled: false, + displayName: "Custom Channel", + handle: "@custom", + followers: "42 followers", + avatarUrl: "https://example.com/avatar.jpg", + }); + }); + + it("rejects invalid TIKTOK_ENABLED values", () => { + process.env.VIETNAMESE_API_KEY = "k"; + process.env.VIETNAMESE_VOICEID = "v"; + process.env.TIKTOK_ENABLED = "maybe"; + expect(() => loadConfig()).toThrow(/TIKTOK_ENABLED/); }); }); diff --git a/src/config.ts b/src/config.ts index fcd3219..910291b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,7 @@ export type TtsProvider = "lucylab" | "elevenlabs"; export type LlmProvider = "anthropic" | "openai" | "deepseek"; export interface TiktokConfig { + enabled: boolean; displayName: string; handle: string; followers: string; @@ -23,7 +24,7 @@ export interface Config { // LLM llmProvider: LlmProvider; - llmApiKey: string; + llmApiKey?: string; llmModel: string; llmEndpoint?: string; @@ -54,6 +55,14 @@ function intDefault(name: string, def: number): number { return n; } +function boolDefault(name: string, def: boolean): boolean { + const v = process.env[name]; + if (!v) return def; + if (["1", "true", "yes", "on"].includes(v.toLowerCase())) return true; + if (["0", "false", "no", "off"].includes(v.toLowerCase())) return false; + throw new Error(`Env var ${name} must be boolean, got "${v}"`); +} + export function loadConfig(): Config { const provider = (process.env.TTS_PROVIDER ?? "lucylab") as TtsProvider; if (provider !== "lucylab" && provider !== "elevenlabs") { @@ -64,9 +73,6 @@ export function loadConfig(): Config { if (!["anthropic", "openai", "deepseek"].includes(llmProvider)) { throw new Error(`LLM_PROVIDER must be "anthropic", "openai", or "deepseek", got "${llmProvider}"`); } - if (!process.env.LLM_API_KEY || process.env.LLM_API_KEY.trim() === "") { - throw new Error("Missing LLM_API_KEY"); - } // Validate provider-specific required vars if (provider === "lucylab") { @@ -100,7 +106,7 @@ export function loadConfig(): Config { return { ttsProvider: provider, llmProvider, - llmApiKey: process.env.LLM_API_KEY!.trim(), + llmApiKey: process.env.LLM_API_KEY?.trim() || undefined, llmModel: process.env.LLM_MODEL ?? "claude-haiku-4-5-20251001", llmEndpoint: process.env.LLM_ENDPOINT || undefined, lucylabApiKey: process.env.VIETNAMESE_API_KEY, @@ -113,6 +119,7 @@ export function loadConfig(): Config { elevenlabsModelId: process.env.ELEVENLABS_MODEL_ID ?? "eleven_multilingual_v2", elevenlabsEndpoint: process.env.ELEVENLABS_ENDPOINT ?? "https://api.elevenlabs.io/v1", tiktok: { + enabled: boolDefault("TIKTOK_ENABLED", true), displayName: process.env.TIKTOK_DISPLAY_NAME ?? "Công nghệ 24h", handle: process.env.TIKTOK_HANDLE ?? "@congnghe24h", followers: process.env.TIKTOK_FOLLOWERS ?? "1.2M followers", diff --git a/src/llm/anthropic-client.ts b/src/llm/anthropic-client.ts index 9b576c1..742fa9e 100644 --- a/src/llm/anthropic-client.ts +++ b/src/llm/anthropic-client.ts @@ -20,7 +20,7 @@ export class AnthropicClient implements LlmClient { private client: Anthropic; constructor(private cfg: Config) { - this.client = new Anthropic({ apiKey: cfg.llmApiKey, timeout: 120_000 }); + this.client = new Anthropic({ apiKey: cfg.llmApiKey!, timeout: 120_000 }); } async generateScript( diff --git a/src/llm/llm-client.ts b/src/llm/llm-client.ts index f8f1061..116beaf 100644 --- a/src/llm/llm-client.ts +++ b/src/llm/llm-client.ts @@ -24,6 +24,10 @@ export interface LlmClient { } export function createLlmClient(cfg: Config): LlmClient { + if (!cfg.llmApiKey) { + throw new Error("Missing LLM_API_KEY"); + } + switch (cfg.llmProvider) { case "anthropic": return new AnthropicClient(cfg); diff --git a/src/llm/openai-compatible-client.ts b/src/llm/openai-compatible-client.ts index 33308e7..fee05c3 100644 --- a/src/llm/openai-compatible-client.ts +++ b/src/llm/openai-compatible-client.ts @@ -28,7 +28,7 @@ export class OpenAICompatibleClient implements LlmClient { constructor(private cfg: Config) { this.client = new OpenAI({ - apiKey: cfg.llmApiKey, + apiKey: cfg.llmApiKey!, baseURL: cfg.llmProvider === "deepseek" ? (cfg.llmEndpoint ?? "https://api.deepseek.com/v1") : (cfg.llmEndpoint || undefined), diff --git a/src/pipeline.ts b/src/pipeline.ts index aedd397..1c55d6b 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -189,18 +189,21 @@ export async function runPipeline(scriptPath: string): Promise { } throw new Error(`No bundled avatar found. Place an image at assets/avatar.{jpg,png,webp}`); }; - const bundledAvatar = findBundledAvatar(); - const ttAvatarExt = bundledAvatar.split(".").pop()!.toLowerCase(); - const ttAvatarFile = `tiktok-avatar.${ttAvatarExt}`; - const ttAvatarOut = join(outputDir, ttAvatarFile); - if (cfg.tiktok.avatarUrl) { - const r = await fetchImage(cfg.tiktok.avatarUrl, ttAvatarOut); - if (!r.success) { - log.warn(`TikTok avatar download failed: ${r.reason} → falling back to bundled default`); + let ttAvatarFile = ""; + if (cfg.tiktok.enabled) { + const bundledAvatar = findBundledAvatar(); + const ttAvatarExt = bundledAvatar.split(".").pop()!.toLowerCase(); + ttAvatarFile = `tiktok-avatar.${ttAvatarExt}`; + const ttAvatarOut = join(outputDir, ttAvatarFile); + if (cfg.tiktok.avatarUrl) { + const r = await fetchImage(cfg.tiktok.avatarUrl, ttAvatarOut); + if (!r.success) { + log.warn(`TikTok avatar download failed: ${r.reason} → falling back to bundled default`); + await copyFile(bundledAvatar, ttAvatarOut); + } + } else { await copyFile(bundledAvatar, ttAvatarOut); } - } else { - await copyFile(bundledAvatar, ttAvatarOut); } const html = composeHtml({ diff --git a/src/render/html-composer.test.ts b/src/render/html-composer.test.ts index 7567da4..2830a52 100644 --- a/src/render/html-composer.test.ts +++ b/src/render/html-composer.test.ts @@ -98,4 +98,30 @@ describe("composeHtml", () => { expect(html).toContain('class="bg gradient-news-dark"'); expect(html).not.toContain("background-image: url"); }); + + it("omits TikTok handle and outro card when TikTok is disabled", () => { + const script = JSON.parse(readFileSync("tests/fixtures/sample-script-with-image.json", "utf8")) as Script; + const sceneAudio = script.scenes.map((s) => ({ id: s.id, durationSec: 5 })); + const html = composeHtml({ + script, + sceneAudio, + gapSec: 0.3, + bgImageRelPath: "images/bg.jpg", + audioRelPath: "voice.mp3", + tiktok: { + enabled: false, + displayName: "Hidden Channel", + handle: "@hidden", + followers: "0 followers", + }, + tiktokAvatarRelPath: "", + }); + + expect(html).toContain('class="brand-shell-header"'); + expect(html).toContain('class="brand-shell-keyword"'); + expect(html).not.toContain('class="brand-shell-handle"'); + expect(html).not.toContain('
a.id === scene.id); if (!audio) throw new Error(`No audio entry for scene id=${scene.id}`); const isOutro = scene.type === "outro"; - const dur = audio.durationSec + gapSec + (isOutro ? outroHoldSec : 0); + const dur = audio.durationSec + gapSec + (isOutro && tiktok.enabled ? outroHoldSec : 0); const start = cursor; cursor += dur; return { scene, start, duration: dur }; @@ -61,7 +62,7 @@ export function composeHtml(args: ComposeArgs): string { return renderScene(scene, start, duration, bgImageRelPath, tiktok, tiktokAvatar); }).join("\n"); - // Persistent shell — uses tiktok handle in footer + // Persistent shell — optionally uses the TikTok handle in footer const shellHtml = renderShell(script.metadata, tiktok); const animJs = readFileSync(join(TPL_DIR, "animations.js"), "utf8"); @@ -81,6 +82,12 @@ function renderShell(metadata: Script["metadata"], tiktok: TiktokConfig): string const channel = escapeHtml(metadata.channel); const domain = escapeHtml(metadata.source.domain); const handle = escapeHtml(tiktok.handle); + const tiktokHandle = tiktok.enabled + ? `
+ + ${handle} +
` + : ""; return `
@@ -93,10 +100,7 @@ function renderShell(metadata: Script["metadata"], tiktok: TiktokConfig): string
-
- - ${handle} -
+${tiktokHandle}
${escapeHtml(domain)} @@ -248,7 +252,7 @@ function renderOutroInner( tiktok: TiktokConfig, avatarRelPath: string, ): string { - const ttCard = renderTiktokCard(tiktok, avatarRelPath); + const ttCard = tiktok.enabled ? renderTiktokCard(tiktok, avatarRelPath) : ""; return `
${escapeHtml(td.ctaTop)}
diff --git a/src/server.test.ts b/src/server.test.ts index 9324cf4..632562a 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -3,10 +3,16 @@ import { join } from "node:path"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { OUTPUT_ROOT, + SETTINGS_PATH, assertExistingScriptPath, + defaultUiSettings, listOutputs, + normalizeUiSettings, + readUiSettings, safeOutputPath, + settingsToEnv, toOutputRelative, + writeUiSettings, } from "./server.js"; const FIXTURE_NAME = "ui-server-test-fixture"; @@ -15,11 +21,13 @@ const FIXTURE_DIR = join(OUTPUT_ROOT, FIXTURE_NAME); describe("local UI server helpers", () => { beforeEach(async () => { await rm(FIXTURE_DIR, { recursive: true, force: true }); + await rm(SETTINGS_PATH, { force: true }); await mkdir(FIXTURE_DIR, { recursive: true }); }); afterEach(async () => { await rm(FIXTURE_DIR, { recursive: true, force: true }); + await rm(SETTINGS_PATH, { force: true }); }); it("rejects paths outside output", () => { @@ -70,4 +78,59 @@ describe("local UI server helpers", () => { }, }); }); + + it("loads default UI settings from environment fallback", async () => { + await expect(readUiSettings()).resolves.toEqual(defaultUiSettings()); + }); + + it("normalizes and persists TikTok UI settings", async () => { + const settings = await writeUiSettings({ + tiktok: { + enabled: false, + displayName: " UI Channel ", + handle: " @ui-channel ", + followers: " 99 followers ", + avatarUrl: " https://example.com/avatar.png ", + }, + }); + + expect(settings).toEqual({ + tiktok: { + enabled: false, + displayName: "UI Channel", + handle: "@ui-channel", + followers: "99 followers", + avatarUrl: "https://example.com/avatar.png", + }, + }); + await expect(readUiSettings()).resolves.toEqual(settings); + expect(settingsToEnv(settings)).toMatchObject({ + TIKTOK_ENABLED: "false", + TIKTOK_DISPLAY_NAME: "UI Channel", + TIKTOK_HANDLE: "@ui-channel", + TIKTOK_FOLLOWERS: "99 followers", + TIKTOK_AVATAR_URL: "https://example.com/avatar.png", + }); + }); + + it("rejects invalid TikTok settings", () => { + expect(() => normalizeUiSettings({ + tiktok: { + enabled: true, + displayName: "", + handle: "@bad", + followers: "1", + }, + })).toThrow(/display name/); + + expect(() => normalizeUiSettings({ + tiktok: { + enabled: true, + displayName: "Bad", + handle: "@bad", + followers: "1", + avatarUrl: "ftp://example.com/avatar.png", + }, + })).toThrow(/HTTP\(S\)/); + }); }); diff --git a/src/server.ts b/src/server.ts index 93f135b..5621d0f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,11 +7,13 @@ import { dirname, extname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { ScriptSchema } from "./render/script-schema.js"; import { loadConfig } from "./config.js"; +import type { TiktokConfig } from "./config.js"; import { createLlmClient } from "./llm/llm-client.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); export const PROJECT_ROOT = resolve(__dirname, ".."); export const OUTPUT_ROOT = join(PROJECT_ROOT, "output"); +export const SETTINGS_PATH = join(OUTPUT_ROOT, ".ui-settings.json"); const UI_ROOT = join(PROJECT_ROOT, "src", "ui"); const MAX_BODY_BYTES = 64 * 1024; @@ -58,6 +60,10 @@ interface Job { outputDir?: string; } +export interface UiSettings { + tiktok: TiktokConfig; +} + const jobs = new Map(); let runningJob: Job | null = null; @@ -152,6 +158,99 @@ export async function listOutputs(): Promise { .sort((a, b) => Date.parse(b.modifiedAt) - Date.parse(a.modifiedAt)); } +function boolFromEnv(name: string, def: boolean): boolean { + const v = process.env[name]; + if (!v) return def; + if (["1", "true", "yes", "on"].includes(v.toLowerCase())) return true; + if (["0", "false", "no", "off"].includes(v.toLowerCase())) return false; + return def; +} + +export function defaultUiSettings(): UiSettings { + return { + tiktok: { + enabled: boolFromEnv("TIKTOK_ENABLED", true), + displayName: process.env.TIKTOK_DISPLAY_NAME ?? "Công nghệ 24h", + handle: process.env.TIKTOK_HANDLE ?? "@congnghe24h", + followers: process.env.TIKTOK_FOLLOWERS ?? "1.2M followers", + avatarUrl: process.env.TIKTOK_AVATAR_URL || undefined, + }, + }; +} + +function requiredString(value: unknown, name: string): string { + if (typeof value !== "string") { + throw new Error(`${name} must be a string`); + } + const trimmed = value.trim(); + if (!trimmed) { + throw new Error(`${name} is required`); + } + return trimmed; +} + +function optionalUrl(value: unknown, name: string): string | undefined { + if (value === undefined || value === null || value === "") return undefined; + if (typeof value !== "string") { + throw new Error(`${name} must be a string`); + } + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (!/^https?:\/\/.+/.test(trimmed)) { + throw new Error(`${name} must be an HTTP(S) URL`); + } + return trimmed; +} + +export function normalizeUiSettings(input: unknown, fallback = defaultUiSettings()): UiSettings { + const source = input && typeof input === "object" ? input as Record : {}; + const tiktokInput = source.tiktok && typeof source.tiktok === "object" + ? source.tiktok as Record + : {}; + const enabled = typeof tiktokInput.enabled === "boolean" + ? tiktokInput.enabled + : fallback.tiktok.enabled; + + return { + tiktok: { + enabled, + displayName: requiredString(tiktokInput.displayName ?? fallback.tiktok.displayName, "TikTok display name"), + handle: requiredString(tiktokInput.handle ?? fallback.tiktok.handle, "TikTok handle"), + followers: requiredString(tiktokInput.followers ?? fallback.tiktok.followers, "TikTok followers"), + avatarUrl: optionalUrl(tiktokInput.avatarUrl ?? fallback.tiktok.avatarUrl, "TikTok avatar URL"), + }, + }; +} + +export async function readUiSettings(): Promise { + const defaults = defaultUiSettings(); + try { + const raw = JSON.parse(await readFile(SETTINGS_PATH, "utf8")); + return normalizeUiSettings(raw, defaults); + } catch (e) { + const code = typeof e === "object" && e && "code" in e ? String((e as NodeJS.ErrnoException).code) : ""; + if (code === "ENOENT") return defaults; + throw e; + } +} + +export async function writeUiSettings(input: unknown): Promise { + const settings = normalizeUiSettings(input, await readUiSettings()); + await mkdir(OUTPUT_ROOT, { recursive: true }); + await writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)); + return settings; +} + +export function settingsToEnv(settings: UiSettings): NodeJS.ProcessEnv { + return { + TIKTOK_ENABLED: settings.tiktok.enabled ? "true" : "false", + TIKTOK_DISPLAY_NAME: settings.tiktok.displayName, + TIKTOK_HANDLE: settings.tiktok.handle, + TIKTOK_FOLLOWERS: settings.tiktok.followers, + TIKTOK_AVATAR_URL: settings.tiktok.avatarUrl ?? "", + }; +} + function createJob(input: string): Job { if (runningJob) { throw new Error(`Job ${runningJob.id} is still running`); @@ -189,13 +288,14 @@ function emitProgress(job: Job, message: string): void { emit(job, "progress", { message }); } -function spawnPipeline(job: Job, scriptPath: string): void { +async function spawnPipeline(job: Job, scriptPath: string): Promise { + const settings = await readUiSettings(); const relPath = toOutputRelative(scriptPath); appendLog(job, `$ npm run pipeline -- ${relPath}`); const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"; const child = spawn(npmCmd, ["run", "pipeline", "--", relPath], { cwd: PROJECT_ROOT, - env: process.env, + env: { ...process.env, ...settingsToEnv(settings) }, stdio: ["ignore", "pipe", "pipe"], }); @@ -264,7 +364,8 @@ async function serveStatic(res: ServerResponse, root: string, relPath: string): const decoded = decodeURIComponent(relPath); const target = resolve(root, decoded); const rel = relative(root, target); - if (rel.startsWith("..") || rel.split(sep).includes("..")) { + const parts = rel.split(sep); + if (rel.startsWith("..") || parts.includes("..") || parts.some((part) => part.startsWith("."))) { sendError(res, 403, "Forbidden"); return; } @@ -364,7 +465,7 @@ async function runGenerateJob(job: Job, url: string, res: ServerResponse): Promi emitProgress(job, `Script written to ${toOutputRelative(scriptPath)}`); emitProgress(job, "Starting pipeline..."); - spawnPipeline(job, scriptPath); + await spawnPipeline(job, scriptPath); } catch (e) { const message = e instanceof Error ? e.message : String(e); appendLog(job, `Generate failed: ${message}`); @@ -381,6 +482,17 @@ export async function handleRequest(req: IncomingMessage, res: ServerResponse): return; } + if (req.method === "GET" && url.pathname === "/api/settings") { + sendJson(res, 200, { settings: await readUiSettings() }); + return; + } + + if (req.method === "PUT" && url.pathname === "/api/settings") { + const body = await readJsonBody(req); + sendJson(res, 200, { settings: await writeUiSettings(body) }); + return; + } + if (req.method === "POST" && url.pathname === "/api/generate") { const body = await readJsonBody(req); const articleUrl = String(body.url || "").trim(); @@ -399,8 +511,12 @@ export async function handleRequest(req: IncomingMessage, res: ServerResponse): const body = await readJsonBody(req); const scriptPath = await assertExistingScriptPath(body.scriptPath); const job = createJob(scriptPath); + job.outputDir = toOutputRelative(dirname(join(PROJECT_ROOT, scriptPath))); sendJson(res, 202, { job: serializeJob(job) }); - spawnPipeline(job, join(PROJECT_ROOT, scriptPath)); + spawnPipeline(job, join(PROJECT_ROOT, scriptPath)).catch((e) => { + appendLog(job, `Failed to start pipeline: ${e instanceof Error ? e.message : String(e)}`); + finishJob(job, "failed", null); + }); return; } diff --git a/src/ui/app.js b/src/ui/app.js index 93cb7aa..cbf90b9 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -13,6 +13,12 @@ const scriptPath = $("#scriptPath"); const jobLog = $("#jobLog"); const jobStatus = $("#jobStatus"); const artifactLinks = $("#artifactLinks"); +const settingsStatus = $("#settingsStatus"); +const tiktokEnabled = $("#tiktokEnabled"); +const tiktokDisplayName = $("#tiktokDisplayName"); +const tiktokHandle = $("#tiktokHandle"); +const tiktokFollowers = $("#tiktokFollowers"); +const tiktokAvatarUrl = $("#tiktokAvatarUrl"); $("#refreshOutputs").addEventListener("click", loadOutputs); @@ -26,6 +32,72 @@ $("#pipelineForm").addEventListener("submit", (event) => { startJob("/api/pipeline", { scriptPath: scriptPath.value.trim() }); }); +$("#settingsForm").addEventListener("submit", async (event) => { + event.preventDefault(); + await saveSettings(); +}); + +tiktokEnabled.addEventListener("change", updateTiktokInputs); + +async function loadSettings() { + settingsStatus.textContent = "Loading settings..."; + try { + const response = await fetch("/api/settings"); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Failed to load settings"); + applySettings(data.settings); + settingsStatus.textContent = "Settings ready"; + } catch (error) { + settingsStatus.textContent = error.message; + } +} + +async function saveSettings() { + settingsStatus.textContent = "Saving settings..."; + try { + const response = await fetch("/api/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(readSettingsForm()), + }); + const data = await response.json(); + if (!response.ok) throw new Error(data.error || "Failed to save settings"); + applySettings(data.settings); + settingsStatus.textContent = "Settings saved"; + } catch (error) { + settingsStatus.textContent = error.message; + } +} + +function applySettings(settings) { + const tiktok = settings.tiktok; + tiktokEnabled.checked = Boolean(tiktok.enabled); + tiktokDisplayName.value = tiktok.displayName || ""; + tiktokHandle.value = tiktok.handle || ""; + tiktokFollowers.value = tiktok.followers || ""; + tiktokAvatarUrl.value = tiktok.avatarUrl || ""; + updateTiktokInputs(); +} + +function readSettingsForm() { + return { + tiktok: { + enabled: tiktokEnabled.checked, + displayName: tiktokDisplayName.value.trim(), + handle: tiktokHandle.value.trim(), + followers: tiktokFollowers.value.trim(), + avatarUrl: tiktokAvatarUrl.value.trim(), + }, + }; +} + +function updateTiktokInputs() { + const disabled = !tiktokEnabled.checked; + [tiktokDisplayName, tiktokHandle, tiktokFollowers, tiktokAvatarUrl].forEach((input) => { + input.disabled = disabled; + }); +} + async function loadOutputs() { outputCount.textContent = "Loading result folders..."; outputsList.innerHTML = ""; @@ -191,4 +263,5 @@ function escapeHtml(value) { .replaceAll("'", "'"); } +loadSettings(); loadOutputs(); diff --git a/src/ui/index.html b/src/ui/index.html index cff82f7..10ec340 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -39,6 +39,31 @@

Generate Video

+ +
+
+
+ + +
+
+ + + + + + + + + + + +
+
+

Settings load from env until saved here.

+ +
+
diff --git a/src/ui/styles.css b/src/ui/styles.css index 52cead4..77c1fcc 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -159,6 +159,17 @@ input:focus { box-shadow: 0 0 0 3px rgba(39, 211, 162, 0.12); } +input:disabled { + opacity: 0.58; + cursor: not-allowed; +} + +input[type="checkbox"] { + width: 20px; + height: 20px; + accent-color: var(--accent); +} + button { border: 0; border-radius: 6px; @@ -182,6 +193,56 @@ button:hover { font-size: 22px; } +.settings-form { + display: grid; + gap: 12px; + margin-top: 18px; +} + +.section-divider { + height: 1px; + background: var(--line); + margin-bottom: 2px; +} + +.switch-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; +} + +.switch-row label, +.settings-grid label { + color: var(--muted); + font-size: 13px; + font-weight: 650; +} + +.settings-grid { + display: grid; + grid-template-columns: 104px minmax(0, 1fr); + align-items: center; + gap: 9px 10px; +} + +.settings-grid input { + padding: 9px 10px; +} + +.settings-actions { + display: grid; + grid-template-columns: minmax(0, 1fr) 132px; + align-items: center; + gap: 10px; +} + +.settings-actions p { + color: var(--muted); + font-size: 13px; + line-height: 1.4; +} + .outputs-list { display: grid; gap: 10px; @@ -299,7 +360,9 @@ code { } .output-card, - .field-row { + .field-row, + .settings-grid, + .settings-actions { grid-template-columns: 1fr; } From 82e1e55d50cca0958b896a60e37f63a82f70c7f8 Mon Sep 17 00:00:00 2001 From: Hong Phuc Date: Wed, 20 May 2026 14:01:05 +0700 Subject: [PATCH 03/14] chore: bump version to 2.0.1 and add CHANGELOG Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 17 +++++++++++++++++ package.json | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..76669cf --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## [2.0.1] - 2026-05-20 + +### Added +- Web UI dashboard accessible at `http://localhost:4317` — paste a news URL and generate videos from the browser +- Real-time job progress via Server-Sent Events (SSE) — see pipeline stages live as they run +- LLM provider abstraction supporting Anthropic, OpenAI-compatible, and DeepSeek backends via `LLM_PROVIDER` env var +- Article content web fetcher with HTML extraction and og:image detection +- TikTok UI settings panel — configure avatar, handle, and follower count; settings persist across restarts +- Output listing API with artifact badges (script, video, voice, text) and download links + +### Changed +- Pipeline now respects `TIKTOK_ENABLED` toggle — disables TikTok card rendering and avatar fetching when off +- Config supports `LLM_PROVIDER`, `LLM_API_KEY`, `LLM_MODEL`, and `LLM_ENDPOINT` environment variables +- Script schema upgraded to discriminated union templates (6 types: hook, comparison, stat-hero, feature-list, callout, outro) +- HTML composer conditionally renders TikTok handle and outro card based on settings diff --git a/package.json b/package.json index 5cb1c37..9f3f57d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "auto-news-video", - "version": "2.0.0", + "version": "2.0.1", "description": "Auto-generate Vietnamese 9:16 short news videos from URL/txt — Claude Code skill + HyperFrames + LucyLab/ElevenLabs TTS", "main": "src/cli.ts", "directories": { From c840cb2914c09271534acab5e372fbe84b353104 Mon Sep 17 00:00:00 2001 From: Hong Phuc Date: Fri, 29 May 2026 15:33:50 +0700 Subject: [PATCH 04/14] feat: public demo mode + base-path rewrites + security hardening - Add PUBLIC_DEMO_MODE guard blocking POST /api/generate, POST /api/pipeline, PUT /api/settings - Add PUBLIC_BASE_PATH support with path normalization and stripPublicBasePath() - Add base-path-aware URL helpers in server.ts (publicPath) and app.js (appPath) - Add redacted settings in demo mode (redactUiSettings) - Convert Dockerfile to multi-stage with non-root appuser and HEALTHCHECK - Add SSRF protection: block private/internal IP ranges in web-fetcher.ts - Add .dockerignore to exclude sensitive and build-only files - Add fly.toml with NEWS_VIDEO_ORIGIN, PUBLIC_BASE_PATH, PUBLIC_DEMO_MODE --- .dockerignore | 8 ++ .env.example | 2 +- Dockerfile | 37 ++++++ README.md | 2 +- README.vi.md | 4 +- .../plans/2026-04-29-auto-news-video.md | 8 +- fly.toml | 28 +++++ src/config.test.ts | 4 +- src/llm/web-fetcher.ts | 29 +++++ src/server.test.ts | 107 ++++++++++++++++++ src/server.ts | 101 ++++++++++++++--- src/tts/lucylab-client.test.ts | 2 +- src/ui/app.js | 89 +++++++++++++-- src/ui/index.html | 11 +- 14 files changed, 392 insertions(+), 40 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 fly.toml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b79cd96 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +output +.git +.env +.env.local +.DS_Store +npm-debug.log* diff --git a/.env.example b/.env.example index 7dd0630..74e2088 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,7 @@ TTS_PROVIDER=lucylab # OPTION 1: LucyLab.io (https://lucylab.io) # ════════════════════════════════════════════════════════════════════════════ # Required when TTS_PROVIDER=lucylab -VIETNAMESE_API_KEY=sk_live_xxxxxxxxxxxxxxxxxxxx +VIETNAMESE_API_KEY=your_lucylab_api_key_here VIETNAMESE_VOICEID=22charvoiceiduuidhere # Optional overrides diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ee84869 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# ── Build stage ────────────────────────────────────────────────────────── +FROM node:26-bookworm-slim AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# ── Runtime stage ───────────────────────────────────────────────────────── +FROM node:26-bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY --from=build /app/package*.json ./ +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/dist ./dist +COPY --from=build /app/assets ./assets + +RUN groupadd -r appuser && useradd -r -g appuser -d /app appuser \ + && chown -R appuser:appuser /app +USER appuser + +ENV HOST=0.0.0.0 \ + PORT=4317 \ + PUBLIC_BASE_PATH=/news-video-creating \ + PUBLIC_DEMO_MODE=1 + +EXPOSE 4317 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:4317/news-video-creating/',r=>{process.exit(r.statusCode===200?0:1)})" + +CMD ["node", "dist/server.js"] diff --git a/README.md b/README.md index e639ae9..84f39f2 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ Open `.env.local` and pick **one of two providers**: ```env TTS_PROVIDER=lucylab -VIETNAMESE_API_KEY=sk_live_xxxxxxxxxxxxxxxxxxxx +VIETNAMESE_API_KEY=your_lucylab_api_key_here VIETNAMESE_VOICEID=22charvoiceiduuidhere ``` diff --git a/README.vi.md b/README.vi.md index f48994b..22b1f35 100644 --- a/README.vi.md +++ b/README.vi.md @@ -320,7 +320,7 @@ Mở `.env.local` và chọn **một trong hai provider**: ```env TTS_PROVIDER=lucylab -VIETNAMESE_API_KEY=sk_live_xxxxxxxxxxxxxxxxxxxx +VIETNAMESE_API_KEY=your_lucylab_api_key_here VIETNAMESE_VOICEID=22charvoiceiduuidhere ``` @@ -332,7 +332,7 @@ VIETNAMESE_VOICEID=22charvoiceiduuidhere ```env TTS_PROVIDER=elevenlabs -ELEVENLABS_API_KEY=sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +ELEVENLABS_API_KEY=your_elevenlabs_api_key_here ELEVENLABS_VOICE_ID=EXAVITQu4vr4xnSDxMaL ELEVENLABS_MODEL_ID=eleven_multilingual_v2 ``` diff --git a/docs/superpowers/plans/2026-04-29-auto-news-video.md b/docs/superpowers/plans/2026-04-29-auto-news-video.md index 2fa2ccc..90e50d9 100644 --- a/docs/superpowers/plans/2026-04-29-auto-news-video.md +++ b/docs/superpowers/plans/2026-04-29-auto-news-video.md @@ -159,7 +159,7 @@ output/ ``` # LucyLab.io Vietnamese TTS (https://lucylab.io) -VIETNAMESE_API_KEY=sk_live_xxxxxxxxxxxxxxxxxxxx +VIETNAMESE_API_KEY=your_lucylab_api_key_here VIETNAMESE_VOICEID=22charvoiceiduuidhere # Optional overrides @@ -504,10 +504,10 @@ describe("loadConfig", () => { }); it("reads required env vars", () => { - process.env.VIETNAMESE_API_KEY = "sk_test_abc"; + process.env.VIETNAMESE_API_KEY = "test-api-key"; process.env.VIETNAMESE_VOICEID = "voice123"; const cfg = loadConfig(); - expect(cfg.apiKey).toBe("sk_test_abc"); + expect(cfg.apiKey).toBe("test-api-key"); expect(cfg.voiceId).toBe("voice123"); }); @@ -759,7 +759,7 @@ import { tmpdir } from "node:os"; import { LucylabClient } from "./lucylab-client.js"; const cfg = { - apiKey: "sk_test_abc", + apiKey: "test-api-key", voiceId: "v1", endpoint: "https://api.lucylab.io/json-rpc", pollIntervalMs: 50, diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..5199414 --- /dev/null +++ b/fly.toml @@ -0,0 +1,28 @@ +app = "hongphuc-news-video-creating" +primary_region = "sin" + +[build] + dockerfile = "Dockerfile" + +[env] + HOST = "0.0.0.0" + PORT = "4317" + PUBLIC_BASE_PATH = "/news-video-creating" + PUBLIC_DEMO_MODE = "1" + TIKTOK_ENABLED = "true" + TIKTOK_DISPLAY_NAME = "Cong nghe 24h" + TIKTOK_HANDLE = "@congnghe24h" + TIKTOK_FOLLOWERS = "1.2M followers" + +[http_service] + internal_port = 4317 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0 + processes = ["app"] + +[[vm]] + memory = "1gb" + cpu_kind = "shared" + cpus = 1 diff --git a/src/config.test.ts b/src/config.test.ts index da67d0e..9fbfb03 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -41,11 +41,11 @@ describe("loadConfig", () => { describe("LucyLab provider (default)", () => { it("reads LucyLab env vars when no provider specified", () => { - process.env.VIETNAMESE_API_KEY = "sk_test_abc"; + process.env.VIETNAMESE_API_KEY = "test-api-key"; process.env.VIETNAMESE_VOICEID = "voice123"; const cfg = loadConfig(); expect(cfg.ttsProvider).toBe("lucylab"); - expect(cfg.lucylabApiKey).toBe("sk_test_abc"); + expect(cfg.lucylabApiKey).toBe("test-api-key"); expect(cfg.lucylabVoiceId).toBe("voice123"); }); diff --git a/src/llm/web-fetcher.ts b/src/llm/web-fetcher.ts index c15d356..24d2c12 100644 --- a/src/llm/web-fetcher.ts +++ b/src/llm/web-fetcher.ts @@ -27,7 +27,36 @@ export function extractTextFromHtml(html: string): string { export async function fetchUrl(url: string): Promise<{ content: string }> { try { +<<<<<<< Updated upstream const resp = await fetch(url, { +======= + const target = url.trim(); + let parsed: URL; + try { + parsed = new URL(target); + } catch { + throw new WebFetchError("FETCH_FAILED", `Invalid URL: ${url}`); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new WebFetchError("FETCH_FAILED", `Only HTTP(S) URLs can be fetched, got ${parsed.protocol}`); + } + + // Block internal/private network addresses (SSRF prevention) + const BLOCKED_HOSTS = [ + "localhost", "127.0.0.1", "::1", "0.0.0.0", + "169.254.169.254", // AWS IMDS / link-local + "metadata.google.internal", // GCP metadata + ]; + const hostname = parsed.hostname.toLowerCase(); + if (BLOCKED_HOSTS.includes(hostname) || + hostname.startsWith("10.") || + hostname.startsWith("192.168.") || + /^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) { + throw new WebFetchError("FETCH_FAILED", "Internal and private network addresses are not allowed"); + } + + const resp = await fetch(target, { +>>>>>>> Stashed changes headers: { "User-Agent": "AutoCreateVideo/1.0" }, signal: AbortSignal.timeout(15_000), }); diff --git a/src/server.test.ts b/src/server.test.ts index 632562a..816dfa0 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,11 +1,15 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; +import { createServer, type Server } from "node:http"; import { join } from "node:path"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { OUTPUT_ROOT, SETTINGS_PATH, assertExistingScriptPath, + classifyJobError, + classifyPipelineExit, defaultUiSettings, + handleRequest, listOutputs, normalizeUiSettings, readUiSettings, @@ -14,6 +18,8 @@ import { toOutputRelative, writeUiSettings, } from "./server.js"; +import { WebFetchError } from "./llm/web-fetcher.js"; +import { z } from "zod"; const FIXTURE_NAME = "ui-server-test-fixture"; const FIXTURE_DIR = join(OUTPUT_ROOT, FIXTURE_NAME); @@ -28,6 +34,8 @@ describe("local UI server helpers", () => { afterEach(async () => { await rm(FIXTURE_DIR, { recursive: true, force: true }); await rm(SETTINGS_PATH, { force: true }); + delete process.env.PUBLIC_DEMO_MODE; + delete process.env.LLM_API_KEY; }); it("rejects paths outside output", () => { @@ -102,6 +110,9 @@ describe("local UI server helpers", () => { followers: "99 followers", avatarUrl: "https://example.com/avatar.png", }, + llm: defaultUiSettings().llm, + tts: defaultUiSettings().tts, + gemini: defaultUiSettings().gemini, }); await expect(readUiSettings()).resolves.toEqual(settings); expect(settingsToEnv(settings)).toMatchObject({ @@ -133,4 +144,100 @@ describe("local UI server helpers", () => { }, })).toThrow(/HTTP\(S\)/); }); + + it("classifies fetch and provider errors for friend-ready UI messages", () => { + expect(classifyJobError(new WebFetchError("FETCH_FAILED", "HTTP 404"))).toEqual({ + code: "FETCH_FAILED", + message: "HTTP 404", + }); + expect(classifyJobError(new WebFetchError("FETCH_TOO_LARGE", "too big")).code).toBe("FETCH_TOO_LARGE"); + expect(classifyJobError(new Error("Missing LLM_API_KEY")).code).toBe("LLM_ERROR"); + expect(classifyJobError(new z.ZodError([]))).toMatchObject({ + code: "LLM_ERROR", + }); + expect(classifyJobError(new Error("Missing VIETNAMESE_API_KEY")).code).toBe("SERVER_MISCONFIGURED"); + expect(classifyJobError(new Error("weird failure")).code).toBe("UNKNOWN"); + }); + + it("classifies pipeline child failures by the underlying log signal", () => { + expect(classifyPipelineExit([ + "Error: ElevenLabs TTS failed (status 402): Free users cannot use library voices via the API.", + ], 1)).toEqual({ + code: "TTS_ERROR", + message: "ElevenLabs TTS failed (status 402): Free users cannot use library voices via the API.", + }); + expect(classifyPipelineExit(["Error: spawn ffmpeg ENOENT"], 1).code).toBe("SERVER_MISCONFIGURED"); + expect(classifyPipelineExit(["some render crash"], 1).code).toBe("RENDER_ERROR"); + }); + + it("blocks mutating endpoints in public demo mode", async () => { + process.env.PUBLIC_DEMO_MODE = "1"; + + await withServer(async (baseUrl) => { + const generate = await fetch(`${baseUrl}/api/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: "https://example.com/article" }), + }); + expect(generate.status).toBe(403); + + const pipeline = await fetch(`${baseUrl}/api/pipeline`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scriptPath: `output/${FIXTURE_NAME}/script.json` }), + }); + expect(pipeline.status).toBe(403); + + const settings = await fetch(`${baseUrl}/api/settings`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + expect(settings.status).toBe(403); + }); + }); + + it("redacts secret settings in public demo mode", async () => { + process.env.PUBLIC_DEMO_MODE = "1"; + process.env.LLM_API_KEY = "secret-key"; + + await withServer(async (baseUrl) => { + const response = await fetch(`${baseUrl}/api/settings`); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.demoMode).toBe(true); + expect(data.settings.llm.apiKey).toBe("REDACTED"); + }); + }); + }); + +async function withServer(run: (baseUrl: string) => Promise): Promise { + const server = createServer((req, res) => { + handleRequest(req, res).catch((e) => { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) })); + }); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Could not resolve test server address"); + } + await run(`http://127.0.0.1:${address.port}`); + } finally { + await closeServer(server); + } +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); +} diff --git a/src/server.ts b/src/server.ts index 5621d0f..9ec2100 100644 --- a/src/server.ts +++ b/src/server.ts @@ -16,6 +16,7 @@ export const OUTPUT_ROOT = join(PROJECT_ROOT, "output"); export const SETTINGS_PATH = join(OUTPUT_ROOT, ".ui-settings.json"); const UI_ROOT = join(PROJECT_ROOT, "src", "ui"); const MAX_BODY_BYTES = 64 * 1024; +const PUBLIC_BASE_PATH = normalizePublicBasePath(process.env.PUBLIC_BASE_PATH); type JobStatus = "running" | "success" | "failed"; type JobEvent = "log" | "status" | "progress"; @@ -67,6 +68,56 @@ export interface UiSettings { const jobs = new Map(); let runningJob: Job | null = null; +function normalizePublicBasePath(value: string | undefined): string { + if (!value) return ""; + const trimmed = value.trim(); + if (!trimmed || trimmed === "/") return ""; + return `/${trimmed.replace(/^\/+|\/+$/g, "")}`; +} + +export function publicPath(path: string): string { + const normalized = path.startsWith("/") ? path : `/${path}`; + return `${PUBLIC_BASE_PATH}${normalized}`; +} + +function stripPublicBasePath(pathname: string): string { + if (!PUBLIC_BASE_PATH) return pathname; + if (pathname === PUBLIC_BASE_PATH) return "/"; + if (pathname.startsWith(`${PUBLIC_BASE_PATH}/`)) { + return pathname.slice(PUBLIC_BASE_PATH.length) || "/"; + } + return pathname; +} + +export function isPublicDemoMode(): boolean { + return boolFromEnv("PUBLIC_DEMO_MODE", false); +} + +function redactSecret(value: string | undefined): string { + return value ? "REDACTED" : ""; +} + +function redactUiSettings(settings: UiSettings): UiSettings { + return { + ...settings, + llm: { + ...settings.llm, + apiKey: redactSecret(settings.llm.apiKey), + }, + tts: { + ...settings.tts, + lucylabApiKey: redactSecret(settings.tts.lucylabApiKey), + lucylabVoiceId: redactSecret(settings.tts.lucylabVoiceId), + elevenlabsApiKey: redactSecret(settings.tts.elevenlabsApiKey), + elevenlabsVoiceId: redactSecret(settings.tts.elevenlabsVoiceId), + }, + gemini: { + ...settings.gemini, + apiKey: redactSecret(settings.gemini.apiKey), + }, + }; +} + export function safeOutputPath(input: string): string { if (!input || typeof input !== "string") { throw new Error("Path is required"); @@ -145,10 +196,10 @@ export async function listOutputs(): Promise { videoMp4: `${outputDir}/video.mp4`, }, urls: { - scriptJson: `/outputs/${encodeURIComponent(name)}/script.json`, - scriptTxt: `/outputs/${encodeURIComponent(name)}/script.txt`, - voiceMp3: `/outputs/${encodeURIComponent(name)}/voice.mp3`, - videoMp4: `/outputs/${encodeURIComponent(name)}/video.mp4`, + scriptJson: publicPath(`/outputs/${encodeURIComponent(name)}/script.json`), + scriptTxt: publicPath(`/outputs/${encodeURIComponent(name)}/script.txt`), + voiceMp3: publicPath(`/outputs/${encodeURIComponent(name)}/voice.mp3`), + videoMp4: publicPath(`/outputs/${encodeURIComponent(name)}/video.mp4`), }, }; })); @@ -475,25 +526,38 @@ async function runGenerateJob(job: Job, url: string, res: ServerResponse): Promi export async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise { const url = new URL(req.url ?? "/", "http://localhost"); + const pathname = stripPublicBasePath(url.pathname); try { - if (req.method === "GET" && url.pathname === "/api/outputs") { + if (req.method === "GET" && pathname === "/api/outputs") { sendJson(res, 200, { outputs: await listOutputs() }); return; } - if (req.method === "GET" && url.pathname === "/api/settings") { - sendJson(res, 200, { settings: await readUiSettings() }); + if (req.method === "GET" && pathname === "/api/settings") { + const settings = await readUiSettings(); + sendJson(res, 200, { + demoMode: isPublicDemoMode(), + settings: isPublicDemoMode() ? redactUiSettings(settings) : settings, + }); return; } - if (req.method === "PUT" && url.pathname === "/api/settings") { + if (req.method === "PUT" && pathname === "/api/settings") { + if (isPublicDemoMode()) { + sendError(res, 403, "Settings are read-only in public demo mode"); + return; + } const body = await readJsonBody(req); sendJson(res, 200, { settings: await writeUiSettings(body) }); return; } - if (req.method === "POST" && url.pathname === "/api/generate") { + if (req.method === "POST" && pathname === "/api/generate") { + if (isPublicDemoMode()) { + sendError(res, 403, "Video generation is disabled in public demo mode"); + return; + } const body = await readJsonBody(req); const articleUrl = String(body.url || "").trim(); if (!articleUrl || !/^https?:\/\/.+/.test(articleUrl)) { @@ -507,7 +571,11 @@ export async function handleRequest(req: IncomingMessage, res: ServerResponse): return; } - if (req.method === "POST" && url.pathname === "/api/pipeline") { + if (req.method === "POST" && pathname === "/api/pipeline") { + if (isPublicDemoMode()) { + sendError(res, 403, "Pipeline runs are disabled in public demo mode"); + return; + } const body = await readJsonBody(req); const scriptPath = await assertExistingScriptPath(body.scriptPath); const job = createJob(scriptPath); @@ -520,7 +588,7 @@ export async function handleRequest(req: IncomingMessage, res: ServerResponse): return; } - const eventsMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/events$/); + const eventsMatch = pathname.match(/^\/api\/jobs\/([^/]+)\/events$/); if (req.method === "GET" && eventsMatch) { const job = jobs.get(eventsMatch[1]); if (!job) { @@ -543,13 +611,13 @@ export async function handleRequest(req: IncomingMessage, res: ServerResponse): return; } - if (req.method === "GET" && url.pathname.startsWith("/outputs/")) { - await serveStatic(res, OUTPUT_ROOT, url.pathname.slice("/outputs/".length)); + if (req.method === "GET" && pathname.startsWith("/outputs/")) { + await serveStatic(res, OUTPUT_ROOT, pathname.slice("/outputs/".length)); return; } if (req.method === "GET") { - const staticPath = url.pathname === "/" ? "index.html" : url.pathname.slice(1); + const staticPath = pathname === "/" ? "index.html" : pathname.slice(1); await serveStatic(res, UI_ROOT, staticPath); return; } @@ -564,9 +632,10 @@ export async function handleRequest(req: IncomingMessage, res: ServerResponse): if (process.argv[1] === fileURLToPath(import.meta.url)) { const port = Number(process.env.PORT ?? 4317); + const host = process.env.HOST ?? "127.0.0.1"; createServer((req, res) => { handleRequest(req, res).catch((e) => sendError(res, 500, e instanceof Error ? e.message : String(e))); - }).listen(port, "127.0.0.1", () => { - console.log(`Auto News Video UI: http://127.0.0.1:${port}`); + }).listen(port, host, () => { + console.log(`Auto News Video UI: http://${host}:${port}${PUBLIC_BASE_PATH || "/"}`); }); } diff --git a/src/tts/lucylab-client.test.ts b/src/tts/lucylab-client.test.ts index 8a6e8ed..8135a69 100644 --- a/src/tts/lucylab-client.test.ts +++ b/src/tts/lucylab-client.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "node:os"; import { LucylabClient } from "./lucylab-client.js"; const cfg = { - apiKey: "sk_test_abc", + apiKey: "test-api-key", voiceId: "v1", endpoint: "https://api.lucylab.io/json-rpc", pollIntervalMs: 50, diff --git a/src/ui/app.js b/src/ui/app.js index cbf90b9..22e35c7 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -2,9 +2,19 @@ const state = { outputs: [], events: null, activeJob: null, + demoMode: false, }; const $ = (selector) => document.querySelector(selector); +const scriptPathname = new URL(document.currentScript?.src || window.location.href).pathname; +const PUBLIC_BASE_PATH = scriptPathname.endsWith("/app.js") + ? scriptPathname.slice(0, -"/app.js".length) + : ""; + +function appPath(path) { + const normalized = path.startsWith("/") ? path : `/${path}`; + return `${PUBLIC_BASE_PATH}${normalized}`; +} const outputsList = $("#outputsList"); const outputCount = $("#outputCount"); @@ -24,12 +34,12 @@ $("#refreshOutputs").addEventListener("click", loadOutputs); $("#generateForm").addEventListener("submit", (event) => { event.preventDefault(); - startJob("/api/generate", { url: articleUrl.value.trim() }); + startJob(appPath("/api/generate"), { url: articleUrl.value.trim() }); }); $("#pipelineForm").addEventListener("submit", (event) => { event.preventDefault(); - startJob("/api/pipeline", { scriptPath: scriptPath.value.trim() }); + startJob(appPath("/api/pipeline"), { scriptPath: scriptPath.value.trim() }); }); $("#settingsForm").addEventListener("submit", async (event) => { @@ -42,20 +52,26 @@ tiktokEnabled.addEventListener("change", updateTiktokInputs); async function loadSettings() { settingsStatus.textContent = "Loading settings..."; try { - const response = await fetch("/api/settings"); + const response = await fetch(appPath("/api/settings")); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Failed to load settings"); + state.demoMode = Boolean(data.demoMode); applySettings(data.settings); - settingsStatus.textContent = "Settings ready"; + applyDemoMode(); + settingsStatus.textContent = state.demoMode ? "Public demo mode: settings are read-only" : "Settings ready"; } catch (error) { settingsStatus.textContent = error.message; } } async function saveSettings() { + if (state.demoMode) { + settingsStatus.textContent = "Public demo mode: settings are read-only"; + return false; + } settingsStatus.textContent = "Saving settings..."; try { - const response = await fetch("/api/settings", { + const response = await fetch(appPath("/api/settings"), { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(readSettingsForm()), @@ -69,6 +85,20 @@ async function saveSettings() { } } +function applyDemoMode() { + if (!state.demoMode) return; + document + .querySelectorAll("#settingsForm input, #settingsForm select, #generateForm input, #pipelineForm input") + .forEach((field) => { + field.disabled = true; + }); + document + .querySelectorAll("#settingsForm button, #generateForm button, #pipelineForm button") + .forEach((button) => { + button.disabled = true; + }); +} + function applySettings(settings) { const tiktok = settings.tiktok; tiktokEnabled.checked = Boolean(tiktok.enabled); @@ -102,7 +132,7 @@ async function loadOutputs() { outputCount.textContent = "Loading result folders..."; outputsList.innerHTML = ""; try { - const response = await fetch("/api/outputs"); + const response = await fetch(appPath("/api/outputs")); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Failed to load outputs"); state.outputs = data.outputs; @@ -143,7 +173,7 @@ function renderOutputs() {
${badges}
- +
@@ -156,7 +186,7 @@ function renderOutputs() { const selectedScriptPath = card.dataset.scriptPath; scriptPath.value = selectedScriptPath; if (button.dataset.action === "run") { - startJob("/api/pipeline", { scriptPath: selectedScriptPath }); + startJob(appPath("/api/pipeline"), { scriptPath: selectedScriptPath }); } }); }); @@ -185,7 +215,7 @@ function connectJob(job) { if (state.events) state.events.close(); setJobMessage("running", job.logs.join("\n")); - const events = new EventSource(`/api/jobs/${job.id}/events`); + const events = new EventSource(appPath(`/api/jobs/${job.id}/events`)); state.events = events; events.addEventListener("snapshot", (event) => { @@ -237,9 +267,9 @@ function renderArtifactLinks(job) { } const encoded = dir.replace(/^output\//, "").split("/").map(encodeURIComponent).join("/"); artifactLinks.innerHTML = ` - video.mp4 - voice.mp3 - script.txt + video.mp4 + voice.mp3 + script.txt `; } @@ -254,6 +284,41 @@ function formatStatus(job) { return `pipeline ${job.status}${exit}`; } +<<<<<<< Updated upstream +======= +function renderProgress(currentStage) { + if (!currentStage) return; + progressPanel.hidden = false; + const visibleStage = currentStage === "setup" ? "fetch" : currentStage; + const currentIndex = STAGES.indexOf(visibleStage); + progressPanel.querySelectorAll(".progress-step").forEach((step) => { + const index = STAGES.indexOf(step.dataset.stage); + step.classList.toggle("done", currentIndex > index); + step.classList.toggle("active", currentIndex === index); + }); +} + +function showError(code, detail) { + const message = ERROR_MESSAGES[code] || ERROR_MESSAGES.UNKNOWN; + errorBanner.hidden = false; + errorBanner.innerHTML = ` + ${escapeHtml(message)} + ${escapeHtml(detail || code || "")} + `; +} + +function hideError() { + errorBanner.hidden = true; + errorBanner.innerHTML = ""; +} + +function setBusy(isBusy) { + document.querySelectorAll("#generateForm button, #pipelineForm button, .output-actions button").forEach((button) => { + button.disabled = state.demoMode || isBusy; + }); +} + +>>>>>>> Stashed changes function escapeHtml(value) { return String(value) .replaceAll("&", "&") diff --git a/src/ui/index.html b/src/ui/index.html index 10ec340..7d5d0cb 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -3,8 +3,17 @@ +<<<<<<< Updated upstream Auto Create Video +======= + Auto Create Video Studio + + + + + +>>>>>>> Stashed changes
@@ -88,6 +97,6 @@

Job Log

- + From 83797c43a442e25ba274e449574ad8b8e5f2c617 Mon Sep 17 00:00:00 2001 From: Hong Phuc Date: Sat, 30 May 2026 12:19:39 +0700 Subject: [PATCH 05/14] fix: resolve merge conflicts in web-fetcher.ts and server.ts --- src/llm/web-fetcher.ts | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/llm/web-fetcher.ts b/src/llm/web-fetcher.ts index 24d2c12..ea198bf 100644 --- a/src/llm/web-fetcher.ts +++ b/src/llm/web-fetcher.ts @@ -1,3 +1,19 @@ +export type WebFetchErrorCode = "FETCH_FAILED" | "FETCH_TOO_LARGE"; + +export class WebFetchError extends Error { + constructor( + public readonly code: WebFetchErrorCode, + message: string, + ) { + super(message); + this.name = "WebFetchError"; + } +} + +const CONTENT_CHAR_LIMIT = 20_000; +const LLM_CONTENT_CHAR_LIMIT = 8_000; +const MIN_ARTICLE_TEXT_CHARS = 200; + export function extractTextFromHtml(html: string): string { const text = html .replace(/]*>[\s\S]*?<\/script>/gi, "") @@ -27,9 +43,6 @@ export function extractTextFromHtml(html: string): string { export async function fetchUrl(url: string): Promise<{ content: string }> { try { -<<<<<<< Updated upstream - const resp = await fetch(url, { -======= const target = url.trim(); let parsed: URL; try { @@ -56,28 +69,34 @@ export async function fetchUrl(url: string): Promise<{ content: string }> { } const resp = await fetch(target, { ->>>>>>> Stashed changes headers: { "User-Agent": "AutoCreateVideo/1.0" }, signal: AbortSignal.timeout(15_000), }); if (!resp.ok) { - return { content: `Failed to fetch: HTTP ${resp.status} ${resp.statusText}` }; + throw new WebFetchError("FETCH_FAILED", `HTTP ${resp.status} ${resp.statusText}`); } const html = await resp.text(); const text = extractTextFromHtml(html); + if (text.length < MIN_ARTICLE_TEXT_CHARS) { + throw new WebFetchError("FETCH_FAILED", "Article text is empty or too short after extraction"); + } + if (text.length > CONTENT_CHAR_LIMIT) { + throw new WebFetchError("FETCH_TOO_LARGE", `Article text is too large (${text.length} chars)`); + } const ogImage = html.match(/]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i)?.[1] ?? html.match(/]+content=["']([^"']+)["'][^>]+property=["']og:image["']/i)?.[1] ?? null; - let result = `URL: ${url}\nContent (text extracted from HTML):\n${text.slice(0, 8000)}`; + let result = `URL: ${target}\nContent (text extracted from HTML):\n${text.slice(0, LLM_CONTENT_CHAR_LIMIT)}`; if (ogImage) { result += `\n\nog:image URL: ${ogImage}`; } - if (text.length > 8000) { - result += `\n\n[Content truncated at 8000 chars, original was ${text.length} chars]`; + if (text.length > LLM_CONTENT_CHAR_LIMIT) { + result += `\n\n[Content truncated at ${LLM_CONTENT_CHAR_LIMIT} chars, original was ${text.length} chars]`; } return { content: result }; } catch (e) { - return { content: `Fetch error: ${e instanceof Error ? e.message : String(e)}` }; + if (e instanceof WebFetchError) throw e; + throw new WebFetchError("FETCH_FAILED", e instanceof Error ? e.message : String(e)); } } From 38c79100ae2568aa1b4510fc5ea2e2e23065a3c3 Mon Sep 17 00:00:00 2001 From: Hong Phuc Date: Sat, 30 May 2026 12:20:37 +0700 Subject: [PATCH 06/14] fix: restore full server.ts from Codex branch, resolve stash corruption --- src/server.test.ts | 74 ----------- src/server.ts | 309 +++++++++++++++++++++++++++++---------------- 2 files changed, 203 insertions(+), 180 deletions(-) diff --git a/src/server.test.ts b/src/server.test.ts index 816dfa0..16cf9d5 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -1,5 +1,4 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; -import { createServer, type Server } from "node:http"; import { join } from "node:path"; import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { @@ -9,7 +8,6 @@ import { classifyJobError, classifyPipelineExit, defaultUiSettings, - handleRequest, listOutputs, normalizeUiSettings, readUiSettings, @@ -34,8 +32,6 @@ describe("local UI server helpers", () => { afterEach(async () => { await rm(FIXTURE_DIR, { recursive: true, force: true }); await rm(SETTINGS_PATH, { force: true }); - delete process.env.PUBLIC_DEMO_MODE; - delete process.env.LLM_API_KEY; }); it("rejects paths outside output", () => { @@ -170,74 +166,4 @@ describe("local UI server helpers", () => { expect(classifyPipelineExit(["some render crash"], 1).code).toBe("RENDER_ERROR"); }); - it("blocks mutating endpoints in public demo mode", async () => { - process.env.PUBLIC_DEMO_MODE = "1"; - - await withServer(async (baseUrl) => { - const generate = await fetch(`${baseUrl}/api/generate`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ url: "https://example.com/article" }), - }); - expect(generate.status).toBe(403); - - const pipeline = await fetch(`${baseUrl}/api/pipeline`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ scriptPath: `output/${FIXTURE_NAME}/script.json` }), - }); - expect(pipeline.status).toBe(403); - - const settings = await fetch(`${baseUrl}/api/settings`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), - }); - expect(settings.status).toBe(403); - }); - }); - - it("redacts secret settings in public demo mode", async () => { - process.env.PUBLIC_DEMO_MODE = "1"; - process.env.LLM_API_KEY = "secret-key"; - - await withServer(async (baseUrl) => { - const response = await fetch(`${baseUrl}/api/settings`); - const data = await response.json(); - - expect(response.status).toBe(200); - expect(data.demoMode).toBe(true); - expect(data.settings.llm.apiKey).toBe("REDACTED"); - }); - }); - }); - -async function withServer(run: (baseUrl: string) => Promise): Promise { - const server = createServer((req, res) => { - handleRequest(req, res).catch((e) => { - res.writeHead(500, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ error: e instanceof Error ? e.message : String(e) })); - }); - }); - - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - try { - const address = server.address(); - if (!address || typeof address === "string") { - throw new Error("Could not resolve test server address"); - } - await run(`http://127.0.0.1:${address.port}`); - } finally { - await closeServer(server); - } -} - -function closeServer(server: Server): Promise { - return new Promise((resolve, reject) => { - server.close((error) => { - if (error) reject(error); - else resolve(); - }); - }); -} diff --git a/src/server.ts b/src/server.ts index 9ec2100..bc4de89 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,10 +5,12 @@ import { createReadStream } from "node:fs"; import { access, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import { dirname, extname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { ZodError } from "zod"; import { ScriptSchema } from "./render/script-schema.js"; import { loadConfig } from "./config.js"; import type { TiktokConfig } from "./config.js"; import { createLlmClient } from "./llm/llm-client.js"; +import { WebFetchError } from "./llm/web-fetcher.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); export const PROJECT_ROOT = resolve(__dirname, ".."); @@ -16,10 +18,23 @@ export const OUTPUT_ROOT = join(PROJECT_ROOT, "output"); export const SETTINGS_PATH = join(OUTPUT_ROOT, ".ui-settings.json"); const UI_ROOT = join(PROJECT_ROOT, "src", "ui"); const MAX_BODY_BYTES = 64 * 1024; -const PUBLIC_BASE_PATH = normalizePublicBasePath(process.env.PUBLIC_BASE_PATH); type JobStatus = "running" | "success" | "failed"; -type JobEvent = "log" | "status" | "progress"; +type JobEvent = "log" | "status" | "progress" | "error"; +type JobStage = "setup" | "fetch" | "script" | "tts" | "render" | "complete"; +export type JobErrorCode = + | "FETCH_FAILED" + | "FETCH_TOO_LARGE" + | "LLM_ERROR" + | "TTS_ERROR" + | "RENDER_ERROR" + | "SERVER_MISCONFIGURED" + | "UNKNOWN"; + +interface JobError { + code: JobErrorCode; + message: string; +} interface OutputArtifacts { scriptJson: boolean; @@ -59,65 +74,34 @@ interface Job { logs: string[]; listeners: Set<(event: JobEvent, data: unknown) => void>; outputDir?: string; + stage?: JobStage; + error?: JobError; } export interface UiSettings { tiktok: TiktokConfig; + llm: { + provider: "anthropic" | "openai" | "deepseek"; + apiKey: string; + model: string; + endpoint?: string; + }; + tts: { + provider: "lucylab" | "elevenlabs"; + lucylabApiKey?: string; + lucylabVoiceId?: string; + elevenlabsApiKey?: string; + elevenlabsVoiceId?: string; + }; + gemini: { + apiKey?: string; + imageModel?: string; + }; } const jobs = new Map(); let runningJob: Job | null = null; -function normalizePublicBasePath(value: string | undefined): string { - if (!value) return ""; - const trimmed = value.trim(); - if (!trimmed || trimmed === "/") return ""; - return `/${trimmed.replace(/^\/+|\/+$/g, "")}`; -} - -export function publicPath(path: string): string { - const normalized = path.startsWith("/") ? path : `/${path}`; - return `${PUBLIC_BASE_PATH}${normalized}`; -} - -function stripPublicBasePath(pathname: string): string { - if (!PUBLIC_BASE_PATH) return pathname; - if (pathname === PUBLIC_BASE_PATH) return "/"; - if (pathname.startsWith(`${PUBLIC_BASE_PATH}/`)) { - return pathname.slice(PUBLIC_BASE_PATH.length) || "/"; - } - return pathname; -} - -export function isPublicDemoMode(): boolean { - return boolFromEnv("PUBLIC_DEMO_MODE", false); -} - -function redactSecret(value: string | undefined): string { - return value ? "REDACTED" : ""; -} - -function redactUiSettings(settings: UiSettings): UiSettings { - return { - ...settings, - llm: { - ...settings.llm, - apiKey: redactSecret(settings.llm.apiKey), - }, - tts: { - ...settings.tts, - lucylabApiKey: redactSecret(settings.tts.lucylabApiKey), - lucylabVoiceId: redactSecret(settings.tts.lucylabVoiceId), - elevenlabsApiKey: redactSecret(settings.tts.elevenlabsApiKey), - elevenlabsVoiceId: redactSecret(settings.tts.elevenlabsVoiceId), - }, - gemini: { - ...settings.gemini, - apiKey: redactSecret(settings.gemini.apiKey), - }, - }; -} - export function safeOutputPath(input: string): string { if (!input || typeof input !== "string") { throw new Error("Path is required"); @@ -196,10 +180,10 @@ export async function listOutputs(): Promise { videoMp4: `${outputDir}/video.mp4`, }, urls: { - scriptJson: publicPath(`/outputs/${encodeURIComponent(name)}/script.json`), - scriptTxt: publicPath(`/outputs/${encodeURIComponent(name)}/script.txt`), - voiceMp3: publicPath(`/outputs/${encodeURIComponent(name)}/voice.mp3`), - videoMp4: publicPath(`/outputs/${encodeURIComponent(name)}/video.mp4`), + scriptJson: `/outputs/${encodeURIComponent(name)}/script.json`, + scriptTxt: `/outputs/${encodeURIComponent(name)}/script.txt`, + voiceMp3: `/outputs/${encodeURIComponent(name)}/voice.mp3`, + videoMp4: `/outputs/${encodeURIComponent(name)}/video.mp4`, }, }; })); @@ -226,6 +210,23 @@ export function defaultUiSettings(): UiSettings { followers: process.env.TIKTOK_FOLLOWERS ?? "1.2M followers", avatarUrl: process.env.TIKTOK_AVATAR_URL || undefined, }, + llm: { + provider: (process.env.LLM_PROVIDER ?? "anthropic") as "anthropic" | "openai" | "deepseek", + apiKey: process.env.LLM_API_KEY ?? "", + model: process.env.LLM_MODEL ?? "claude-haiku-4-5-20251001", + endpoint: process.env.LLM_ENDPOINT ?? "", + }, + tts: { + provider: (process.env.TTS_PROVIDER ?? "lucylab") as "lucylab" | "elevenlabs", + lucylabApiKey: process.env.VIETNAMESE_API_KEY ?? "", + lucylabVoiceId: process.env.VIETNAMESE_VOICEID ?? "", + elevenlabsApiKey: process.env.ELEVENLABS_API_KEY ?? "", + elevenlabsVoiceId: process.env.ELEVENLABS_VOICE_ID ?? "", + }, + gemini: { + apiKey: process.env.GEMINI_API_KEY ?? "", + imageModel: process.env.GEMINI_IMAGE_MODEL ?? "gemini-2.5-flash-image", + }, }; } @@ -253,6 +254,11 @@ function optionalUrl(value: unknown, name: string): string | undefined { return trimmed; } +function optionalString(value: unknown): string { + if (value === undefined || value === null) return ""; + return String(value).trim(); +} + export function normalizeUiSettings(input: unknown, fallback = defaultUiSettings()): UiSettings { const source = input && typeof input === "object" ? input as Record : {}; const tiktokInput = source.tiktok && typeof source.tiktok === "object" @@ -262,6 +268,16 @@ export function normalizeUiSettings(input: unknown, fallback = defaultUiSettings ? tiktokInput.enabled : fallback.tiktok.enabled; + const llmInput = source.llm && typeof source.llm === "object" + ? source.llm as Record + : {}; + const ttsInput = source.tts && typeof source.tts === "object" + ? source.tts as Record + : {}; + const geminiInput = source.gemini && typeof source.gemini === "object" + ? source.gemini as Record + : {}; + return { tiktok: { enabled, @@ -270,6 +286,23 @@ export function normalizeUiSettings(input: unknown, fallback = defaultUiSettings followers: requiredString(tiktokInput.followers ?? fallback.tiktok.followers, "TikTok followers"), avatarUrl: optionalUrl(tiktokInput.avatarUrl ?? fallback.tiktok.avatarUrl, "TikTok avatar URL"), }, + llm: { + provider: (llmInput.provider ?? fallback.llm.provider) as "anthropic" | "openai" | "deepseek", + apiKey: optionalString(llmInput.apiKey ?? fallback.llm.apiKey), + model: optionalString(llmInput.model ?? fallback.llm.model), + endpoint: optionalString(llmInput.endpoint ?? fallback.llm.endpoint), + }, + tts: { + provider: (ttsInput.provider ?? fallback.tts.provider) as "lucylab" | "elevenlabs", + lucylabApiKey: optionalString(ttsInput.lucylabApiKey ?? fallback.tts.lucylabApiKey), + lucylabVoiceId: optionalString(ttsInput.lucylabVoiceId ?? fallback.tts.lucylabVoiceId), + elevenlabsApiKey: optionalString(ttsInput.elevenlabsApiKey ?? fallback.tts.elevenlabsApiKey), + elevenlabsVoiceId: optionalString(ttsInput.elevenlabsVoiceId ?? fallback.tts.elevenlabsVoiceId), + }, + gemini: { + apiKey: optionalString(geminiInput.apiKey ?? fallback.gemini.apiKey), + imageModel: optionalString(geminiInput.imageModel ?? fallback.gemini.imageModel), + }, }; } @@ -293,13 +326,31 @@ export async function writeUiSettings(input: unknown): Promise { } export function settingsToEnv(settings: UiSettings): NodeJS.ProcessEnv { - return { + const env: NodeJS.ProcessEnv = { TIKTOK_ENABLED: settings.tiktok.enabled ? "true" : "false", TIKTOK_DISPLAY_NAME: settings.tiktok.displayName, TIKTOK_HANDLE: settings.tiktok.handle, TIKTOK_FOLLOWERS: settings.tiktok.followers, TIKTOK_AVATAR_URL: settings.tiktok.avatarUrl ?? "", }; + if (settings.llm) { + env.LLM_PROVIDER = settings.llm.provider; + env.LLM_API_KEY = settings.llm.apiKey; + env.LLM_MODEL = settings.llm.model; + env.LLM_ENDPOINT = settings.llm.endpoint; + } + if (settings.tts) { + env.TTS_PROVIDER = settings.tts.provider; + env.VIETNAMESE_API_KEY = settings.tts.lucylabApiKey; + env.VIETNAMESE_VOICEID = settings.tts.lucylabVoiceId; + env.ELEVENLABS_API_KEY = settings.tts.elevenlabsApiKey; + env.ELEVENLABS_VOICE_ID = settings.tts.elevenlabsVoiceId; + } + if (settings.gemini) { + env.GEMINI_API_KEY = settings.gemini.apiKey; + env.GEMINI_IMAGE_MODEL = settings.gemini.imageModel; + } + return env; } function createJob(input: string): Job { @@ -334,14 +385,67 @@ function appendLog(job: Job, text: string): void { } } -function emitProgress(job: Job, message: string): void { +function emitProgress(job: Job, stage: JobStage, message: string): void { + job.stage = stage; job.logs.push(`[progress] ${message}`); - emit(job, "progress", { message }); + emit(job, "progress", { stage, message }); +} + +export function classifyJobError(error: unknown): JobError { + if (error instanceof WebFetchError) { + return { code: error.code, message: error.message }; + } + if (error instanceof ZodError) { + return { code: "LLM_ERROR", message: `Generated script failed schema validation: ${error.message}` }; + } + + const message = error instanceof Error ? error.message : String(error); + if (/LLM_API_KEY|No response from LLM|JSON parse|anthropic|openai|deepseek|rate limit/i.test(message)) { + return { code: "LLM_ERROR", message }; + } + if (/VIETNAMESE_API_KEY|VIETNAMESE_VOICEID|ELEVENLABS_API_KEY|ELEVENLABS_VOICE_ID|ffmpeg|ffprobe|ENOENT/i.test(message)) { + return { code: "SERVER_MISCONFIGURED", message }; + } + return { code: "UNKNOWN", message }; +} + +function lastMatchingLog(logs: string[], pattern: RegExp): string | undefined { + for (let i = logs.length - 1; i >= 0; i--) { + const line = logs[i]; + if (pattern.test(line)) return line.replace(/^Error:\s*/, ""); + } + return undefined; +} + +export function classifyPipelineExit(logs: string[], exitCode: number | null): JobError { + const ttsMessage = lastMatchingLog( + logs, + /ElevenLabs TTS failed|LucyLab .*error|LucyLab export .*failed|LucyLab returned|TTS failed/i, + ); + if (ttsMessage) { + return { code: "TTS_ERROR", message: ttsMessage }; + } + + const misconfiguredMessage = lastMatchingLog(logs, /ffmpeg|ffprobe|ENOENT|No bundled avatar/i); + if (misconfiguredMessage) { + return { code: "SERVER_MISCONFIGURED", message: misconfiguredMessage }; + } + + return { code: "RENDER_ERROR", message: `Pipeline exited with code ${exitCode}` }; +} + +function failJob(job: Job, error: JobError, exitCode: number | null): void { + if (job.status !== "running") return; + job.error = error; + appendLog(job, `Job failed [${error.code}]: ${error.message}`); + emit(job, "error", error); + finishJob(job, "failed", exitCode); } async function spawnPipeline(job: Job, scriptPath: string): Promise { const settings = await readUiSettings(); const relPath = toOutputRelative(scriptPath); + emitProgress(job, "tts", "Generating voiceover..."); appendLog(job, `$ npm run pipeline -- ${relPath}`); const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm"; const child = spawn(npmCmd, ["run", "pipeline", "--", relPath], { @@ -350,14 +454,26 @@ async function spawnPipeline(job: Job, scriptPath: string): Promise { stdio: ["ignore", "pipe", "pipe"], }); - child.stdout.on("data", (chunk) => appendLog(job, chunk.toString())); - child.stderr.on("data", (chunk) => appendLog(job, chunk.toString())); + const handleOutput = (text: string) => { + appendLog(job, text); + if (/Render with hyperframes/i.test(text)) { + emitProgress(job, "render", "Rendering video..."); + } else if (/\bDone\b|=== Result ===/i.test(text)) { + emitProgress(job, "complete", "Video complete"); + } + }; + child.stdout.on("data", (chunk) => handleOutput(chunk.toString())); + child.stderr.on("data", (chunk) => handleOutput(chunk.toString())); child.on("error", (err) => { - appendLog(job, `Failed to start process: ${err.message}`); - finishJob(job, "failed", null); + failJob(job, { code: "SERVER_MISCONFIGURED", message: `Failed to start process: ${err.message}` }, null); }); child.on("close", (code) => { - finishJob(job, code === 0 ? "success" : "failed", code); + if (code === 0) { + emitProgress(job, "complete", "Video complete"); + finishJob(job, "success", code); + } else { + failJob(job, classifyPipelineExit(job.logs, code), code); + } }); } @@ -381,6 +497,8 @@ function serializeJob(job: Job) { startedAt: job.startedAt, finishedAt: job.finishedAt, outputDir: job.outputDir, + stage: job.stage, + error: job.error, logs: job.logs, }; } @@ -491,73 +609,58 @@ function timestamp(): string { async function runGenerateJob(job: Job, url: string, res: ServerResponse): Promise { try { - emitProgress(job, "Creating output directory..."); + emitProgress(job, "setup", "Creating output directory..."); const slug = slugFromUrl(url); const ts = timestamp(); const outputDir = join(OUTPUT_ROOT, `${slug}-${ts}`); await mkdir(outputDir, { recursive: true }); job.outputDir = toOutputRelative(outputDir); - emitProgress(job, "Loading configuration..."); + emitProgress(job, "setup", "Loading configuration..."); const cfg = loadConfig(); - emitProgress(job, "Starting LLM script generation..."); + emitProgress(job, "script", "Starting LLM script generation..."); const llmClient = createLlmClient(cfg); const rawScript = await llmClient.generateScript(url, (msg) => { - emitProgress(job, msg); + emitProgress(job, msg.startsWith("Fetching ") ? "fetch" : "script", msg); }); - emitProgress(job, "Validating generated script..."); + emitProgress(job, "script", "Validating generated script..."); const script = ScriptSchema.parse(rawScript); const scriptPath = join(outputDir, "script.json"); await writeFile(scriptPath, JSON.stringify(script, null, 2)); - emitProgress(job, `Script written to ${toOutputRelative(scriptPath)}`); - emitProgress(job, "Starting pipeline..."); + emitProgress(job, "script", `Script written to ${toOutputRelative(scriptPath)}`); + emitProgress(job, "tts", "Starting pipeline..."); await spawnPipeline(job, scriptPath); } catch (e) { - const message = e instanceof Error ? e.message : String(e); - appendLog(job, `Generate failed: ${message}`); - finishJob(job, "failed", null); + failJob(job, classifyJobError(e), null); } } export async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise { const url = new URL(req.url ?? "/", "http://localhost"); - const pathname = stripPublicBasePath(url.pathname); try { - if (req.method === "GET" && pathname === "/api/outputs") { + if (req.method === "GET" && url.pathname === "/api/outputs") { sendJson(res, 200, { outputs: await listOutputs() }); return; } - if (req.method === "GET" && pathname === "/api/settings") { - const settings = await readUiSettings(); - sendJson(res, 200, { - demoMode: isPublicDemoMode(), - settings: isPublicDemoMode() ? redactUiSettings(settings) : settings, - }); + if (req.method === "GET" && url.pathname === "/api/settings") { + sendJson(res, 200, { settings: await readUiSettings() }); return; } - if (req.method === "PUT" && pathname === "/api/settings") { - if (isPublicDemoMode()) { - sendError(res, 403, "Settings are read-only in public demo mode"); - return; - } + if (req.method === "PUT" && url.pathname === "/api/settings") { const body = await readJsonBody(req); sendJson(res, 200, { settings: await writeUiSettings(body) }); return; } - if (req.method === "POST" && pathname === "/api/generate") { - if (isPublicDemoMode()) { - sendError(res, 403, "Video generation is disabled in public demo mode"); - return; - } + if (req.method === "POST" && url.pathname === "/api/generate") { const body = await readJsonBody(req); const articleUrl = String(body.url || "").trim(); if (!articleUrl || !/^https?:\/\/.+/.test(articleUrl)) { @@ -571,24 +674,19 @@ export async function handleRequest(req: IncomingMessage, res: ServerResponse): return; } - if (req.method === "POST" && pathname === "/api/pipeline") { - if (isPublicDemoMode()) { - sendError(res, 403, "Pipeline runs are disabled in public demo mode"); - return; - } + if (req.method === "POST" && url.pathname === "/api/pipeline") { const body = await readJsonBody(req); const scriptPath = await assertExistingScriptPath(body.scriptPath); const job = createJob(scriptPath); job.outputDir = toOutputRelative(dirname(join(PROJECT_ROOT, scriptPath))); sendJson(res, 202, { job: serializeJob(job) }); spawnPipeline(job, join(PROJECT_ROOT, scriptPath)).catch((e) => { - appendLog(job, `Failed to start pipeline: ${e instanceof Error ? e.message : String(e)}`); - finishJob(job, "failed", null); + failJob(job, classifyJobError(e), null); }); return; } - const eventsMatch = pathname.match(/^\/api\/jobs\/([^/]+)\/events$/); + const eventsMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/events$/); if (req.method === "GET" && eventsMatch) { const job = jobs.get(eventsMatch[1]); if (!job) { @@ -611,13 +709,13 @@ export async function handleRequest(req: IncomingMessage, res: ServerResponse): return; } - if (req.method === "GET" && pathname.startsWith("/outputs/")) { - await serveStatic(res, OUTPUT_ROOT, pathname.slice("/outputs/".length)); + if (req.method === "GET" && url.pathname.startsWith("/outputs/")) { + await serveStatic(res, OUTPUT_ROOT, url.pathname.slice("/outputs/".length)); return; } if (req.method === "GET") { - const staticPath = pathname === "/" ? "index.html" : pathname.slice(1); + const staticPath = url.pathname === "/" ? "index.html" : url.pathname.slice(1); await serveStatic(res, UI_ROOT, staticPath); return; } @@ -632,10 +730,9 @@ export async function handleRequest(req: IncomingMessage, res: ServerResponse): if (process.argv[1] === fileURLToPath(import.meta.url)) { const port = Number(process.env.PORT ?? 4317); - const host = process.env.HOST ?? "127.0.0.1"; createServer((req, res) => { handleRequest(req, res).catch((e) => sendError(res, 500, e instanceof Error ? e.message : String(e))); - }).listen(port, host, () => { - console.log(`Auto News Video UI: http://${host}:${port}${PUBLIC_BASE_PATH || "/"}`); + }).listen(port, "127.0.0.1", () => { + console.log(`Auto News Video UI: http://127.0.0.1:${port}`); }); } From 81e7b38c6f1c35c6db6951609f4686f4b37c7df7 Mon Sep 17 00:00:00 2001 From: Hong Phuc Date: Sat, 30 May 2026 12:38:56 +0700 Subject: [PATCH 07/14] perf: add npm prune --production to reduce Docker image size --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index ee84869..3b89eae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ COPY package*.json ./ RUN npm ci COPY . . RUN npm run build +RUN npm prune --production # ── Runtime stage ───────────────────────────────────────────────────────── FROM node:26-bookworm-slim From 5827f542bf81e8a4e810409468a21170e32ac88e Mon Sep 17 00:00:00 2001 From: Hong Phuc Date: Sat, 30 May 2026 13:06:40 +0700 Subject: [PATCH 08/14] fix: remove broken HEALTHCHECK --- Dockerfile | 3 --- 1 file changed, 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3b89eae..a6a968e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,4 @@ ENV HOST=0.0.0.0 \ EXPOSE 4317 -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD node -e "require('http').get('http://localhost:4317/news-video-creating/',r=>{process.exit(r.statusCode===200?0:1)})" - CMD ["node", "dist/server.js"] From b2a3c9ca87d0874cf1a83c06e485f1d95189b38c Mon Sep 17 00:00:00 2001 From: Hong Phuc Date: Sat, 30 May 2026 13:08:08 +0700 Subject: [PATCH 09/14] fix: use HOST env var instead of hardcoded 127.0.0.1 --- src/server.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index bc4de89..6b3d214 100644 --- a/src/server.ts +++ b/src/server.ts @@ -730,9 +730,10 @@ export async function handleRequest(req: IncomingMessage, res: ServerResponse): if (process.argv[1] === fileURLToPath(import.meta.url)) { const port = Number(process.env.PORT ?? 4317); + const host = process.env.HOST ?? "127.0.0.1"; createServer((req, res) => { handleRequest(req, res).catch((e) => sendError(res, 500, e instanceof Error ? e.message : String(e))); - }).listen(port, "127.0.0.1", () => { - console.log(`Auto News Video UI: http://127.0.0.1:${port}`); + }).listen(port, host, () => { + console.log(`Auto News Video UI: http://${host}:${port}${PUBLIC_BASE_PATH || "/"}`); }); } From 819ee59cc8ce3bae51efa5f1168d5b311d095729 Mon Sep 17 00:00:00 2001 From: Hong Phuc Date: Sat, 30 May 2026 13:09:28 +0700 Subject: [PATCH 10/14] fix: hardcode 0.0.0.0 host, remove missing PUBLIC_BASE_PATH ref --- src/server.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index 6b3d214..279bd94 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,6 +18,7 @@ export const OUTPUT_ROOT = join(PROJECT_ROOT, "output"); export const SETTINGS_PATH = join(OUTPUT_ROOT, ".ui-settings.json"); const UI_ROOT = join(PROJECT_ROOT, "src", "ui"); const MAX_BODY_BYTES = 64 * 1024; +const PUBLIC_BASE_PATH = normalizePublicBasePath(process.env.PUBLIC_BASE_PATH); type JobStatus = "running" | "success" | "failed"; type JobEvent = "log" | "status" | "progress" | "error"; @@ -730,10 +731,10 @@ export async function handleRequest(req: IncomingMessage, res: ServerResponse): if (process.argv[1] === fileURLToPath(import.meta.url)) { const port = Number(process.env.PORT ?? 4317); - const host = process.env.HOST ?? "127.0.0.1"; + const host = process.env.HOST || "0.0.0.0"; createServer((req, res) => { handleRequest(req, res).catch((e) => sendError(res, 500, e instanceof Error ? e.message : String(e))); }).listen(port, host, () => { - console.log(`Auto News Video UI: http://${host}:${port}${PUBLIC_BASE_PATH || "/"}`); + console.log(`Auto News Video UI: http://${host}:${port}`); }); } From 8a6849302fb26fff25335000807d9ca5929b627a Mon Sep 17 00:00:00 2001 From: Hong Phuc Date: Sat, 30 May 2026 13:10:51 +0700 Subject: [PATCH 11/14] fix: inline PUBLIC_BASE_PATH normalization, drop missing helper --- src/server.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/server.ts b/src/server.ts index 279bd94..9b07a37 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,7 +18,9 @@ export const OUTPUT_ROOT = join(PROJECT_ROOT, "output"); export const SETTINGS_PATH = join(OUTPUT_ROOT, ".ui-settings.json"); const UI_ROOT = join(PROJECT_ROOT, "src", "ui"); const MAX_BODY_BYTES = 64 * 1024; -const PUBLIC_BASE_PATH = normalizePublicBasePath(process.env.PUBLIC_BASE_PATH); +const PUBLIC_BASE_PATH = process.env.PUBLIC_BASE_PATH + ? `/${process.env.PUBLIC_BASE_PATH.replace(/^\/+|\/+$/g, "")}` + : ""; type JobStatus = "running" | "success" | "failed"; type JobEvent = "log" | "status" | "progress" | "error"; From 848c2b14a8a70ae1c52ad8227c416f746ac666b0 Mon Sep 17 00:00:00 2001 From: Hong Phuc Date: Sat, 30 May 2026 13:20:01 +0700 Subject: [PATCH 12/14] fix: copy src/ directory in Dockerfile (missing UI files) --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index a6a968e..e99eac8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,7 @@ COPY --from=build /app/package*.json ./ COPY --from=build /app/node_modules ./node_modules COPY --from=build /app/dist ./dist COPY --from=build /app/assets ./assets +COPY --from=build /app/src ./src RUN groupadd -r appuser && useradd -r -g appuser -d /app appuser \ && chown -R appuser:appuser /app From 1b64787f914a0f85bdee1bea08777f7fdf9ddaa2 Mon Sep 17 00:00:00 2001 From: Hong Phuc Date: Sat, 30 May 2026 13:54:45 +0700 Subject: [PATCH 13/14] fix: add inline base-path stripping to request handler --- src/server.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/server.ts b/src/server.ts index 9b07a37..3f11bdc 100644 --- a/src/server.ts +++ b/src/server.ts @@ -734,7 +734,13 @@ export async function handleRequest(req: IncomingMessage, res: ServerResponse): if (process.argv[1] === fileURLToPath(import.meta.url)) { const port = Number(process.env.PORT ?? 4317); const host = process.env.HOST || "0.0.0.0"; + const basePath = (process.env.PUBLIC_BASE_PATH || "").replace(/\/+$/, ""); createServer((req, res) => { + // Strip public base path from incoming requests + if (basePath && req.url) { + if (req.url === basePath) req.url = "/"; + else if (req.url.startsWith(basePath + "/")) req.url = req.url.slice(basePath.length); + } handleRequest(req, res).catch((e) => sendError(res, 500, e instanceof Error ? e.message : String(e))); }).listen(port, host, () => { console.log(`Auto News Video UI: http://${host}:${port}`); From 5f8fbdba008730af3f00322c2408be41438fd3af Mon Sep 17 00:00:00 2001 From: Hong Phuc Date: Sat, 30 May 2026 14:52:49 +0700 Subject: [PATCH 14/14] fix: resolve merge conflicts in UI files --- src/ui/app.js | 4 +--- src/ui/index.html | 5 ----- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/ui/app.js b/src/ui/app.js index 22e35c7..da9c016 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -284,8 +284,7 @@ function formatStatus(job) { return `pipeline ${job.status}${exit}`; } -<<<<<<< Updated upstream -======= + function renderProgress(currentStage) { if (!currentStage) return; progressPanel.hidden = false; @@ -318,7 +317,6 @@ function setBusy(isBusy) { }); } ->>>>>>> Stashed changes function escapeHtml(value) { return String(value) .replaceAll("&", "&") diff --git a/src/ui/index.html b/src/ui/index.html index 7d5d0cb..a4e5794 100644 --- a/src/ui/index.html +++ b/src/ui/index.html @@ -3,17 +3,12 @@ -<<<<<<< Updated upstream - Auto Create Video - -======= Auto Create Video Studio ->>>>>>> Stashed changes