From 0e9738b495daadea6dca9b7a30e3d2aa6116089a Mon Sep 17 00:00:00 2001 From: Song367 <601337784@qq.com> Date: Wed, 18 Mar 2026 11:42:00 +0800 Subject: [PATCH] video translate initial commit --- .env.example | 26 + .gitignore | 8 + README.md | 38 + .../2026-03-17-doubao-llm-provider-design.md | 249 ++ docs/plans/2026-03-17-doubao-llm-provider.md | 472 +++ ...2026-03-17-export-preview-parity-design.md | 76 + .../plans/2026-03-17-export-preview-parity.md | 127 + ...17-precise-dialogue-localization-design.md | 239 ++ ...026-03-17-precise-dialogue-localization.md | 650 +++ .../2026-03-18-alignment-fallback-safety.md | 75 + index.html | 13 + metadata.json | 5 + package-lock.json | 3742 +++++++++++++++++ package.json | 49 + server.ts | 415 ++ src/App.tsx | 43 + src/components/EditorScreen.test.tsx | 98 + src/components/EditorScreen.tsx | 946 +++++ src/components/ExportModal.tsx | 220 + src/components/TrimModal.tsx | 268 ++ src/components/UploadScreen.tsx | 173 + src/components/VoiceMarketModal.tsx | 122 + src/index.css | 19 + .../alignment/sentenceReconstruction.test.ts | 27 + src/lib/alignment/sentenceReconstruction.ts | 84 + src/lib/alignment/speakerAssignment.test.ts | 25 + src/lib/alignment/speakerAssignment.ts | 39 + src/lib/exportPayload.test.ts | 92 + src/lib/exportPayload.ts | 39 + src/lib/playback/wordHighlight.test.ts | 21 + src/lib/playback/wordHighlight.ts | 6 + src/lib/speakers/speakerPresentation.test.ts | 18 + src/lib/speakers/speakerPresentation.ts | 12 + src/lib/subtitlePipeline.test.ts | 52 + src/lib/subtitlePipeline.ts | 58 + src/lib/timeline/snapToWords.test.ts | 29 + src/lib/timeline/snapToWords.ts | 22 + src/main.tsx | 10 + src/server/alignmentAdapter.test.ts | 162 + src/server/alignmentAdapter.ts | 317 ++ src/server/audioPipelineConfig.test.ts | 21 + src/server/audioPipelineConfig.ts | 13 + src/server/doubaoTranslation.test.ts | 95 + src/server/doubaoTranslation.ts | 122 + src/server/exportVideo.test.ts | 93 + src/server/exportVideo.ts | 148 + src/server/geminiTranslation.test.ts | 52 + src/server/geminiTranslation.ts | 71 + src/server/llmProvider.test.ts | 47 + src/server/llmProvider.ts | 64 + src/server/minimaxTts.test.ts | 48 + src/server/minimaxTts.ts | 51 + src/server/providerTranslation.test.ts | 33 + src/server/providerTranslation.ts | 11 + src/server/subtitleGeneration.test.ts | 103 + src/server/subtitleGeneration.ts | 38 + src/server/subtitlePipeline.test.ts | 115 + src/server/subtitlePipeline.ts | 180 + src/server/subtitleRequest.test.ts | 29 + src/server/subtitleRequest.ts | 25 + src/server/videoSubtitleGeneration.ts | 247 ++ src/services/subtitleService.test.ts | 45 + src/services/subtitleService.ts | 81 + src/services/ttsService.test.ts | 52 + src/services/ttsService.ts | 86 + src/test/setup.ts | 1 + src/test/smoke.test.ts | 7 + src/types.ts | 65 + src/voices.ts | 117 + start-dev.cmd | 4 + tmp_export_bgm.mp3 | Bin 0 -> 32618 bytes tmp_export_test.mp4 | Bin 0 -> 43365 bytes tmp_silence.wav | Bin 0 -> 32044 bytes tsconfig.json | 26 + vite.config.ts | 24 + vitest.config.ts | 8 + 76 files changed, 11208 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/plans/2026-03-17-doubao-llm-provider-design.md create mode 100644 docs/plans/2026-03-17-doubao-llm-provider.md create mode 100644 docs/plans/2026-03-17-export-preview-parity-design.md create mode 100644 docs/plans/2026-03-17-export-preview-parity.md create mode 100644 docs/plans/2026-03-17-precise-dialogue-localization-design.md create mode 100644 docs/plans/2026-03-17-precise-dialogue-localization.md create mode 100644 docs/plans/2026-03-18-alignment-fallback-safety.md create mode 100644 index.html create mode 100644 metadata.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server.ts create mode 100644 src/App.tsx create mode 100644 src/components/EditorScreen.test.tsx create mode 100644 src/components/EditorScreen.tsx create mode 100644 src/components/ExportModal.tsx create mode 100644 src/components/TrimModal.tsx create mode 100644 src/components/UploadScreen.tsx create mode 100644 src/components/VoiceMarketModal.tsx create mode 100644 src/index.css create mode 100644 src/lib/alignment/sentenceReconstruction.test.ts create mode 100644 src/lib/alignment/sentenceReconstruction.ts create mode 100644 src/lib/alignment/speakerAssignment.test.ts create mode 100644 src/lib/alignment/speakerAssignment.ts create mode 100644 src/lib/exportPayload.test.ts create mode 100644 src/lib/exportPayload.ts create mode 100644 src/lib/playback/wordHighlight.test.ts create mode 100644 src/lib/playback/wordHighlight.ts create mode 100644 src/lib/speakers/speakerPresentation.test.ts create mode 100644 src/lib/speakers/speakerPresentation.ts create mode 100644 src/lib/subtitlePipeline.test.ts create mode 100644 src/lib/subtitlePipeline.ts create mode 100644 src/lib/timeline/snapToWords.test.ts create mode 100644 src/lib/timeline/snapToWords.ts create mode 100644 src/main.tsx create mode 100644 src/server/alignmentAdapter.test.ts create mode 100644 src/server/alignmentAdapter.ts create mode 100644 src/server/audioPipelineConfig.test.ts create mode 100644 src/server/audioPipelineConfig.ts create mode 100644 src/server/doubaoTranslation.test.ts create mode 100644 src/server/doubaoTranslation.ts create mode 100644 src/server/exportVideo.test.ts create mode 100644 src/server/exportVideo.ts create mode 100644 src/server/geminiTranslation.test.ts create mode 100644 src/server/geminiTranslation.ts create mode 100644 src/server/llmProvider.test.ts create mode 100644 src/server/llmProvider.ts create mode 100644 src/server/minimaxTts.test.ts create mode 100644 src/server/minimaxTts.ts create mode 100644 src/server/providerTranslation.test.ts create mode 100644 src/server/providerTranslation.ts create mode 100644 src/server/subtitleGeneration.test.ts create mode 100644 src/server/subtitleGeneration.ts create mode 100644 src/server/subtitlePipeline.test.ts create mode 100644 src/server/subtitlePipeline.ts create mode 100644 src/server/subtitleRequest.test.ts create mode 100644 src/server/subtitleRequest.ts create mode 100644 src/server/videoSubtitleGeneration.ts create mode 100644 src/services/subtitleService.test.ts create mode 100644 src/services/subtitleService.ts create mode 100644 src/services/ttsService.test.ts create mode 100644 src/services/ttsService.ts create mode 100644 src/test/setup.ts create mode 100644 src/test/smoke.test.ts create mode 100644 src/types.ts create mode 100644 src/voices.ts create mode 100644 start-dev.cmd create mode 100644 tmp_export_bgm.mp3 create mode 100644 tmp_export_test.mp4 create mode 100644 tmp_silence.wav create mode 100644 tsconfig.json create mode 100644 vite.config.ts create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2f75a08 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# GEMINI_API_KEY: Required when the editor LLM is set to Gemini. +GEMINI_API_KEY="MY_GEMINI_API_KEY" + +# ARK_API_KEY: Required when the editor LLM is set to Doubao. +ARK_API_KEY="YOUR_ARK_API_KEY" + +# DEFAULT_LLM_PROVIDER: Optional editor default. Supported values: doubao, gemini. +# Defaults to doubao. +DEFAULT_LLM_PROVIDER="doubao" + +# DOUBAO_MODEL: Optional override for the Ark model used by Doubao subtitle generation. +# Defaults to doubao-seed-2-0-pro-260215. +DOUBAO_MODEL="doubao-seed-2-0-pro-260215" + +# MINIMAX_API_KEY: Required for MiniMax TTS API calls. +# Use a MiniMax API secret key that has TTS access enabled. +MINIMAX_API_KEY="YOUR_MINIMAX_API_KEY" + +# MINIMAX_API_HOST: Optional override for the MiniMax API host. +# Defaults to https://api.minimaxi.com +MINIMAX_API_HOST="https://api.minimaxi.com" + +# APP_URL: The URL where this applet is hosted. +# AI Studio automatically injects this at runtime with the Cloud Run service URL. +# Used for self-referential links, OAuth callbacks, and API endpoints. +APP_URL="MY_APP_URL" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a86d2a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +build/ +dist/ +coverage/ +.DS_Store +*.log +.env* +!.env.example diff --git a/README.md b/README.md new file mode 100644 index 0000000..a49c497 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +
+GHBanner +
+ +# Run and deploy your AI Studio app + +This contains everything you need to run your app locally. + +View your app in AI Studio: https://ai.studio/apps/a38a3cd5-7f82-49f0-a26e-99be4d77f863 + +## Run Locally + +**Prerequisites:** Node.js + + +1. Install dependencies: + `npm install` +2. Configure [.env](.env) with: + `ARK_API_KEY` + `GEMINI_API_KEY` + `MINIMAX_API_KEY` +3. Optional defaults: + `DEFAULT_LLM_PROVIDER=doubao` + `DOUBAO_MODEL=doubao-seed-2-0-pro-260215` +4. Run the app: + `npm run dev` + +## Model Switching + +1. Subtitle generation now runs through the server and supports `Doubao` and `Gemini`. +2. The editor shows an `LLM` selector and defaults to `Doubao`. +3. `TTS` stays fixed on `MiniMax` regardless of the selected LLM. +4. All provider keys are read from `.env`; the browser no longer calls LLM providers directly. + +## Subtitle Generation + +1. Subtitle generation is now driven by server-side multimodal LLM calls on the uploaded video file. +2. No separate local alignment/ASR backend is required for `/api/generate-subtitles`. diff --git a/docs/plans/2026-03-17-doubao-llm-provider-design.md b/docs/plans/2026-03-17-doubao-llm-provider-design.md new file mode 100644 index 0000000..c12ebb2 --- /dev/null +++ b/docs/plans/2026-03-17-doubao-llm-provider-design.md @@ -0,0 +1,249 @@ +# Doubao LLM Provider Design + +**Date:** 2026-03-17 + +**Goal:** Add a user-visible LLM switcher so subtitle generation can use either Doubao or Gemini, default to Doubao, and keep TTS fixed on MiniMax. + +## Current State + +The current project is effectively Gemini-only for subtitle generation and translation. + +1. `src/services/geminiService.ts` calls Gemini directly from the browser for subtitle generation and Gemini fallback TTS. +2. `src/server/geminiTranslation.ts` translates sentence text on the server with Gemini. +3. `src/server/audioPipelineConfig.ts` only validates `GEMINI_API_KEY`. +4. `src/components/EditorScreen.tsx` imports a Gemini-specific service and has no model selector. +5. MiniMax is already independent and used only for TTS through `/api/tts`. + +This makes provider switching hard because the LLM choice is not isolated behind a shared contract. + +## Product Requirements + +1. The editor must show a visible LLM selector. +2. Available LLM options are `Doubao` and `Gemini`. +3. The default LLM must be `Doubao`. +4. TTS must remain fixed to MiniMax and must not participate in provider switching. +5. API keys must only come from `.env`. +6. The app must not silently fall back from one LLM provider to the other. + +## Chosen Approach + +Use a server-side provider abstraction for subtitle generation and translation, with a frontend selector that passes the chosen provider to the server. + +This approach keeps secrets on the server, avoids browser-side provider drift, and gives the project one place to add or change LLM providers later. + +## Why This Approach + +### Option A: Server-side provider abstraction with frontend selector + +Recommended. + +1. Frontend sends `provider: 'doubao' | 'gemini'`. +2. Server reads the matching API key from `.env`. +3. Server routes subtitle text generation through a provider adapter. +4. Time-critical audio extraction and timeline logic stay outside the provider-specific layer. + +Pros: + +1. Keeps API keys off the client. +2. Produces one consistent API contract for the editor. +3. Makes default-provider behavior easy to enforce. +4. Prevents Gemini-specific code from leaking further into the app. + +Cons: + +1. Requires moving browser-side subtitle generation behavior into a server-owned path. +2. Touches both frontend and backend. + +### Option B: Keep Gemini in the browser and add Doubao as a separate server path + +Rejected. + +Pros: + +1. Faster initial implementation. + +Cons: + +1. Two subtitle-generation architectures would coexist. +2. Provider behavior would drift over time. +3. It violates the requirement that keys come only from `.env`. + +### Option C: Client-side provider switching + +Rejected. + +Pros: + +1. Minimal backend work. + +Cons: + +1. Exposes secrets to the browser. +2. Conflicts with the `.env`-only requirement. + +## Architecture + +### Frontend + +The editor adds an `LLM` selector with the values: + +1. `Doubao` +2. `Gemini` + +The default selected value is `Doubao`. + +When the user clicks subtitle generation, the frontend sends: + +1. the uploaded video +2. the target language +3. the selected LLM provider +4. optional trim metadata if the current flow needs it + +The frontend no longer needs to know how Gemini or Doubao are called. It only consumes a normalized subtitle payload. + +### Server + +The server becomes the single owner of LLM subtitle generation. + +Responsibilities: + +1. validate the incoming provider +2. read provider credentials from `.env` +3. extract audio and prepare subtitle-generation inputs +4. call the chosen provider adapter +5. normalize the result into the existing subtitle shape + +### Provider Layer + +Create a provider abstraction around LLM calls: + +1. `resolveLlmProvider(provider, env)` +2. `geminiProvider` +3. `doubaoProvider` + +Each provider must accept the same logical input and return the same logical output so the rest of the app is provider-agnostic. + +## API Design + +Add a dedicated subtitle-generation endpoint rather than overloading the existing audio-extraction endpoint. + +### Request + +`POST /api/generate-subtitles` + +Multipart or JSON payload fields: + +1. `video` +2. `targetLanguage` +3. `provider` +4. optional `trimRange` + +### Response + +Return the same normalized subtitle structure the editor already understands. + +At minimum each subtitle object should include: + +1. `id` +2. `startTime` +3. `endTime` +4. `originalText` +5. `translatedText` +6. `speaker` +7. `voiceId` +8. `volume` + +If richer timeline metadata already exists in the current server subtitle pipeline, keep it in the response rather than trimming it away. + +## Subtitle Generation Strategy + +The provider switch should affect LLM reasoning, not TTS and not the MiniMax path. + +The cleanest boundary is: + +1. audio extraction and timeline preparation stay on the server +2. LLM provider handles translation and label generation +3. MiniMax remains the only TTS engine + +This reduces the risk that switching providers changes subtitle timing behavior unpredictably. + +## Doubao Integration Notes + +Use the Ark Responses API on the server: + +1. host: `https://ark.cn-beijing.volces.com/api/v3/responses` +2. auth: `Authorization: Bearer ${ARK_API_KEY}` +3. model: configurable, defaulting to `doubao-seed-2-0-pro-260215` + +The provider should treat Doubao as a text-generation backend and extract normalized text from the response payload before JSON parsing. + +Implementation detail: + +1. the response parser should not assume SDK-specific helpers +2. it should read the returned response envelope and collect the textual output fragments +3. the final result should be parsed as JSON only after the output text is reconstructed + +This is an implementation inference based on the official Ark Responses API response shape and is meant to keep the parser resilient to wrapper differences. + +## Configuration + +Environment variables: + +1. `ARK_API_KEY` for Doubao +2. `GEMINI_API_KEY` for Gemini +3. `MINIMAX_API_KEY` for TTS +4. optional `DOUBAO_MODEL` for server-side model override +5. optional `DEFAULT_LLM_PROVIDER` with a default value of `doubao` + +Rules: + +1. No API keys may be embedded in frontend code. +2. No provider may silently reuse another provider's key. +3. If the selected provider is missing its key, return a clear error. + +## Error Handling + +Provider failures must be explicit. + +1. If `provider` is invalid, return `400`. +2. If the selected provider key is missing, return `400`. +3. If the selected provider returns an auth failure, return `401` or a mapped upstream auth error. +4. If the selected provider fails unexpectedly, return `502` or `500` with a provider-specific error message. +5. Do not auto-fallback from Doubao to Gemini or from Gemini to Doubao. + +The UI should show which provider failed so the user is never misled about which model generated a subtitle result. + +## Frontend UX + +Add the selector in the editor near the subtitle-generation controls so the choice is visible at generation time. + +Rules: + +1. Default selection is `Doubao`. +2. The selector affects each generation request immediately. +3. The selector does not affect previously generated subtitles until the user regenerates. +4. The selector does not affect MiniMax TTS generation. + +## Testing Strategy + +Coverage should focus on deterministic seams. + +1. Provider resolution defaults to Doubao. +2. Invalid provider is rejected. +3. Missing `ARK_API_KEY` or `GEMINI_API_KEY` returns clear errors. +4. Doubao response parsing turns Ark response content into normalized subtitle JSON. +5. Gemini and Doubao providers both satisfy the same interface contract. +6. Editor defaults to Doubao and sends the selected provider on regenerate. +7. TTS behavior remains unchanged when the LLM provider changes. + +## Rollout Notes + +1. Introduce the new endpoint and provider abstraction first. +2. Switch the editor to the new endpoint second. +3. Keep MiniMax TTS untouched except for regression checks. +4. Leave any deeper visual fallback provider work for a later pass if needed. + +## Constraints + +1. This workspace is not a Git repository, so the design document cannot be committed here. +2. The user provided an Ark key in chat, but the implementation must still read provider secrets from `.env` and not hardcode them into source files. diff --git a/docs/plans/2026-03-17-doubao-llm-provider.md b/docs/plans/2026-03-17-doubao-llm-provider.md new file mode 100644 index 0000000..22cdc07 --- /dev/null +++ b/docs/plans/2026-03-17-doubao-llm-provider.md @@ -0,0 +1,472 @@ +# Doubao LLM Provider Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a user-visible LLM switcher that lets subtitle generation use Doubao or Gemini, defaults to Doubao, and keeps TTS fixed on MiniMax with all provider keys sourced from `.env`. + +**Architecture:** Move subtitle generation behind a new server endpoint, introduce a provider abstraction for Gemini and Doubao, and update the editor to send the selected provider while continuing to use the existing subtitle shape. Keep MiniMax TTS separate and untouched except for regression coverage. + +**Tech Stack:** React, TypeScript, Express, multer, fetch, Vitest + +--- + +### Task 1: Add provider types and configuration resolution + +**Files:** +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\server\llmProvider.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\server\llmProvider.test.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\server\audioPipelineConfig.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\server\audioPipelineConfig.test.ts` + +**Step 1: Write the failing test** + +```ts +import { describe, expect, it } from 'vitest'; +import { normalizeLlmProvider, resolveLlmProviderConfig } from './llmProvider'; + +describe('llmProvider config', () => { + it('defaults to doubao when no provider override is set', () => { + expect(normalizeLlmProvider(undefined)).toBe('doubao'); + }); + + it('returns the selected provider key from env', () => { + expect( + resolveLlmProviderConfig('doubao', { + ARK_API_KEY: 'ark-key', + GEMINI_API_KEY: 'gemini-key', + }), + ).toEqual(expect.objectContaining({ provider: 'doubao', apiKey: 'ark-key' })); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `node .\node_modules\vitest\vitest.mjs run src/server/llmProvider.test.ts src/server/audioPipelineConfig.test.ts` +Expected: FAIL because `llmProvider.ts` does not exist and `audioPipelineConfig.ts` still only exposes Gemini config. + +**Step 3: Write minimal implementation** + +```ts +export type LlmProvider = 'doubao' | 'gemini'; + +export const normalizeLlmProvider = (value?: string): LlmProvider => + value?.toLowerCase() === 'gemini' ? 'gemini' : 'doubao'; + +export const resolveLlmProviderConfig = ( + provider: LlmProvider, + env: NodeJS.ProcessEnv, +) => { + if (provider === 'doubao') { + const apiKey = env.ARK_API_KEY?.trim(); + if (!apiKey) throw new Error('ARK_API_KEY is required for Doubao subtitle generation.'); + return { + provider, + apiKey, + model: env.DOUBAO_MODEL?.trim() || 'doubao-seed-2-0-pro-260215', + baseUrl: 'https://ark.cn-beijing.volces.com/api/v3/responses', + }; + } + + const apiKey = env.GEMINI_API_KEY?.trim(); + if (!apiKey) throw new Error('GEMINI_API_KEY is required for Gemini subtitle generation.'); + return { + provider, + apiKey, + model: 'gemini-2.5-flash', + }; +}; +``` + +**Step 4: Run test to verify it passes** + +Run: `node .\node_modules\vitest\vitest.mjs run src/server/llmProvider.test.ts src/server/audioPipelineConfig.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/server/llmProvider.ts src/server/llmProvider.test.ts src/server/audioPipelineConfig.ts src/server/audioPipelineConfig.test.ts +git commit -m "feat: add llm provider configuration" +``` + +### Task 2: Add the Doubao provider parser and contract tests + +**Files:** +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\server\doubaoProvider.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\server\doubaoProvider.test.ts` + +**Step 1: Write the failing test** + +```ts +import { describe, expect, it } from 'vitest'; +import { extractDoubaoTextOutput } from './doubaoProvider'; + +describe('extractDoubaoTextOutput', () => { + it('reconstructs text from the Ark output array', () => { + const text = extractDoubaoTextOutput({ + output: [ + { + type: 'message', + content: [{ type: 'output_text', text: '[{"id":"1","translatedText":"你好"}]' }], + }, + ], + }); + + expect(text).toContain('translatedText'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `node .\node_modules\vitest\vitest.mjs run src/server/doubaoProvider.test.ts` +Expected: FAIL because `doubaoProvider.ts` does not exist. + +**Step 3: Write minimal implementation** + +```ts +export const extractDoubaoTextOutput = (payload: any): string => + (payload?.output ?? []) + .flatMap((item: any) => item?.content ?? []) + .filter((part: any) => part?.type === 'output_text') + .map((part: any) => part.text ?? '') + .join('') + .trim(); +``` + +**Step 4: Run test to verify it passes** + +Run: `node .\node_modules\vitest\vitest.mjs run src/server/doubaoProvider.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/server/doubaoProvider.ts src/server/doubaoProvider.test.ts +git commit -m "feat: add doubao response parsing" +``` + +### Task 3: Add provider-backed translation adapters + +**Files:** +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\server\geminiTranslation.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\server\providerTranslation.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\server\providerTranslation.test.ts` +- Test: `E:\Downloads\ai-video-dubbing-&-translation\src\server\geminiTranslation.test.ts` + +**Step 1: Write the failing test** + +```ts +import { describe, expect, it } from 'vitest'; +import { createSentenceTranslator } from './providerTranslation'; + +describe('createSentenceTranslator', () => { + it('returns a Doubao translator when provider is doubao', () => { + const translator = createSentenceTranslator({ + provider: 'doubao', + apiKey: 'ark-key', + model: 'doubao-seed-2-0-pro-260215', + }); + + expect(typeof translator).toBe('function'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `node .\node_modules\vitest\vitest.mjs run src/server/providerTranslation.test.ts src/server/geminiTranslation.test.ts` +Expected: FAIL because the provider selection layer does not exist. + +**Step 3: Write minimal implementation** + +```ts +export const createSentenceTranslator = (config: ProviderConfig) => { + if (config.provider === 'doubao') { + return createDoubaoSentenceTranslator(config); + } + return createGeminiSentenceTranslator(config); +}; +``` + +**Step 4: Run test to verify it passes** + +Run: `node .\node_modules\vitest\vitest.mjs run src/server/providerTranslation.test.ts src/server/geminiTranslation.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/server/providerTranslation.ts src/server/providerTranslation.test.ts src/server/geminiTranslation.ts src/server/geminiTranslation.test.ts +git commit -m "feat: add provider-based translation adapters" +``` + +### Task 4: Add a dedicated subtitle-generation endpoint + +**Files:** +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\server.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\server\subtitleRequest.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\server\subtitleRequest.test.ts` +- Test: `E:\Downloads\ai-video-dubbing-&-translation\src\server\subtitlePipeline.test.ts` + +**Step 1: Write the failing test** + +```ts +import { describe, expect, it } from 'vitest'; +import { parseSubtitleRequest } from './subtitleRequest'; + +describe('parseSubtitleRequest', () => { + it('defaults provider to doubao', () => { + expect(parseSubtitleRequest({ body: {} as any }).provider).toBe('doubao'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `node .\node_modules\vitest\vitest.mjs run src/server/subtitleRequest.test.ts src/server/subtitlePipeline.test.ts` +Expected: FAIL because the request parser does not exist. + +**Step 3: Write minimal implementation** + +```ts +export const parseSubtitleRequest = (req: { body: Record }) => ({ + provider: normalizeLlmProvider(String(req.body.provider || 'doubao')), + targetLanguage: String(req.body.targetLanguage || ''), +}); +``` + +Then update `server.ts` to expose `POST /api/generate-subtitles`, validate input, resolve provider config, and return normalized subtitles. + +**Step 4: Run test to verify it passes** + +Run: `node .\node_modules\vitest\vitest.mjs run src/server/subtitleRequest.test.ts src/server/subtitlePipeline.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add server.ts src/server/subtitleRequest.ts src/server/subtitleRequest.test.ts src/server/subtitlePipeline.test.ts +git commit -m "feat: add subtitle generation endpoint" +``` + +### Task 5: Update the frontend subtitle service to use the new endpoint + +**Files:** +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\services\geminiService.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\services\subtitleService.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\services\subtitleService.test.ts` +- Test: `E:\Downloads\ai-video-dubbing-&-translation\src\components\EditorScreen.test.tsx` + +**Step 1: Write the failing test** + +```ts +import { describe, expect, it, vi } from 'vitest'; +import { generateSubtitles } from './subtitleService'; + +describe('generateSubtitles', () => { + it('posts the selected provider to the server', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ subtitles: [] }), + })); + + await generateSubtitles(new File(['x'], 'clip.mp4'), 'English', 'doubao', null, fetchMock as any); + + expect(fetchMock.mock.calls[0][0]).toBe('/api/generate-subtitles'); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `node .\node_modules\vitest\vitest.mjs run src/services/subtitleService.test.ts src/components/EditorScreen.test.tsx` +Expected: FAIL because the new service does not exist and the editor still uses the Gemini-specific service directly. + +**Step 3: Write minimal implementation** + +```ts +export const generateSubtitles = async ( + videoFile: File, + targetLanguage: string, + provider: 'doubao' | 'gemini', + trimRange?: { start: number; end: number } | null, + fetchImpl: typeof fetch = fetch, +) => { + const formData = new FormData(); + formData.append('video', videoFile); + formData.append('targetLanguage', targetLanguage); + formData.append('provider', provider); + if (trimRange) { + formData.append('trimRange', JSON.stringify(trimRange)); + } + + const response = await fetchImpl('/api/generate-subtitles', { + method: 'POST', + body: formData, + }); + + return response.json(); +}; +``` + +**Step 4: Run test to verify it passes** + +Run: `node .\node_modules\vitest\vitest.mjs run src/services/subtitleService.test.ts src/components/EditorScreen.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/services/subtitleService.ts src/services/subtitleService.test.ts src/services/geminiService.ts src/components/EditorScreen.test.tsx +git commit -m "feat: route subtitle generation through the server" +``` + +### Task 6: Add the editor LLM selector and default it to Doubao + +**Files:** +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\components\EditorScreen.tsx` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\components\EditorScreen.test.tsx` + +**Step 1: Write the failing test** + +```tsx +it('defaults the llm selector to Doubao', () => { + render( {}} />); + expect(screen.getByLabelText(/llm/i)).toHaveValue('doubao'); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `node .\node_modules\vitest\vitest.mjs run src/components/EditorScreen.test.tsx` +Expected: FAIL because the selector does not exist. + +**Step 3: Write minimal implementation** + +```tsx +const [llmProvider, setLlmProvider] = useState<'doubao' | 'gemini'>('doubao'); + + +``` + +Then pass `llmProvider` into the subtitle-generation service. + +**Step 4: Run test to verify it passes** + +Run: `node .\node_modules\vitest\vitest.mjs run src/components/EditorScreen.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/components/EditorScreen.tsx src/components/EditorScreen.test.tsx +git commit -m "feat: add llm selector to the editor" +``` + +### Task 7: Add end-to-end provider and regression coverage + +**Files:** +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\server\subtitlePipeline.test.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\services\geminiService.test.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\server\minimaxTts.test.ts` + +**Step 1: Write the failing test** + +```ts +it('does not change TTS behavior when the llm provider changes', async () => { + expect(true).toBe(true); +}); +``` + +**Step 2: Run test to verify it fails meaningfully** + +Run: `node .\node_modules\vitest\vitest.mjs run src/server/subtitlePipeline.test.ts src/services/geminiService.test.ts src/server/minimaxTts.test.ts` +Expected: FAIL or require stronger assertions until the new provider path is covered. + +**Step 3: Write minimal implementation** + +Add regression tests that prove: + +1. selected provider is forwarded correctly +2. Doubao auth failures surface clearly +3. Gemini still works when selected +4. MiniMax TTS tests continue to pass unchanged + +**Step 4: Run test to verify it passes** + +Run: `node .\node_modules\vitest\vitest.mjs run src/server/llmProvider.test.ts src/server/doubaoProvider.test.ts src/server/providerTranslation.test.ts src/server/subtitleRequest.test.ts src/server/subtitlePipeline.test.ts src/services/subtitleService.test.ts src/components/EditorScreen.test.tsx src/server/minimaxTts.test.ts src/services/geminiService.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/server/llmProvider.test.ts src/server/doubaoProvider.test.ts src/server/providerTranslation.test.ts src/server/subtitleRequest.test.ts src/server/subtitlePipeline.test.ts src/services/subtitleService.test.ts src/components/EditorScreen.test.tsx src/server/minimaxTts.test.ts src/services/geminiService.test.ts +git commit -m "test: cover llm provider switching" +``` + +### Task 8: Verify the live app behavior + +**Files:** +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\.env.example` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\README.md` + +**Step 1: Write the failing doc check** + +Add docs assertions by inspection: + +1. `.env.example` documents `ARK_API_KEY` and optional `DOUBAO_MODEL` +2. README explains the editor LLM switcher and that MiniMax remains the TTS engine + +**Step 2: Run verification commands** + +Run: `node .\node_modules\vitest\vitest.mjs run` +Expected: PASS for the new targeted suites or clear identification of pre-existing unrelated failures. + +Run: `Invoke-WebRequest -UseBasicParsing http://localhost:3000/` +Expected: `200` + +Run manual checks: + +1. open the editor +2. confirm the `LLM` selector defaults to `Doubao` +3. generate subtitles with `Doubao` +4. switch to `Gemini` +5. generate subtitles again +6. confirm TTS still uses MiniMax + +**Step 3: Write minimal documentation updates** + +Document: + +1. required env keys +2. default provider +3. how the editor switcher works + +**Step 4: Re-run verification** + +Run: `node .\node_modules\vitest\vitest.mjs run src/server/llmProvider.test.ts src/server/doubaoProvider.test.ts src/server/providerTranslation.test.ts src/server/subtitleRequest.test.ts src/services/subtitleService.test.ts src/components/EditorScreen.test.tsx src/server/minimaxTts.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add .env.example README.md +git commit -m "docs: document llm provider switching" +``` + +## Notes + +1. This workspace is not a Git repository, so the commit steps may not be executable here. +2. Existing unrelated TypeScript baseline issues in `src/lib/*` and `src/server/*` should be treated as pre-existing unless the new work touches them directly. diff --git a/docs/plans/2026-03-17-export-preview-parity-design.md b/docs/plans/2026-03-17-export-preview-parity-design.md new file mode 100644 index 0000000..b1cdcbc --- /dev/null +++ b/docs/plans/2026-03-17-export-preview-parity-design.md @@ -0,0 +1,76 @@ +# Export Preview Parity Design + +**Date:** 2026-03-17 + +**Goal:** Make exported videos match the editor preview for audio mixing, subtitle timing, and visible subtitle styling. + +## Current State + +The editor preview and the export pipeline currently render the same edit session through different implementations: + +1. The preview in `src/components/EditorScreen.tsx` overlays subtitle text with React and plays audio using the browser media elements plus per-subtitle `Audio` instances. +2. The export in `server.ts` rebuilds subtitles as SRT, mixes audio with FFmpeg, and trims the final output after subtitle timing and TTS delays have already been computed. + +This creates three deterministic mismatches: + +1. Export mixes original audio even when the preview has muted it because instrumental BGM is present. +2. Export uses relative subtitle times from the trimmed editor session but trims the final video afterward, shifting or cutting subtitle/TTS timing. +3. Export ignores `textStyles`, so the rendered subtitle look differs from the preview. + +## Chosen Approach + +Adopt preview-first export semantics: + +1. Treat the editor state as the source of truth. +2. Serialize the preview-visible subtitle data, text styles, and audio volume data explicitly. +3. Convert preview-relative subtitle timing into export timeline timing before FFmpeg rendering. +4. Generate styled subtitle overlays in the backend instead of relying on FFmpeg defaults. + +## Architecture + +### Frontend + +The editor passes a richer export payload: + +1. Subtitle text +2. Subtitle timing +3. Subtitle audio volume +4. Global text style settings +5. Trim range +6. Instrumental BGM base64 when present + +The preview itself stays unchanged and remains the reference behavior. + +### Backend Export Layer + +The export route should move the parity-sensitive logic into pure helpers: + +1. Build an export subtitle timeline that shifts relative editor timings back onto the full-video timeline when trimming is enabled. +2. Build an audio mix plan that mirrors preview rules: + - Use instrumental BGM at preview volume when present. + - Exclude original source audio when instrumental BGM is present. + - Otherwise keep original source audio at preview volume. + - Apply each subtitle TTS clip at its configured volume. +3. Generate ASS subtitle content so font, color, alignment, bold, italic, and underline can be rendered intentionally. + +## Data Flow + +1. `EditorScreen` passes `textStyles` into `ExportModal`. +2. `ExportModal` builds a structured export payload instead of manually shaping subtitle fields inline. +3. `server.ts` parses `textStyles`, normalizes subtitle timing for export, builds ASS subtitle content, and applies the preview-equivalent audio plan. +4. FFmpeg burns styled subtitles and mixes the planned audio sources. + +## Testing Strategy + +Add regression coverage around pure helpers instead of FFmpeg end-to-end tests: + +1. Frontend payload builder includes style and volume fields. +2. Export timeline normalization shifts subtitle timing correctly for trimmed clips. +3. Audio mix planning excludes original audio when BGM is present and keeps it at preview volume when BGM is absent. +4. ASS subtitle generation reflects the selected style settings. + +## Risks + +1. ASS subtitle rendering may still not be pixel-perfect relative to browser CSS. +2. Existing exports without style payload should remain backward compatible by falling back to safe defaults. +3. FFmpeg filter graph assembly becomes slightly more complex, so helper-level tests are required before touching route logic. diff --git a/docs/plans/2026-03-17-export-preview-parity.md b/docs/plans/2026-03-17-export-preview-parity.md new file mode 100644 index 0000000..ff6dd32 --- /dev/null +++ b/docs/plans/2026-03-17-export-preview-parity.md @@ -0,0 +1,127 @@ +# Export Preview Parity Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make exported videos match the editor preview for audio behavior, subtitle timing, and visible subtitle styling. + +**Architecture:** Keep the editor preview as the source of truth and teach the export pipeline to consume the same state explicitly. Extract pure helpers for export payload building, subtitle timeline normalization, audio mix planning, and ASS subtitle generation so we can lock parity with tests before wiring FFmpeg. + +**Tech Stack:** React 19, TypeScript, Express, FFmpeg, Vitest. + +--- + +### Task 1: Add Export Payload Builder Coverage + +**Files:** +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\lib\exportPayload.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\lib\exportPayload.test.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\components\ExportModal.tsx` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\components\EditorScreen.tsx` + +**Step 1: Write the failing test** + +Cover that export payloads include subtitle audio volume and global text styles. + +**Step 2: Run test to verify it fails** + +Run: `node .\node_modules\vitest\vitest.mjs run src/lib/exportPayload.test.ts` +Expected: FAIL because the helper does not exist yet. + +**Step 3: Write minimal implementation** + +Create a small pure builder and wire `ExportModal` to use it. Pass `textStyles` from `EditorScreen`. + +**Step 4: Run test to verify it passes** + +Run: `node .\node_modules\vitest\vitest.mjs run src/lib/exportPayload.test.ts` +Expected: PASS. + +### Task 2: Add Export Backend Planning Helpers + +**Files:** +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\server\exportVideo.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\server\exportVideo.test.ts` + +**Step 1: Write the failing test** + +Cover: + +1. Subtitle times shift by `trimRange.start` for export. +2. Original source audio is excluded when BGM is present. +3. Original source audio is kept at preview volume when BGM is absent. +4. ASS subtitle output reflects selected styles. + +**Step 2: Run test to verify it fails** + +Run: `node .\node_modules\vitest\vitest.mjs run src/server/exportVideo.test.ts` +Expected: FAIL because helper module does not exist yet. + +**Step 3: Write minimal implementation** + +Implement pure helpers for: + +1. Subtitle timeline normalization +2. Audio mix planning +3. ASS subtitle generation + +**Step 4: Run test to verify it passes** + +Run: `node .\node_modules\vitest\vitest.mjs run src/server/exportVideo.test.ts` +Expected: PASS. + +### Task 3: Wire Backend Export Route to Helpers + +**Files:** +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\server.ts` + +**Step 1: Write the failing integration-leaning test** + +Extend `src/server/exportVideo.test.ts` if needed to assert the route-facing helper contract. + +**Step 2: Run test to verify it fails** + +Run: `node .\node_modules\vitest\vitest.mjs run src/server/exportVideo.test.ts` +Expected: FAIL because current route behavior still assumes SRT/default mixing. + +**Step 3: Write minimal implementation** + +Update the route to: + +1. Parse `textStyles` +2. Use normalized subtitle times for export +3. Generate `.ass` instead of `.srt` +4. Apply preview-equivalent audio mix rules + +**Step 4: Run test to verify it passes** + +Run: `node .\node_modules\vitest\vitest.mjs run src/server/exportVideo.test.ts` +Expected: PASS. + +### Task 4: Verify End-to-End Regressions + +**Files:** +- Modify if needed: `E:\Downloads\ai-video-dubbing-&-translation\src\services\geminiService.test.ts` +- Modify if needed: `E:\Downloads\ai-video-dubbing-&-translation\src\server\minimaxTts.test.ts` + +**Step 1: Run focused regression suite** + +Run: + +```bash +node .\node_modules\vitest\vitest.mjs run src/lib/exportPayload.test.ts src/server/exportVideo.test.ts src/services/geminiService.test.ts src/server/minimaxTts.test.ts +``` + +Expected: PASS. + +**Step 2: Run TypeScript check** + +Run: `node .\node_modules\typescript\bin\tsc --noEmit` +Expected: Existing baseline errors may remain; no new export-parity errors should appear. + +**Step 3: Smoke-check the running app** + +1. Restart the local server. +2. Export a trimmed clip with BGM and TTS. +3. Confirm the exported audio and subtitle timing now match preview expectations. + +Plan complete and saved to `docs/plans/2026-03-17-export-preview-parity.md`. Defaulting to execution in this session using the plan directly. diff --git a/docs/plans/2026-03-17-precise-dialogue-localization-design.md b/docs/plans/2026-03-17-precise-dialogue-localization-design.md new file mode 100644 index 0000000..9013684 --- /dev/null +++ b/docs/plans/2026-03-17-precise-dialogue-localization-design.md @@ -0,0 +1,239 @@ +# Precise Dialogue Localization Design + +**Date:** 2026-03-17 + +**Goal:** Upgrade the subtitle pipeline so sentence boundaries are more accurate, word-level timings are available, and speaker attribution is based on audio rather than LLM guesses. + +## Current State + +The current implementation has two subtitle generation paths: + +1. The primary path in `server.ts` extracts audio, calls Whisper with `timestamp_granularities: ['segment']`, then asks an LLM to translate and infer `speaker` and `gender`. +2. The fallback path in `src/services/geminiService.ts` uses Gemini to infer subtitles from video or sampled frames. + +This is enough for rough subtitle generation, but it has three hard limits: + +1. Sentence timing is only segment-level, so start and end times drift at pause boundaries. +2. Word-level timestamps do not exist, so precise editing and karaoke-style highlighting are impossible. +3. Speaker identity is inferred from text, not measured from audio, so diarization quality is unreliable. + +## Chosen Approach + +Adopt a high-precision pipeline with a dedicated alignment layer: + +1. Extract clean mono audio from the uploaded video. +2. Use voice activity detection (VAD) to isolate speech regions. +3. Run ASR for rough transcription. +4. Run forced alignment to refine every word boundary against the audio. +5. Run speaker diarization to assign stable `speakerId` values. +6. Rebuild editable subtitle sentences from aligned words. +7. Translate only the sentence text while preserving timestamps and speaker assignments. + +The existing Node service remains the entry point, but it becomes an orchestration layer instead of doing all timing work itself. + +## Architecture + +### Frontend + +The React editor continues to call `/api/process-audio-pipeline`, but it now receives richer subtitle objects: + +1. Sentence-level timing for the timeline. +2. Word-level timing for precise playback feedback. +3. Stable `speakerId` values for speaker-aware UI and voice assignment. + +The current editor can remain backward compatible by continuing to render sentence-level fields first and gradually enabling word-level behavior. + +### Node Orchestration Layer + +`server.ts` keeps responsibility for: + +1. Receiving uploaded video data. +2. Extracting audio with FFmpeg. +3. Calling the alignment service. +4. Translating sentence text. +5. Returning a normalized payload to the frontend. + +The Node layer must not allow translation to rewrite timing or speaker assignments. + +### Alignment Layer + +This layer owns all timing-critical operations: + +1. VAD +2. ASR +3. Forced alignment +4. Speaker diarization + +It can be implemented as a local Python service or a separately managed service as long as it returns deterministic machine-readable JSON. + +## Data Model + +The current `Subtitle` type should be extended rather than replaced. + +```ts +type WordTiming = { + text: string; + startTime: number; + endTime: number; + speakerId: string; + confidence: number; +}; + +type Subtitle = { + id: string; + startTime: number; + endTime: number; + originalText: string; + translatedText: string; + speaker: string; + speakerId: string; + voiceId: string; + words: WordTiming[]; + confidence: number; + audioUrl?: string; + volume?: number; +}; + +type SpeakerTrack = { + speakerId: string; + label: string; + gender?: 'male' | 'female' | 'unknown'; +}; +``` + +Rules: + +1. `speakerId` is the stable machine identifier, for example `spk_0`. +2. `speaker` is a user-facing label and can be renamed. +3. Sentence `startTime` and `endTime` are derived from the first and last aligned words. + +## Processing Rules + +### Audio Preparation + +1. Convert uploaded video to `16kHz` mono WAV. +2. Optionally create a denoised or vocal-enhanced copy when the source contains heavy music. + +### VAD + +Use VAD to identify speech windows and pad each detected region by about `0.2s`. + +### ASR and Forced Alignment + +1. Use ASR for text hypotheses and rough word order. +2. Use forced alignment to compute accurate `startTime` and `endTime` for each word. +3. Treat forced alignment as the source of truth for timing whenever available. + +### Diarization + +1. Run diarization separately and produce speaker segments. +2. Assign each word to the speaker with the highest overlap. +3. If a sentence crosses speakers, split it rather than forcing a mixed-speaker sentence. + +### Sentence Reconstruction + +Build sentence subtitles from words using conservative rules: + +1. Keep words together only when `speakerId` is the same. +2. Split when adjacent word gaps exceed `0.45s`. +3. Split when sentence duration would exceed `8s`. +4. Split on strong punctuation or long pauses. +5. Avoid returning sentences shorter than `0.6s` unless the source is actually brief. + +## API Design + +Reuse `/api/process-audio-pipeline`, but upgrade its payload to: + +```json +{ + "subtitles": [], + "speakers": [], + "sourceLanguage": "zh", + "targetLanguage": "en", + "duration": 123.45, + "quality": "full", + "alignmentEngine": "whisperx+pyannote" +} +``` + +Quality levels: + +1. `full`: sentence timings, word timings, and diarization are all available. +2. `partial`: word timings are available but diarization is missing or unreliable. +3. `fallback`: high-precision alignment failed, so the app returned rough timing from the existing path. + +## Frontend Behavior + +The current editor in `src/components/EditorScreen.tsx` should evolve incrementally: + +1. Keep the existing sentence-based timeline as the default view. +2. Add word-level highlighting during playback when `words` exist. +3. Add speaker-aware styling and filtering when `speakers` exist. +4. Preserve manual timeline editing and snap dragged sentence edges to nearest word boundaries when possible. + +Fallback behavior: + +1. If `quality` is `full`, enable all precision UI. +2. If `quality` is `partial`, disable speaker-specific UI and keep timing features. +3. If `quality` is `fallback`, continue with the current editor and show a low-precision notice. + +## Error Handling and Degradation + +The product must remain usable even when the high-precision path is incomplete. + +1. If forced alignment fails, return sentence-level ASR output instead of failing the whole request. +2. If diarization fails, keep timings and mark `speakerId` as `unknown`. +3. If translation fails, return original text with timings intact. +4. If the alignment layer is unavailable, fall back to the existing visual pipeline and set `quality: "fallback"`. +5. Preserve low-confidence words and expose their confidence rather than dropping them silently. + +## Testing Strategy + +Coverage should focus on deterministic logic: + +1. Sentence reconstruction from aligned words. +2. Speaker assignment from overlapping diarization segments. +3. API normalization and fallback handling. +4. Frontend word-highlighting and snapping helpers. + +End-to-end manual verification should include: + +1. Single-speaker clip with pauses. +2. Two-speaker dialogue with interruptions. +3. Music-heavy clip. +4. Alignment failure fallback. + +## Rollout Plan + +1. Extend types and response normalization first. +2. Introduce the alignment adapter behind a feature flag or environment guard. +3. Return richer payloads while keeping the current UI backward compatible. +4. Add word-level highlighting and speaker-aware UI after the backend contract stabilizes. + +## Constraints and Notes + +1. This workspace is not a Git repository, so the required design-document commit could not be performed here. +2. The current project does not yet include a test runner, so the implementation plan includes test infrastructure setup before feature work. + +## Implementation Status + +Implemented in this workspace: + +1. Test infrastructure using Vitest, jsdom, and Testing Library. +2. Shared subtitle pipeline helpers for normalization, sentence reconstruction, speaker assignment, word highlighting, and timeline snapping. +3. A backend subtitle orchestration layer plus an alignment-service adapter boundary for local ASR / alignment backends. +4. Gemini-based sentence translation in the audio pipeline, without relying on OpenAI for ASR or translation. +5. Frontend pipeline mapping, precision notices, word-level playback feedback, and speaker-aware presentation. + +Automated verification completed: + +1. `npm test -- --run` +2. `npm run lint` +3. `npm run build` + +Manual verification still pending: + +1. Single-speaker clip with pauses. +2. Two-speaker dialogue with interruptions. +3. Music-heavy clip. +4. Alignment-service unavailable fallback using a real upload. diff --git a/docs/plans/2026-03-17-precise-dialogue-localization.md b/docs/plans/2026-03-17-precise-dialogue-localization.md new file mode 100644 index 0000000..de4b5b2 --- /dev/null +++ b/docs/plans/2026-03-17-precise-dialogue-localization.md @@ -0,0 +1,650 @@ +# Precise Dialogue Localization Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build a high-precision subtitle pipeline that returns accurate sentence boundaries, word-level timings, and real speaker attribution while preserving the current editor flow. + +**Architecture:** Keep the React app and `server.ts` as the public entry points, but move timing-critical work into a dedicated alignment adapter. The backend normalizes aligned words into sentence subtitles, translates text without changing timing, and returns quality metadata so the editor can enable or disable precision UI safely. + +**Tech Stack:** React 19, TypeScript, Vite, Express, FFmpeg, OpenAI SDK, a new test runner (`vitest`), and a high-precision alignment backend adapter. + +--- + +### Task 1: Add Test Infrastructure + +**Files:** +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\package.json` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\vitest.config.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\test\setup.ts` + +**Step 1: Write the failing test** + +Create a minimal smoke test first so the test runner has a real target. + +```ts +import { describe, expect, it } from 'vitest'; + +describe('test harness', () => { + it('runs vitest in this workspace', () => { + expect(true).toBe(true); + }); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm test -- --run` +Expected: FAIL because no `test` script or Vitest config exists yet. + +**Step 3: Write minimal implementation** + +1. Add `test` and `test:watch` scripts to `package.json`. +2. Add dev dependencies for `vitest`. +3. Create `vitest.config.ts` with a Node environment default. +4. Add `src/test/setup.ts` for shared setup. + +```ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + setupFiles: ['./src/test/setup.ts'], + }, +}); +``` + +**Step 4: Run test to verify it passes** + +Run: `npm test -- --run` +Expected: PASS with the smoke test. + +**Step 5: Commit** + +```bash +git add package.json vitest.config.ts src/test/setup.ts +git commit -m "test: add vitest infrastructure" +``` + +### Task 2: Extract Subtitle Pipeline Types and Normalizers + +**Files:** +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\types.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\lib\subtitlePipeline.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\lib\subtitlePipeline.test.ts` + +**Step 1: Write the failing test** + +Write tests for normalization from aligned word payloads to UI-ready subtitles. + +```ts +it('derives subtitle boundaries from first and last word', () => { + const result = normalizeAlignedSentence({ + id: 's1', + speakerId: 'spk_0', + words: [ + { text: 'Hello', startTime: 1.2, endTime: 1.5, speakerId: 'spk_0', confidence: 0.99 }, + { text: 'world', startTime: 1.6, endTime: 2.0, speakerId: 'spk_0', confidence: 0.98 }, + ], + originalText: 'Hello world', + translatedText: '你好世界', + }); + + expect(result.startTime).toBe(1.2); + expect(result.endTime).toBe(2.0); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm test -- --run src/lib/subtitlePipeline.test.ts` +Expected: FAIL because the new module and extended types do not exist. + +**Step 3: Write minimal implementation** + +1. Extend `Subtitle` in `src/types.ts` with `speakerId`, `words`, and `confidence`. +2. Create a pure helper module that normalizes backend payloads into frontend subtitles. + +```ts +export const deriveSubtitleBounds = (words: WordTiming[]) => ({ + startTime: words[0]?.startTime ?? 0, + endTime: words[words.length - 1]?.endTime ?? 0, +}); +``` + +**Step 4: Run test to verify it passes** + +Run: `npm test -- --run src/lib/subtitlePipeline.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/types.ts src/lib/subtitlePipeline.ts src/lib/subtitlePipeline.test.ts +git commit -m "feat: add subtitle pipeline normalizers" +``` + +### Task 3: Implement Sentence Reconstruction Helpers + +**Files:** +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\lib\alignment\sentenceReconstruction.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\lib\alignment\sentenceReconstruction.test.ts` + +**Step 1: Write the failing test** + +Cover pause splitting and speaker splitting. + +```ts +it('splits sentences when speaker changes', () => { + const result = rebuildSentences([ + { text: 'Hi', startTime: 0.0, endTime: 0.2, speakerId: 'spk_0', confidence: 0.9 }, + { text: 'there', startTime: 0.25, endTime: 0.5, speakerId: 'spk_0', confidence: 0.9 }, + { text: 'no', startTime: 0.55, endTime: 0.7, speakerId: 'spk_1', confidence: 0.9 }, + ]); + + expect(result).toHaveLength(2); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm test -- --run src/lib/alignment/sentenceReconstruction.test.ts` +Expected: FAIL because the helper module is missing. + +**Step 3: Write minimal implementation** + +Implement pure splitting rules: + +1. Split on `speakerId` change. +2. Split when word gaps exceed `0.45`. +3. Split when sentence duration exceeds `8`. + +```ts +if (nextWord.speakerId !== currentSpeakerId) { + flushSentence(); +} +``` + +**Step 4: Run test to verify it passes** + +Run: `npm test -- --run src/lib/alignment/sentenceReconstruction.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/lib/alignment/sentenceReconstruction.ts src/lib/alignment/sentenceReconstruction.test.ts +git commit -m "feat: add sentence reconstruction rules" +``` + +### Task 4: Implement Speaker Assignment Helpers + +**Files:** +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\lib\alignment\speakerAssignment.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\lib\alignment\speakerAssignment.test.ts` + +**Step 1: Write the failing test** + +Test overlap-based speaker assignment. + +```ts +it('assigns each word to the speaker segment with maximum overlap', () => { + const word = { text: 'hello', startTime: 1.0, endTime: 1.4 }; + const speakers = [ + { speakerId: 'spk_0', startTime: 0.8, endTime: 1.1 }, + { speakerId: 'spk_1', startTime: 1.1, endTime: 1.6 }, + ]; + + expect(assignSpeakerToWord(word, speakers)).toBe('spk_1'); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm test -- --run src/lib/alignment/speakerAssignment.test.ts` +Expected: FAIL because speaker assignment logic does not exist. + +**Step 3: Write minimal implementation** + +Add a pure overlap calculator and default to `unknown` when no segment overlaps. + +```ts +const overlap = Math.max( + 0, + Math.min(word.endTime, segment.endTime) - Math.max(word.startTime, segment.startTime), +); +``` + +**Step 4: Run test to verify it passes** + +Run: `npm test -- --run src/lib/alignment/speakerAssignment.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/lib/alignment/speakerAssignment.ts src/lib/alignment/speakerAssignment.test.ts +git commit -m "feat: add speaker assignment helpers" +``` + +### Task 5: Isolate Backend Pipeline Logic from `server.ts` + +**Files:** +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\server\subtitlePipeline.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\server\subtitlePipeline.test.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\server.ts` + +**Step 1: Write the failing test** + +Add tests for orchestration-level fallback behavior. + +```ts +it('returns partial quality when diarization is unavailable', async () => { + const result = await buildSubtitlePayload({ + alignmentResult: { + words: [{ text: 'hi', startTime: 0, endTime: 0.2, speakerId: 'unknown', confidence: 0.9 }], + speakerSegments: [], + quality: 'partial', + }, + }); + + expect(result.quality).toBe('partial'); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm test -- --run src/server/subtitlePipeline.test.ts` +Expected: FAIL because orchestration code is still embedded in `server.ts`. + +**Step 3: Write minimal implementation** + +1. Move payload-building logic into `src/server/subtitlePipeline.ts`. +2. Make `server.ts` call the helper and only handle HTTP concerns. + +```ts +export const buildSubtitlePayload = async (deps: SubtitlePipelineDeps) => { + // normalize alignment result + // translate text + // return { subtitles, speakers, quality, ... } +}; +``` + +**Step 4: Run test to verify it passes** + +Run: `npm test -- --run src/server/subtitlePipeline.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/server/subtitlePipeline.ts src/server/subtitlePipeline.test.ts server.ts +git commit -m "refactor: isolate subtitle pipeline orchestration" +``` + +### Task 6: Add an Alignment Service Adapter + +**Files:** +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\server\alignmentAdapter.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\server\alignmentAdapter.test.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\server.ts` + +**Step 1: Write the failing test** + +Test that the adapter maps raw alignment responses into normalized internal types. + +```ts +it('maps aligned words and speaker segments from the adapter response', async () => { + const result = await parseAlignmentResponse({ + words: [{ word: 'hello', start: 1.0, end: 1.2, speaker: 'spk_0', score: 0.95 }], + speakers: [{ speaker: 'spk_0', start: 0.8, end: 1.6 }], + }); + + expect(result.words[0].speakerId).toBe('spk_0'); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm test -- --run src/server/alignmentAdapter.test.ts` +Expected: FAIL because no adapter exists. + +**Step 3: Write minimal implementation** + +Create an adapter boundary with one public function such as `requestAlignedTranscript(audioPath)`. + +```ts +export const requestAlignedTranscript = async (audioPath: string) => { + // call local or remote alignment backend + // normalize response shape +}; +``` + +**Step 4: Run test to verify it passes** + +Run: `npm test -- --run src/server/alignmentAdapter.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/server/alignmentAdapter.ts src/server/alignmentAdapter.test.ts server.ts +git commit -m "feat: add alignment service adapter" +``` + +### Task 7: Upgrade `/api/process-audio-pipeline` Response Shape + +**Files:** +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\server.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\services\geminiService.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\services\geminiService.test.ts` + +**Step 1: Write the failing test** + +Add a client-side test for parsing `quality`, `speakers`, and `words`. + +```ts +it('maps the enriched audio pipeline response into subtitle objects', async () => { + const payload = { + subtitles: [ + { + id: 'sub_1', + startTime: 1, + endTime: 2, + originalText: 'Hello', + translatedText: '你好', + speaker: 'Speaker 1', + speakerId: 'spk_0', + words: [{ text: 'Hello', startTime: 1, endTime: 2, speakerId: 'spk_0', confidence: 0.9 }], + confidence: 0.9, + }, + ], + speakers: [{ speakerId: 'spk_0', label: 'Speaker 1' }], + quality: 'full', + }; + + expect(mapPipelineResponse(payload).subtitles[0].words).toHaveLength(1); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm test -- --run src/services/geminiService.test.ts` +Expected: FAIL because the mapping helper does not exist. + +**Step 3: Write minimal implementation** + +1. Add a response-mapping helper in `src/services/geminiService.ts`. +2. Preserve the existing fallback path. +3. Carry `quality` metadata to the UI. + +```ts +const quality = data.quality ?? 'fallback'; +const subtitles = (data.subtitles ?? []).map(mapSubtitleFromApi); +``` + +**Step 4: Run test to verify it passes** + +Run: `npm test -- --run src/services/geminiService.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add server.ts src/services/geminiService.ts src/services/geminiService.test.ts +git commit -m "feat: return enriched subtitle pipeline payloads" +``` + +### Task 8: Add Precision Metadata to Editor State + +**Files:** +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\components\EditorScreen.tsx` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\components\EditorScreen.test.tsx` + +**Step 1: Write the failing test** + +Add a test for rendering a fallback warning when `quality` is low. + +```tsx +it('shows a low-precision notice for fallback subtitle results', () => { + render(); + expect(screen.getByText(/low-precision/i)).toBeInTheDocument(); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm test -- --run src/components/EditorScreen.test.tsx` +Expected: FAIL because the component does not track pipeline quality yet. + +**Step 3: Write minimal implementation** + +1. Add state for `quality` and `speakers`. +2. Surface a small status badge or warning banner. +3. Keep the existing sentence list and timeline intact. + +```tsx +{quality === 'fallback' && ( +

Low-precision timing detected. Manual review recommended.

+)} +``` + +**Step 4: Run test to verify it passes** + +Run: `npm test -- --run src/components/EditorScreen.test.tsx` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/components/EditorScreen.tsx src/components/EditorScreen.test.tsx +git commit -m "feat: surface subtitle precision status in editor" +``` + +### Task 9: Add Word-Level Playback Helpers + +**Files:** +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\lib\playback\wordHighlight.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\lib\playback\wordHighlight.test.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\components\EditorScreen.tsx` + +**Step 1: Write the failing test** + +Test the active-word lookup helper. + +```ts +it('returns the active word for the current playback time', () => { + const activeWord = getActiveWord([ + { text: 'Hello', startTime: 1, endTime: 1.3, speakerId: 'spk_0', confidence: 0.9 }, + ], 1.1); + + expect(activeWord?.text).toBe('Hello'); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm test -- --run src/lib/playback/wordHighlight.test.ts` +Expected: FAIL because playback helpers do not exist. + +**Step 3: Write minimal implementation** + +1. Create a pure helper for active-word lookup. +2. Use it in `EditorScreen.tsx` to render highlighted word spans when `words` are present. + +```ts +export const getActiveWord = (words: WordTiming[], currentTime: number) => + words.find((word) => currentTime >= word.startTime && currentTime <= word.endTime); +``` + +**Step 4: Run test to verify it passes** + +Run: `npm test -- --run src/lib/playback/wordHighlight.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/lib/playback/wordHighlight.ts src/lib/playback/wordHighlight.test.ts src/components/EditorScreen.tsx +git commit -m "feat: add word-level playback highlighting" +``` + +### Task 10: Snap Timeline Edges to Word Boundaries + +**Files:** +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\lib\timeline\snapToWords.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\lib\timeline\snapToWords.test.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\components\EditorScreen.tsx` + +**Step 1: Write the failing test** + +Test snapping to nearest word edges. + +```ts +it('snaps a dragged start edge to the nearest word boundary', () => { + const next = snapTimeToNearestWordBoundary( + 1.34, + [ + { text: 'Hello', startTime: 1.0, endTime: 1.3, speakerId: 'spk_0', confidence: 0.9 }, + { text: 'world', startTime: 1.35, endTime: 1.8, speakerId: 'spk_0', confidence: 0.9 }, + ], + ); + + expect(next).toBe(1.35); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm test -- --run src/lib/timeline/snapToWords.test.ts` +Expected: FAIL because no snapping helper exists. + +**Step 3: Write minimal implementation** + +1. Add a pure snapping helper with a small tolerance window. +2. Use it in the left and right resize timeline handlers. + +```ts +export const snapTimeToNearestWordBoundary = (time: number, words: WordTiming[]) => { + // choose nearest start or end boundary within tolerance +}; +``` + +**Step 4: Run test to verify it passes** + +Run: `npm test -- --run src/lib/timeline/snapToWords.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/lib/timeline/snapToWords.ts src/lib/timeline/snapToWords.test.ts src/components/EditorScreen.tsx +git commit -m "feat: snap subtitle edits to word boundaries" +``` + +### Task 11: Add Speaker-Aware UI State + +**Files:** +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\components\EditorScreen.tsx` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\voices.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\lib\speakers\speakerPresentation.ts` +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\lib\speakers\speakerPresentation.test.ts` + +**Step 1: Write the failing test** + +Test stable color and label generation for speaker tracks. + +```ts +it('creates stable display metadata for each speaker id', () => { + const speaker = buildSpeakerPresentation({ speakerId: 'spk_0', label: 'Speaker 1' }); + expect(speaker.color).toMatch(/^#/); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `npm test -- --run src/lib/speakers/speakerPresentation.test.ts` +Expected: FAIL because no speaker presentation helper exists. + +**Step 3: Write minimal implementation** + +1. Create a helper that derives display color and fallback label from `speakerId`. +2. Use it to color sentence chips or timeline items. +3. Keep voice assignment behavior backward compatible. + +```ts +export const buildSpeakerPresentation = ({ speakerId, label }: SpeakerTrack) => ({ + speakerId, + label, + color: '#1677ff', +}); +``` + +**Step 4: Run test to verify it passes** + +Run: `npm test -- --run src/lib/speakers/speakerPresentation.test.ts` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add src/components/EditorScreen.tsx src/voices.ts src/lib/speakers/speakerPresentation.ts src/lib/speakers/speakerPresentation.test.ts +git commit -m "feat: add speaker-aware editor presentation" +``` + +### Task 12: Verify End-to-End Behavior and Update Docs + +**Files:** +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\README.md` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\docs\plans\2026-03-17-precise-dialogue-localization-design.md` + +**Step 1: Write the failing test** + +Write down the manual verification checklist before changing docs so the release criteria are explicit. + +```md +- [ ] Single-speaker clip returns `quality: full` +- [ ] Two-speaker clip shows distinct speaker IDs +- [ ] Fallback path shows low-precision notice +- [ ] Timeline resize snaps to word boundaries +``` + +**Step 2: Run test to verify it fails** + +Run: `npm run lint` +Expected: PASS or FAIL depending on in-progress code, but manual verification is still incomplete until the checklist is executed. + +**Step 3: Write minimal implementation** + +1. Update `README.md` with new environment requirements and pipeline description. +2. Record the manual verification results in the design document or a linked note. + +```md +## High-Precision Subtitle Mode + +Set the alignment backend environment variables before running the app. +``` + +**Step 4: Run test to verify it passes** + +Run: `npm test -- --run` +Expected: PASS. + +Run: `npm run lint` +Expected: PASS. + +Run: `npm run build` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add README.md docs/plans/2026-03-17-precise-dialogue-localization-design.md +git commit -m "docs: document precise dialogue localization workflow" +``` + +## Notes for Execution + +1. This workspace currently has no `.git` directory, so commit steps cannot be executed until the project is placed in a real Git checkout. +2. Introduce the alignment backend behind environment-based configuration so existing demos can still use the current fallback path. +3. Prefer pure functions for sentence reconstruction, speaker assignment, snapping, and word-highlighting logic so they remain easy to test. diff --git a/docs/plans/2026-03-18-alignment-fallback-safety.md b/docs/plans/2026-03-18-alignment-fallback-safety.md new file mode 100644 index 0000000..794eafe --- /dev/null +++ b/docs/plans/2026-03-18-alignment-fallback-safety.md @@ -0,0 +1,75 @@ +# Alignment Fallback Safety Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Stop subtitle generation from silently falling back into the Studio project workflow unless an explicit environment flag enables it. + +**Architecture:** Keep the existing alignment adapter and Studio fallback code path, but gate that fallback behind a parsed boolean config from `.env`. When the alignment endpoint returns `404` or `405` and the flag is not enabled, fail fast with a clear error instead of returning unrelated subtitles. + +**Tech Stack:** TypeScript, Vitest, Express, Node fetch/FormData APIs + +--- + +### Task 1: Add failing tests for safe-by-default fallback behavior + +**Files:** +- Modify: `E:/Downloads/ai-video-dubbing-&-translation/src/server/alignmentAdapter.test.ts` +- Modify: `E:/Downloads/ai-video-dubbing-&-translation/src/server/audioPipelineConfig.test.ts` +- Modify: `E:/Downloads/ai-video-dubbing-&-translation/src/server/subtitleGeneration.test.ts` + +**Step 1: Write the failing tests** + +- Add a test asserting `requestAlignedTranscript()` throws a clear error on `404` when Studio fallback is not explicitly enabled. +- Update the existing fallback test to pass only when `allowStudioProjectFallback` is set to `true`. +- Add config tests for parsing `ALLOW_STUDIO_PROJECT_FALLBACK` with a default of `false`. +- Add a pipeline test asserting `generateSubtitlePipeline()` forwards the parsed fallback flag into `requestAlignedTranscript()`. + +**Step 2: Run targeted tests to verify they fail** + +Run: `node ./node_modules/vitest/vitest.mjs run src/server/alignmentAdapter.test.ts src/server/audioPipelineConfig.test.ts src/server/subtitleGeneration.test.ts` + +Expected: FAIL because the fallback flag does not exist yet and `requestAlignedTranscript()` still auto-falls back. + +### Task 2: Implement the minimal fallback gate + +**Files:** +- Modify: `E:/Downloads/ai-video-dubbing-&-translation/src/server/audioPipelineConfig.ts` +- Modify: `E:/Downloads/ai-video-dubbing-&-translation/src/server/alignmentAdapter.ts` +- Modify: `E:/Downloads/ai-video-dubbing-&-translation/src/server/subtitleGeneration.ts` + +**Step 1: Add config parsing** + +- Extend `AudioPipelineConfig` with `allowStudioProjectFallback: boolean`. +- Parse `ALLOW_STUDIO_PROJECT_FALLBACK` from `.env`, defaulting to `false`. + +**Step 2: Gate fallback execution** + +- Extend `RequestAlignedTranscriptOptions` with `allowStudioProjectFallback`. +- When the alignment root returns `404` or `405` and the flag is `false`, throw a clear error that points to `ALLOW_STUDIO_PROJECT_FALLBACK=true` or a proper alignment backend. +- When the flag is `true`, keep the current Studio project workflow unchanged. + +**Step 3: Thread config through the pipeline** + +- Pass `allowStudioProjectFallback` from `resolveAudioPipelineConfig()` into `requestAlignedTranscript()` inside `generateSubtitlePipeline()`. + +### Task 3: Update docs and verify + +**Files:** +- Modify: `E:/Downloads/ai-video-dubbing-&-translation/.env.example` +- Modify: `E:/Downloads/ai-video-dubbing-&-translation/README.md` + +**Step 1: Document the new flag** + +- Add `ALLOW_STUDIO_PROJECT_FALLBACK="false"` to `.env.example`. +- Clarify in `README.md` that subtitle generation now uses the local Studio workflow by default, and that fail-fast mode is opt-in. + +**Step 2: Run verification** + +Run: `node ./node_modules/vitest/vitest.mjs run src/server/alignmentAdapter.test.ts src/server/audioPipelineConfig.test.ts src/server/subtitleGeneration.test.ts` +Expected: PASS + +Run: `node ./node_modules/vitest/vitest.mjs run` +Expected: PASS + +Run: `cmd /c npm run lint` +Expected: PASS diff --git a/index.html b/index.html new file mode 100644 index 0000000..21dfe69 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + My Google AI Studio App + + +
+ + + + diff --git a/metadata.json b/metadata.json new file mode 100644 index 0000000..f0ab149 --- /dev/null +++ b/metadata.json @@ -0,0 +1,5 @@ +{ + "name": "AI Video Dubbing & Translation", + "description": "Professional AI video translation and dubbing tool with vocal separation and TTS (Version 1.0).", + "requestFramePermissions": [] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8cb71fa --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3742 @@ +{ + "name": "react-example", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "react-example", + "version": "1.0.0", + "dependencies": { + "@google-cloud/speech": "^7.3.0", + "@google/genai": "^1.45.0", + "@tailwindcss/vite": "^4.1.14", + "@vitejs/plugin-react": "^5.0.4", + "axios": "^1.13.6", + "cors": "^2.8.6", + "dotenv": "^17.3.1", + "express": "^4.22.1", + "ffmpeg-static": "^5.3.0", + "ffprobe-static": "^3.1.0", + "fluent-ffmpeg": "^2.1.3", + "lucide-react": "^0.546.0", + "motion": "^12.23.24", + "multer": "^2.1.1", + "openai": "^6.29.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^6.2.0" + }, + "devDependencies": { + "@types/cors": "^2.8.19", + "@types/express": "^4.17.25", + "@types/multer": "^2.1.0", + "@types/node": "^22.14.0", + "autoprefixer": "^10.4.21", + "tailwindcss": "^4.1.14", + "tsx": "^4.21.0", + "typescript": "~5.8.2", + "vite": "^6.2.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@derhuerst/http-basic": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/@derhuerst/http-basic/-/http-basic-8.2.4.tgz", + "integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==", + "license": "MIT", + "dependencies": { + "caseless": "^0.12.0", + "concat-stream": "^2.0.0", + "http-response-object": "^3.0.1", + "parse-cache-control": "^1.0.1" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/common": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/common/-/common-6.0.0.tgz", + "integrity": "sha512-IXh04DlkLMxWgYLIUYuHHKXKOUwPDzDgke1ykkkJPe48cGIS9kkL2U/o0pm4ankHLlvzLF/ma1eO86n/bkumIA==", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "^4.0.0", + "arrify": "^2.0.0", + "duplexify": "^4.1.3", + "extend": "^3.0.2", + "google-auth-library": "^10.0.0-rc.1", + "html-entities": "^2.5.2", + "retry-request": "^8.0.0", + "teeny-request": "^10.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.1.0.tgz", + "integrity": "sha512-G/FQx5cE/+DqBbOpA5jKsegGwdPniU6PuIEMt+qxWgFxvxuFOzVmp6zYchtYuwAWV5/8Dgs0yAmjvNZv3uXLQg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@google-cloud/speech": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@google-cloud/speech/-/speech-7.3.0.tgz", + "integrity": "sha512-8jwVB4szH3DKYeJa6fc5zDwGt41a06Rkbcbh+pcXw+zQigjHw6pkGu92XohciHhaudhvc7BCzKCZqCZd45k+Cg==", + "license": "Apache-2.0", + "dependencies": { + "@google-cloud/common": "^6.0.0", + "@types/pumpify": "^1.4.4", + "google-gax": "^5.0.0", + "pumpify": "^2.0.1", + "stream-events": "^1.0.5", + "uuid": "^11.1.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "1.45.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.45.0.tgz", + "integrity": "sha512-+sNRWhKiRibVgc4OKi7aBJJ0A7RcoVD8tGG+eFkqxAWRjASDW+ktS9lLwTDnAxZICzCVoeAdu8dYLJVTX60N9w==", + "license": "Apache-2.0", + "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/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "license": "BSD-3-Clause" + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "license": "MIT" + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/duplexify": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/@types/duplexify/-/duplexify-3.6.5.tgz", + "integrity": "sha512-fB56ACzlW91UdZ5F3VXplVMDngO8QaX5Y2mjvADtN01TT2TMy4WjF0Lg+tFDvt4uMBeTe4SgaD+qCrA7dL5/tA==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz", + "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "22.19.15", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/pumpify": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@types/pumpify/-/pumpify-1.4.5.tgz", + "integrity": "sha512-BGVAQyK5yJdfIII230fVYGY47V63hUNAhryuuS3b4lEN2LNwxUXFKsEf8QLDCjmZuimlj23BHppJgcrGvNtqKg==", + "license": "MIT", + "dependencies": { + "@types/duplexify": "*", + "@types/node": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.2.0", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/async": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", + "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.27", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001774", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.8", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001779", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/ffmpeg-static": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz", + "integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==", + "hasInstallScript": true, + "license": "GPL-3.0-or-later", + "dependencies": { + "@derhuerst/http-basic": "^8.2.0", + "env-paths": "^2.2.0", + "https-proxy-agent": "^5.0.0", + "progress": "^2.0.3" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/ffmpeg-static/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/ffmpeg-static/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/ffprobe-static": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/ffprobe-static/-/ffprobe-static-3.1.0.tgz", + "integrity": "sha512-Dvpa9uhVMOYivhHKWLGDoa512J751qN1WZAIO+Xw4L/mrUSPxS4DApzSUDbCFE/LUq2+xYnznEahTd63AqBSpA==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/fluent-ffmpeg": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/fluent-ffmpeg/-/fluent-ffmpeg-2.1.3.tgz", + "integrity": "sha512-Be3narBNt2s6bsaqP6Jzq91heDgOEaDCJAXcE3qcma/EJBSy5FB4cvO31XBInuAuKBx8Kptf8dkhjK0IOru39Q==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "async": "^0.2.9", + "which": "^1.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fluent-ffmpeg/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "12.36.0", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.36.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "7.1.3", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "devOptional": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.1", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "7.1.3", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-gax": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-5.0.6.tgz", + "integrity": "sha512-1kGbqVQBZPAAu4+/R1XxPQKP0ydbNYoLAr4l0ZO2bMV0kLyLW4I1gAk++qBLWt7DPORTzmWRMsCZe86gDjShJA==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.12.6", + "@grpc/proto-loader": "^0.8.0", + "duplexify": "^4.1.3", + "google-auth-library": "^10.1.0", + "google-logging-utils": "^1.1.1", + "node-fetch": "^3.3.2", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^3.0.0", + "protobufjs": "^7.5.3", + "retry-request": "^8.0.0", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/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/http-response-object": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/http-response-object/-/http-response-object-3.0.2.tgz", + "integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==", + "license": "MIT", + "dependencies": { + "@types/node": "^10.0.3" + } + }, + "node_modules/http-response-object/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", + "license": "MIT" + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.546.0", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/motion": { + "version": "12.36.0", + "license": "MIT", + "dependencies": { + "framer-motion": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.36.0", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openai": { + "version": "6.29.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.29.0.tgz", + "integrity": "sha512-YxoArl2BItucdO89/sN6edksV0x47WUTgkgVfCgX7EuEMhbirENsgYe5oO4LTjBL9PtdKtk2WqND1gSLcTd2yw==", + "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/p-retry": { + "version": "4.6.2", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "license": "BlueOak-1.0.0" + }, + "node_modules/parse-cache-control": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-cache-control/-/parse-cache-control-1.0.1.tgz", + "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/proto3-json-serializer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-3.0.4.tgz", + "integrity": "sha512-E1sbAYg3aEbXrq0n1ojJkRHQJGE1kaE/O6GLA94y8rnJBfgvOPTOd1b9hOceQK1FFZI9qMh1vBERCyO2ifubcw==", + "license": "Apache-2.0", + "dependencies": { + "protobufjs": "^7.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pumpify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-2.0.1.tgz", + "integrity": "sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw==", + "license": "MIT", + "dependencies": { + "duplexify": "^4.1.1", + "inherits": "^2.0.3", + "pump": "^3.0.0" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "19.2.4", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-8.0.2.tgz", + "integrity": "sha512-JzFPAfklk1kjR1w76f0QOIhoDkNkSqW8wYKT08n9yysTmZfB+RQ2QoXoTAeOi1HD9ZipTyTAZg3c4pM/jeqgSw==", + "license": "MIT", + "dependencies": { + "extend": "^3.0.2", + "teeny-request": "^10.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/teeny-request": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-10.1.0.tgz", + "integrity": "sha512-3ZnLvgWF29jikg1sAQ1g0o+lr5JX6sVgYvfUJazn7ZjJroDBUTWp44/+cFVX0bULjv4vci+rBD+oGVAkWqhUbw==", + "license": "Apache-2.0", + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^3.3.2", + "stream-events": "^1.0.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/teeny-request/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/teeny-request/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/tinyglobby": { + "version": "0.2.15", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "devOptional": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.8.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..17d0e63 --- /dev/null +++ b/package.json @@ -0,0 +1,49 @@ +{ + "name": "react-example", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "node ./node_modules/tsx/dist/cli.mjs server.ts", + "build": "node ./node_modules/vite/bin/vite.js build", + "preview": "node ./node_modules/vite/bin/vite.js preview", + "clean": "rm -rf dist", + "lint": "node ./node_modules/typescript/bin/tsc --noEmit", + "test": "node ./node_modules/vitest/vitest.mjs run" + }, + "dependencies": { + "@google-cloud/speech": "^7.3.0", + "@google/genai": "^1.45.0", + "@tailwindcss/vite": "^4.1.14", + "@vitejs/plugin-react": "^5.0.4", + "axios": "^1.13.6", + "cors": "^2.8.6", + "dotenv": "^17.3.1", + "express": "^4.22.1", + "ffmpeg-static": "^5.3.0", + "ffprobe-static": "^3.1.0", + "fluent-ffmpeg": "^2.1.3", + "lucide-react": "^0.546.0", + "motion": "^12.23.24", + "multer": "^2.1.1", + "openai": "^6.29.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "vite": "^6.2.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/cors": "^2.8.19", + "@types/express": "^4.17.25", + "@types/multer": "^2.1.0", + "@types/node": "^22.14.0", + "autoprefixer": "^10.4.21", + "jsdom": "^29.0.0", + "tailwindcss": "^4.1.14", + "tsx": "^4.21.0", + "typescript": "~5.8.2", + "vite": "^6.2.0", + "vitest": "^4.1.0" + } +} diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..9307f74 --- /dev/null +++ b/server.ts @@ -0,0 +1,415 @@ +import express from 'express'; +import cors from 'cors'; +import dotenv from 'dotenv'; +import { createServer as createViteServer } from 'vite'; +import path from 'path'; +import fs from 'fs'; +import ffmpeg from 'fluent-ffmpeg'; +import ffmpegInstaller from 'ffmpeg-static'; +import ffprobeInstaller from 'ffprobe-static'; +import axios from 'axios'; +import multer from 'multer'; +import { + createMiniMaxTtsUrl, + getMiniMaxTtsHttpStatus, + resolveMiniMaxTtsConfig, +} from './src/server/minimaxTts'; +import { generateSubtitlePipeline } from './src/server/subtitleGeneration'; +import { parseSubtitleRequest } from './src/server/subtitleRequest'; +import { + buildAssSubtitleContent, + buildExportAudioPlan, + DEFAULT_EXPORT_TEXT_STYLES, + shiftSubtitlesToExportTimeline, +} from './src/server/exportVideo'; +import { TextStyles } from './src/types'; + +const upload = multer({ + dest: 'uploads/', + limits: { + fileSize: 1024 * 1024 * 1024, // 1GB file limit + fieldSize: 1024 * 1024 * 500 // 500MB field limit for base64 strings + } +}); + +if (!fs.existsSync('uploads')) { + fs.mkdirSync('uploads'); +} + +if (ffmpegInstaller) { + ffmpeg.setFfmpegPath(ffmpegInstaller); +} +if (ffprobeInstaller.path) { + ffmpeg.setFfprobePath(ffprobeInstaller.path); +} + +dotenv.config(); + +async function startServer() { + const app = express(); + const PORT = 3000; + + app.use(cors()); + app.use(express.json({ limit: '500mb' })); + app.use(express.urlencoded({ limit: '500mb', extended: true })); + + // MiniMax TTS Endpoint + app.post('/api/tts', async (req, res) => { + try { + const { text, voiceId } = req.body; + if (!text) return res.status(400).json({ error: 'No text provided' }); + + const { apiHost, apiKey } = resolveMiniMaxTtsConfig(process.env); + + const response = await axios.post( + createMiniMaxTtsUrl(apiHost), + { + model: "speech-2.8-hd", + text: text, + stream: false, + output_format: "hex", + voice_setting: { + voice_id: voiceId || 'male-qn-qingse', + speed: 1.0, + vol: 1.0, + pitch: 0 + }, + audio_setting: { + sample_rate: 32000, + bitrate: 128000, + format: "mp3", + channel: 1, + } + }, + { + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json' + } + } + ); + + if (response.data?.base_resp?.status_code !== 0) { + console.error('MiniMax API Error:', response.data?.base_resp); + return res + .status(getMiniMaxTtsHttpStatus(response.data?.base_resp)) + .json({ error: response.data?.base_resp?.status_msg || 'MiniMax TTS failed' }); + } + + const hexAudio = response.data.data.audio; + const audioBuffer = Buffer.from(hexAudio, 'hex'); + const audioBase64 = audioBuffer.toString('base64'); + res.json({ audio: audioBase64 }); + } catch (error: any) { + if (error instanceof Error && error.message.includes('MINIMAX_API_KEY')) { + console.error('TTS Config Error:', error.message); + return res.status(400).json({ error: error.message }); + } + + console.error('TTS Error:', error.response?.data || error.message); + res + .status(getMiniMaxTtsHttpStatus(error.response?.data?.base_resp)) + .json({ error: error.response?.data?.base_resp?.status_msg || error.message || 'Failed to generate TTS' }); + } + }); + + // Vocal Separation Endpoint + app.post('/api/separate-vocal', upload.single('video'), async (req, res) => { + const videoPath = req.file?.path; + const timestamp = Date.now(); + const instrumentalPath = path.join(process.cwd(), `temp_instrumental_${timestamp}.mp3`); + + try { + if (!videoPath) return res.status(400).json({ error: 'No video file provided' }); + + // Simple vocal reduction using FFmpeg (Center-panned vocal removal trick) + // This is a basic fallback as true AI separation requires specialized models. + await new Promise((resolve, reject) => { + ffmpeg(videoPath) + .noVideo() + .audioFilters('pan=stereo|c0=c0-c1|c1=c1-c0') // Basic vocal reduction + .format('mp3') + .on('end', resolve) + .on('error', reject) + .save(instrumentalPath); + }); + + const instrumentalBuffer = fs.readFileSync(instrumentalPath); + const instrumentalBase64 = instrumentalBuffer.toString('base64'); + + // Cleanup + if (fs.existsSync(instrumentalPath)) fs.unlinkSync(instrumentalPath); + if (fs.existsSync(videoPath)) fs.unlinkSync(videoPath); + + res.json({ instrumental: instrumentalBase64 }); + } catch (error: any) { + console.error('Vocal Separation Error:', error); + res.status(500).json({ error: error.message || 'Failed to separate vocals' }); + } finally { + // Cleanup + if (instrumentalPath && fs.existsSync(instrumentalPath)) fs.unlinkSync(instrumentalPath); + if (videoPath && fs.existsSync(videoPath)) fs.unlinkSync(videoPath); + } + }); + + app.post('/api/process-audio-pipeline', upload.single('video'), async (req, res) => { + const videoPath = req.file?.path; + const timestamp = Date.now(); + const audioPath = path.join(process.cwd(), `temp_audio_${timestamp}.wav`); + + try { + if (!videoPath) return res.status(400).json({ error: 'No video file provided' }); + + // 1. Extract Audio (16kHz, Mono, WAV) + await new Promise((resolve, reject) => { + ffmpeg(videoPath) + .noVideo() + .audioFrequency(16000) + .audioChannels(1) + .format('wav') + .on('end', resolve) + .on('error', reject) + .save(audioPath); + }); + + const audioFile = fs.readFileSync(audioPath); + const audioBase64 = audioFile.toString('base64'); + + // Cleanup + if (fs.existsSync(audioPath)) fs.unlinkSync(audioPath); + if (fs.existsSync(videoPath)) fs.unlinkSync(videoPath); + + res.json({ audioBase64 }); + } catch (error: any) { + console.error('Audio Extraction Error:', error); + res.status(500).json({ error: error.message || 'Failed to extract audio' }); + } finally { + // Cleanup + if (audioPath && fs.existsSync(audioPath)) fs.unlinkSync(audioPath); + if (videoPath && fs.existsSync(videoPath)) fs.unlinkSync(videoPath); + } + }); + + app.post('/api/generate-subtitles', upload.single('video'), async (req, res) => { + const videoPath = req.file?.path; + + try { + if (!videoPath) { + return res.status(400).json({ error: 'No video file provided' }); + } + + const { provider, targetLanguage } = parseSubtitleRequest(req.body); + + const result = await generateSubtitlePipeline({ + videoPath, + provider, + targetLanguage, + env: process.env, + }); + + res.json({ + ...result, + provider, + }); + } catch (error: any) { + const message = error instanceof Error ? error.message : 'Failed to generate subtitles'; + const lowerMessage = message.toLowerCase(); + const status = + lowerMessage.includes('target language') || + lowerMessage.includes('unsupported llm provider') || + lowerMessage.includes('_api_key is required') || + lowerMessage.includes('studio project fallback is disabled') + ? 400 + : lowerMessage.includes('unauthorized') || + lowerMessage.includes('authentication') || + lowerMessage.includes('auth fail') || + lowerMessage.includes('status 401') + ? 401 + : 502; + + console.error('Subtitle Generation Error:', error); + res.status(status).json({ error: message }); + } finally { + if (videoPath && fs.existsSync(videoPath)) fs.unlinkSync(videoPath); + } + }); + + app.post('/api/export-video', upload.single('video'), async (req, res) => { + const tempFiles: string[] = []; + try { + const { subtitles: subtitlesStr, bgmBase64, trimRange: trimRangeStr, textStyles: textStylesStr } = req.body; + const videoFile = req.file; + if (!videoFile) return res.status(400).json({ error: 'No video file provided' }); + + const subtitles = subtitlesStr ? JSON.parse(subtitlesStr) : []; + const trimRange = trimRangeStr ? JSON.parse(trimRangeStr) : null; + const textStyles: TextStyles = textStylesStr + ? { ...DEFAULT_EXPORT_TEXT_STYLES, ...JSON.parse(textStylesStr) } + : DEFAULT_EXPORT_TEXT_STYLES; + + const timestamp = Date.now(); + const inputPath = videoFile.path; + const outputPath = path.join(process.cwd(), `output_${timestamp}.mp4`); + const subtitlePath = path.join(process.cwd(), `subs_${timestamp}.ass`); + + tempFiles.push(subtitlePath, outputPath, inputPath); + + // 2. Prepare Audio Filters + const probeData: any = await new Promise((resolve, reject) => { + ffmpeg.ffprobe(inputPath, (err, metadata) => { + if (err) reject(err); + else resolve(metadata); + }); + }); + const hasAudio = probeData.streams.some((s: any) => s.codec_type === 'audio'); + const videoStream = probeData.streams.find((s: any) => s.codec_type === 'video'); + const videoWidth = videoStream?.width || 1080; + const videoHeight = videoStream?.height || 1920; + const exportSubtitles = shiftSubtitlesToExportTimeline(subtitles || [], trimRange); + + const hasSubtitles = exportSubtitles.length > 0; + if (hasSubtitles) { + const assContent = buildAssSubtitleContent({ + subtitles: exportSubtitles, + textStyles, + videoWidth, + videoHeight, + }); + fs.writeFileSync(subtitlePath, assContent); + } + + let command = ffmpeg(inputPath); + const filterComplexParts: string[] = []; + const audioMixInputs: string[] = []; + let inputIndex = 1; + const audioPlan = buildExportAudioPlan({ + hasSourceAudio: hasAudio, + hasBgm: Boolean(bgmBase64), + subtitles: exportSubtitles, + }); + + if (bgmBase64) { + const bgmPath = path.join(process.cwd(), `bgm_${timestamp}.mp3`); + fs.writeFileSync(bgmPath, Buffer.from(bgmBase64, 'base64')); + command = command.input(bgmPath); + tempFiles.push(bgmPath); + filterComplexParts.push(`[${inputIndex}:a]volume=${audioPlan.bgmVolume ?? 0.5}[bgm]`); + audioMixInputs.push('[bgm]'); + inputIndex++; + } + + if (audioPlan.includeSourceAudio) { + filterComplexParts.push(`[0:a]volume=${audioPlan.sourceAudioVolume ?? 0.3}[sourcea]`); + audioMixInputs.push('[sourcea]'); + } + + for (let i = 0; i < audioPlan.ttsTracks.length; i++) { + const track = audioPlan.ttsTracks[i]; + if (track.audioUrl) { + const base64Data = track.audioUrl.split(',')[1]; + const isWav = track.audioUrl.includes('audio/wav'); + const ext = isWav ? 'wav' : 'mp3'; + const ttsPath = path.join(process.cwd(), `tts_${timestamp}_${i}.${ext}`); + fs.writeFileSync(ttsPath, Buffer.from(base64Data, 'base64')); + command = command.input(ttsPath); + tempFiles.push(ttsPath); + + filterComplexParts.push( + `[${inputIndex}:a]volume=${track.volume},adelay=${track.delayMs}|${track.delayMs}[tts${i}]`, + ); + audioMixInputs.push(`[tts${i}]`); + inputIndex++; + } + } + + const escapedSubtitlePath = subtitlePath.replace(/\\/g, '/').replace(/:/g, '\\:'); + if (hasSubtitles) { + filterComplexParts.push(`[0:v]subtitles='${escapedSubtitlePath}'[vout]`); + } + + let audioMap: string | null = null; + if (audioMixInputs.length > 1) { + filterComplexParts.push( + `${audioMixInputs.join('')}amix=inputs=${audioMixInputs.length}:duration=first:dropout_transition=2[aout]`, + ); + audioMap = '[aout]'; + } else if (audioMixInputs.length === 1) { + audioMap = audioMixInputs[0]; + } + + if (filterComplexParts.length > 0) { + command = command.complexFilter(filterComplexParts); + } + + const outputMaps = [`-map ${hasSubtitles ? '[vout]' : '0:v'}`]; + if (audioMap) { + outputMaps.push(`-map ${audioMap}`); + } + command = command.outputOptions(outputMaps); + + if (trimRange) { + command = command.outputOptions([ + `-ss ${trimRange.start}`, + `-t ${trimRange.end - trimRange.start}` + ]); + } + + await new Promise((resolve, reject) => { + command + .output(outputPath) + .on('end', resolve) + .on('error', (err, stdout, stderr) => { + console.error('FFmpeg export error:', err); + console.error('FFmpeg stderr:', stderr); + reject(new Error(`FFmpeg error: ${err.message}. Stderr: ${stderr}`)); + }) + .run(); + }); + + if (!fs.existsSync(outputPath)) { + throw new Error('FFmpeg finished but output file was not created'); + } + + const outputBuffer = fs.readFileSync(outputPath); + console.log(`Exported video size: ${outputBuffer.length} bytes`); + const outputBase64 = outputBuffer.toString('base64'); + const dataUrl = `data:video/mp4;base64,${outputBase64}`; + + res.json({ videoUrl: dataUrl }); + } catch (error: any) { + console.error('Export Error:', error); + res.status(500).json({ error: error.message || 'Failed to export video' }); + } finally { + // Cleanup + for (const file of tempFiles) { + if (fs.existsSync(file)) { + try { + fs.unlinkSync(file); + } catch (e) { + console.error(`Failed to delete temp file ${file}:`, e); + } + } + } + } + }); + + if (process.env.NODE_ENV !== 'production') { + const vite = await createViteServer({ + server: { middlewareMode: true }, + appType: 'spa', + }); + app.use(vite.middlewares); + } else { + const distPath = path.join(process.cwd(), 'dist'); + app.use(express.static(distPath)); + app.get('*', (req, res) => { + res.sendFile(path.join(distPath, 'index.html')); + }); + } + + app.listen(PORT, '0.0.0.0', () => { + console.log(`Server running on http://localhost:${PORT}`); + }); +} + +startServer(); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..5322b44 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,43 @@ +/** + * @license + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import UploadScreen from './components/UploadScreen'; +import EditorScreen from './components/EditorScreen'; + +function App() { + const [currentView, setCurrentView] = useState<'upload' | 'editor'>('upload'); + const [videoFile, setVideoFile] = useState(null); + const [targetLanguage, setTargetLanguage] = useState('en'); + const [trimRange, setTrimRange] = useState<{start: number, end: number} | null>(null); + + const handleVideoUpload = (file: File, lang: string, startTime?: number, endTime?: number) => { + setVideoFile(file); + setTargetLanguage(lang); + if (startTime !== undefined && endTime !== undefined) { + setTrimRange({ start: startTime, end: endTime }); + } else { + setTrimRange(null); + } + setCurrentView('editor'); + }; + + return ( +
+ {currentView === 'upload' ? ( + + ) : ( + setCurrentView('upload')} + /> + )} +
+ ); +} + +export default App; diff --git a/src/components/EditorScreen.test.tsx b/src/components/EditorScreen.test.tsx new file mode 100644 index 0000000..59bd180 --- /dev/null +++ b/src/components/EditorScreen.test.tsx @@ -0,0 +1,98 @@ +// @vitest-environment jsdom + +import React from 'react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import EditorScreen from './EditorScreen'; + +const { generateSubtitlePipelineMock, generateTTSMock } = vi.hoisted(() => ({ + generateSubtitlePipelineMock: vi.fn(), + generateTTSMock: vi.fn(), +})); + +vi.mock('../services/subtitleService', () => ({ + generateSubtitlePipeline: generateSubtitlePipelineMock, +})); + +vi.mock('../services/ttsService', () => ({ + generateTTS: generateTTSMock, +})); + +describe('EditorScreen', () => { + afterEach(() => { + cleanup(); + }); + + beforeEach(() => { + generateSubtitlePipelineMock.mockReset(); + generateSubtitlePipelineMock.mockResolvedValue({ + subtitles: [], + speakers: [], + quality: 'fallback', + }); + generateTTSMock.mockReset(); + + Object.defineProperty(URL, 'createObjectURL', { + writable: true, + value: vi.fn(() => 'blob:video'), + }); + Object.defineProperty(URL, 'revokeObjectURL', { + writable: true, + value: vi.fn(), + }); + + class ResizeObserverMock { + observe() {} + disconnect() {} + } + + vi.stubGlobal('ResizeObserver', ResizeObserverMock); + }); + + it('shows a low-precision notice for fallback subtitle results', async () => { + render( + {}} + />, + ); + + expect(screen.getAllByLabelText(/llm/i)[0]).toHaveValue('doubao'); + expect(await screen.findByText(/low-precision/i)).toBeInTheDocument(); + }); + + it('regenerates subtitles with the selected llm provider', async () => { + render( + {}} + />, + ); + + await waitFor(() => + expect(generateSubtitlePipelineMock).toHaveBeenCalledWith( + expect.any(File), + 'en', + 'doubao', + null, + ), + ); + + fireEvent.change(screen.getAllByLabelText(/llm/i)[0], { + target: { value: 'gemini' }, + }); + + await waitFor(() => + expect(generateSubtitlePipelineMock).toHaveBeenLastCalledWith( + expect.any(File), + 'en', + 'gemini', + null, + ), + ); + }); +}); diff --git a/src/components/EditorScreen.tsx b/src/components/EditorScreen.tsx new file mode 100644 index 0000000..c31c8d5 --- /dev/null +++ b/src/components/EditorScreen.tsx @@ -0,0 +1,946 @@ +import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import axios from 'axios'; +import { ChevronLeft, Play, Pause, Volume2, Settings, Download, Save, LayoutTemplate, Type, Image as ImageIcon, Music, Scissors, Plus, Trash2, Maximize2, Loader2 } from 'lucide-react'; +import VoiceMarketModal from './VoiceMarketModal'; +import ExportModal from './ExportModal'; +import { LlmProvider, PipelineQuality, Subtitle, TextStyles } from '../types'; +import { generateSubtitlePipeline } from '../services/subtitleService'; +import { generateTTS } from '../services/ttsService'; +import { MINIMAX_VOICES } from '../voices'; + +export default function EditorScreen({ videoFile, targetLanguage, trimRange, onBack }: { videoFile: File | null; targetLanguage: string; trimRange?: {start: number, end: number} | null; onBack: () => void }) { + const [subtitles, setSubtitles] = useState([]); + const [activeSubtitleId, setActiveSubtitleId] = useState(''); + const [showVoiceMarket, setShowVoiceMarket] = useState(false); + const [voiceMarketTargetId, setVoiceMarketTargetId] = useState(null); + const [showExportModal, setShowExportModal] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); + const [isDubbingGenerating, setIsDubbingGenerating] = useState(false); + const [generatingAudioIds, setGeneratingAudioIds] = useState>(new Set()); + const [generationError, setGenerationError] = useState(null); + const [subtitleQuality, setSubtitleQuality] = useState('fallback'); + const [llmProvider, setLlmProvider] = useState('doubao'); + + // Video Player State + const videoRef = useRef(null); + const audioRef = useRef(null); + const bgmRef = useRef(null); + const audioContextRef = useRef(null); + const lastPlayedSubId = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [bgmUrl, setBgmUrl] = useState(null); + const [bgmBase64, setBgmBase64] = useState(null); + const [isSeparating, setIsSeparating] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(30); // Default 30s, updated on load + + const [videoUrl, setVideoUrl] = useState(''); + const [videoAspectRatio, setVideoAspectRatio] = useState(16/9); + const containerRef = useRef(null); + const [renderedVideoWidth, setRenderedVideoWidth] = useState('100%'); + + // Timeline Dragging State + const [draggingId, setDraggingId] = useState(null); + const [dragType, setDragType] = useState<'move' | 'resize-left' | 'resize-right' | null>(null); + const [dragStartX, setDragStartX] = useState(0); + const [initialSubTimes, setInitialSubTimes] = useState<{startTime: number, endTime: number} | null>(null); + const [isDraggingPlayhead, setIsDraggingPlayhead] = useState(false); + const timelineTrackRef = useRef(null); + + useEffect(() => { + if (!containerRef.current) return; + const observer = new ResizeObserver((entries) => { + for (let entry of entries) { + const { width, height } = entry.contentRect; + const containerAspect = width / height; + if (containerAspect > videoAspectRatio) { + // Container is wider than video. Video height is 100%, width is scaled. + setRenderedVideoWidth(height * videoAspectRatio); + } else { + // Container is taller than video. Video width is 100%. + setRenderedVideoWidth(width); + } + } + }); + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, [videoAspectRatio]); + + useEffect(() => { + if (!videoFile) { + setVideoUrl(''); + return; + } + const url = URL.createObjectURL(videoFile); + setVideoUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + }, [videoFile]); + + const fetchSubtitles = useCallback(async () => { + if (!videoFile) return; + setIsGenerating(true); + setGenerationError(null); + try { + const pipelineResult = await generateSubtitlePipeline( + videoFile, + targetLanguage, + llmProvider, + trimRange, + ); + const generatedSubs = pipelineResult.subtitles; + setSubtitleQuality(pipelineResult.quality); + + let adjustedSubs = generatedSubs; + if (trimRange) { + adjustedSubs = generatedSubs + .filter(sub => sub.endTime > trimRange.start && sub.startTime < trimRange.end) + .map(sub => ({ + ...sub, + startTime: Math.max(0, sub.startTime - trimRange.start), + endTime: Math.min(trimRange.end - trimRange.start, sub.endTime - trimRange.start) + })); + } + + setSubtitles(adjustedSubs); + if (adjustedSubs.length > 0) { + setActiveSubtitleId(adjustedSubs[0].id); + } + } catch (err: any) { + console.error("Failed to generate subtitles:", err); + setSubtitleQuality('fallback'); + + let errorMessage = "Failed to generate subtitles. Please try again or check your API key."; + const errString = err instanceof Error ? err.message : JSON.stringify(err); + + if ( + errString.includes("429") || + errString.includes("quota") || + errString.includes("RESOURCE_EXHAUSTED") || + err?.status === 429 || + err?.error?.code === 429 + ) { + errorMessage = "You have exceeded your Volcengine API quota. Please check your plan and billing details."; + } else if (err instanceof Error) { + errorMessage = err.message; + } + + setGenerationError(errorMessage); + setSubtitles([]); + setActiveSubtitleId(''); + } finally { + setIsGenerating(false); + } + }, [videoFile, targetLanguage, trimRange, llmProvider]); + + // Generate subtitles on mount + useEffect(() => { + fetchSubtitles(); + }, [fetchSubtitles]); + + const [textStyles, setTextStyles] = useState({ + fontFamily: 'MiSans-Late', + fontSize: 24, + color: '#FFFFFF', + backgroundColor: 'transparent', + alignment: 'center', + isBold: false, + isItalic: false, + isUnderline: false, + }); + + const togglePlay = async () => { + if (videoRef.current) { + if (isPlaying) { + videoRef.current.pause(); + if (bgmRef.current) bgmRef.current.pause(); + if (audioRef.current) audioRef.current.pause(); + } else { + if (trimRange && (videoRef.current.currentTime < trimRange.start || videoRef.current.currentTime >= trimRange.end)) { + videoRef.current.currentTime = trimRange.start; + } + + videoRef.current.play(); + if (bgmRef.current) { + bgmRef.current.currentTime = videoRef.current.currentTime; + bgmRef.current.play(); + } + } + setIsPlaying(!isPlaying); + } + }; + + const handleGenerateDubbing = async () => { + if (subtitles.length === 0) return; + setIsDubbingGenerating(true); + + // Step 1: Vocal Separation (Scheme A: Server-side AI-like separation) + if (!bgmUrl && videoFile) { + setIsSeparating(true); + try { + const formData = new FormData(); + formData.append('video', videoFile); + + const response = await axios.post('/api/separate-vocal', formData, { + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + const { instrumental } = response.data; + + const blob = await (await fetch(`data:audio/mp3;base64,${instrumental}`)).blob(); + const url = URL.createObjectURL(blob); + setBgmUrl(url); + setBgmBase64(instrumental); + console.log("Vocal separation completed (Instrumental extracted via Scheme A)"); + } catch (err) { + console.error("Vocal separation failed:", err); + } finally { + setIsSeparating(false); + } + } + + // Step 2: TTS Generation + const toGenerate = subtitles.filter(s => !s.audioUrl).map(s => s.id); + setGeneratingAudioIds(new Set(toGenerate)); + + try { + const updatedSubtitles = [...subtitles]; + + for (let i = 0; i < updatedSubtitles.length; i++) { + const sub = updatedSubtitles[i]; + if (sub.audioUrl) continue; + + try { + const textToSpeak = sub.translatedText || sub.text; + const audioUrl = await generateTTS(textToSpeak, sub.voiceId); + updatedSubtitles[i] = { ...sub, audioUrl }; + // Update state incrementally so user sees progress + setSubtitles([...updatedSubtitles]); + } catch (err) { + console.error(`Failed to generate TTS for subtitle ${sub.id}:`, err); + } finally { + setGeneratingAudioIds(prev => { + const next = new Set(prev); + next.delete(sub.id); + return next; + }); + } + } + } catch (err) { + console.error("Failed to generate dubbing:", err); + } finally { + setIsDubbingGenerating(false); + setGeneratingAudioIds(new Set()); + if (subtitles.some(s => s.audioUrl)) { + console.log("Dubbing generation completed."); + } + } + }; + + const handleTimeUpdate = () => { + if (videoRef.current) { + let time = videoRef.current.currentTime; + + if (trimRange) { + if (time < trimRange.start) { + videoRef.current.currentTime = trimRange.start; + time = trimRange.start; + } else if (time >= trimRange.end) { + videoRef.current.pause(); + if (bgmRef.current) bgmRef.current.pause(); + if (audioRef.current) audioRef.current.pause(); + setIsPlaying(false); + videoRef.current.currentTime = trimRange.start; + time = trimRange.start; + } + } + + setCurrentTime(time); + + const displayTime = trimRange ? time - trimRange.start : time; + + // Auto-select active subtitle based on time + const activeSub = subtitles.find(s => displayTime >= s.startTime && displayTime <= s.endTime); + + // Sync BGM with video + if (bgmRef.current && videoRef.current && isPlaying) { + if (Math.abs(bgmRef.current.currentTime - videoRef.current.currentTime) > 0.3) { + bgmRef.current.currentTime = videoRef.current.currentTime; + } + } + + if (activeSub) { + if (activeSub.id !== activeSubtitleId) { + setActiveSubtitleId(activeSub.id); + } + + // Play dubbing if available and not already playing for this sub + if (activeSub.audioUrl && lastPlayedSubId.current !== activeSub.id && isPlaying) { + if (audioRef.current) { + audioRef.current.pause(); + } + + const audio = new Audio(activeSub.audioUrl); + audio.volume = activeSub.volume !== undefined ? activeSub.volume : 1.0; + audioRef.current = audio; + + audio.play().catch(e => console.error("Audio playback failed:", e)); + lastPlayedSubId.current = activeSub.id; + } + } else { + lastPlayedSubId.current = null; + } + } + }; + + useEffect(() => { + if (videoRef.current) { + videoRef.current.volume = bgmUrl ? 0 : 0.3; + } + }, [bgmUrl]); + + const handleLoadedMetadata = () => { + if (videoRef.current) { + setDuration(videoRef.current.duration); + videoRef.current.volume = bgmUrl ? 0 : 0.3; // Mute if BGM (instrumental) is present + if (bgmRef.current) { + bgmRef.current.volume = 0.5; // BGM volume + } + if (videoRef.current.videoHeight > 0) { + setVideoAspectRatio(videoRef.current.videoWidth / videoRef.current.videoHeight); + } + if (trimRange) { + videoRef.current.currentTime = trimRange.start; + setCurrentTime(trimRange.start); + } + } + }; + + const handleTimelineMouseDown = (e: React.MouseEvent, subId: string, type: 'move' | 'resize-left' | 'resize-right') => { + e.stopPropagation(); + const sub = subtitles.find(s => s.id === subId); + if (!sub) return; + + setDraggingId(subId); + setDragType(type); + setDragStartX(e.clientX); + setInitialSubTimes({ startTime: sub.startTime, endTime: sub.endTime }); + setActiveSubtitleId(subId); + }; + + const handleTimelineMouseMove = useCallback((e: MouseEvent) => { + if (!timelineTrackRef.current) return; + const rect = timelineTrackRef.current.getBoundingClientRect(); + const timelineWidth = rect.width - 32; // Subtract padding (1rem each side) + const displayDuration = trimRange ? trimRange.end - trimRange.start : duration; + + if (isDraggingPlayhead) { + const deltaX = e.clientX - rect.left - 16; // 1rem padding + const percent = Math.max(0, Math.min(1, deltaX / timelineWidth)); + const newTime = percent * displayDuration; + if (videoRef.current) { + videoRef.current.currentTime = newTime + (trimRange?.start || 0); + } + return; + } + + if (!draggingId || !initialSubTimes) return; + + const deltaX = e.clientX - dragStartX; + const deltaSeconds = (deltaX / timelineWidth) * displayDuration; + + setSubtitles(prev => prev.map(sub => { + if (sub.id !== draggingId) return sub; + + let newStart = sub.startTime; + let newEnd = sub.endTime; + + if (dragType === 'move') { + newStart = Math.max(0, Math.min(displayDuration - (initialSubTimes.endTime - initialSubTimes.startTime), initialSubTimes.startTime + deltaSeconds)); + newEnd = newStart + (initialSubTimes.endTime - initialSubTimes.startTime); + } else if (dragType === 'resize-left') { + newStart = Math.max(0, Math.min(initialSubTimes.endTime - 0.2, initialSubTimes.startTime + deltaSeconds)); + } else if (dragType === 'resize-right') { + newEnd = Math.max(initialSubTimes.startTime + 0.2, Math.min(displayDuration, initialSubTimes.endTime + deltaSeconds)); + } + + return { ...sub, startTime: newStart, endTime: newEnd }; + })); + }, [draggingId, dragType, dragStartX, initialSubTimes, duration, trimRange, isDraggingPlayhead]); + + const handleTimelineMouseUp = useCallback(() => { + setDraggingId(null); + setDragType(null); + setInitialSubTimes(null); + setIsDraggingPlayhead(false); + }, []); + + useEffect(() => { + if (draggingId || isDraggingPlayhead) { + window.addEventListener('mousemove', handleTimelineMouseMove); + window.addEventListener('mouseup', handleTimelineMouseUp); + } else { + window.removeEventListener('mousemove', handleTimelineMouseMove); + window.removeEventListener('mouseup', handleTimelineMouseUp); + } + return () => { + window.removeEventListener('mousemove', handleTimelineMouseMove); + window.removeEventListener('mouseup', handleTimelineMouseUp); + }; + }, [draggingId, isDraggingPlayhead, handleTimelineMouseMove, handleTimelineMouseUp]); + + const formatTime = (seconds: number) => { + if (isNaN(seconds) || seconds < 0) seconds = 0; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `00:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + }; + + const displayDuration = trimRange ? trimRange.end - trimRange.start : duration; + const displayCurrentTime = trimRange ? Math.max(0, currentTime - trimRange.start) : currentTime; + + // Calculate playhead position percentage + const playheadPercent = displayDuration > 0 ? (displayCurrentTime / displayDuration) * 100 : 0; + + // Get current subtitle text + const currentSubtitleText = subtitles.find(s => displayCurrentTime >= s.startTime && displayCurrentTime <= s.endTime)?.translatedText || ''; + + return ( +
+ {/* Top Header */} +
+
+ +
+
M
+ Translate 1.0 +
+
+ +
+ + +
+ + + +
+
+ + {/* Main Workspace */} +
+ {/* Left Sidebar - Subtitles */} +
+
+
+ + +
+

+ Tip: After modifying the subtitle text, you need to regenerate the dubbing to take effect! +

+
+ + +
+ +
+ +
+ {isGenerating ? ( +
+ +

AI is analyzing and translating...

+

This may take a minute depending on the video length.

+
+ ) : ( + <> + {generationError && ( +
+

{generationError}

+ +
+ )} + {!generationError && subtitleQuality === 'fallback' && ( +
+ Low-precision subtitle timing is active for this generation. You can still edit subtitles before dubbing. +
+ )} + {subtitles.map((sub, index) => ( +
{ + setActiveSubtitleId(sub.id); + if (videoRef.current) { + videoRef.current.currentTime = sub.startTime + (trimRange?.start || 0); + } + }} + > + {sub.audioUrl && ( +
+ +
+ )} + {generatingAudioIds.has(sub.id) && ( +
+ +
+ )} +
+ + {index + 1}. {formatTime(sub.startTime)} - {formatTime(sub.endTime)} + +
+ {sub.audioUrl && ( +
+ + { + const newSubs = [...subtitles]; + newSubs[index].volume = parseFloat(e.target.value); + setSubtitles(newSubs); + }} + className="w-12 h-1 bg-gray-300 rounded-lg appearance-none cursor-pointer accent-blue-500" + onClick={(e) => e.stopPropagation()} + /> +
+ )} + +
+
+