From 04072dc94bb274f7386e71956ea9aab36cba2bf9 Mon Sep 17 00:00:00 2001 From: Song367 <601337784@qq.com> Date: Thu, 19 Mar 2026 20:13:24 +0800 Subject: [PATCH] commit code --- .env | 1 + docs/plans/2026-03-19-bilingual-ui-design.md | 117 +++++ docs/plans/2026-03-19-bilingual-ui.md | 174 +++++++ .../2026-03-19-subtitle-jobs-async-design.md | 100 ++++ docs/plans/2026-03-19-subtitle-jobs-async.md | 47 ++ ...-03-19-subtitle-properties-panel-design.md | 142 ++++++ .../2026-03-19-subtitle-properties-panel.md | 177 +++++++ ...2026-03-19-tts-language-contract-design.md | 41 ++ .../plans/2026-03-19-tts-language-contract.md | 61 +++ ...6-03-19-upload-subtitle-defaults-design.md | 112 +++++ .../2026-03-19-upload-subtitle-defaults.md | 182 ++++++++ server.ts | 282 ++++++++++-- src/App.test.tsx | 26 ++ src/App.tsx | 61 ++- src/components/EditorScreen.test.tsx | 266 ++++++++++- src/components/EditorScreen.tsx | 434 ++++++++++++------ src/components/ExportModal.tsx | 30 +- src/components/TrimModal.tsx | 6 +- src/components/UploadScreen.test.tsx | 128 ++++++ src/components/UploadScreen.tsx | 201 ++++++-- src/components/VoiceMarketModal.tsx | 24 +- src/i18n.tsx | 304 ++++++++++++ src/lib/exportPayload.test.ts | 4 + src/server/doubaoTranslation.test.ts | 4 +- src/server/errorLogging.test.ts | 61 ++- src/server/errorLogging.ts | 84 ++++ src/server/exportVideo.test.ts | 2 + src/server/exportVideo.ts | 6 +- src/server/providerTranslation.test.ts | 2 +- src/server/subtitleGeneration.test.ts | 30 ++ src/server/subtitleGeneration.ts | 84 +++- src/server/subtitleJobs.test.ts | 59 +++ src/server/subtitleJobs.ts | 168 +++++++ src/server/subtitleRequest.test.ts | 16 + src/server/subtitleRequest.ts | 5 + src/server/videoSubtitleGeneration.test.ts | 73 ++- src/server/videoSubtitleGeneration.ts | 226 +++++++-- src/services/subtitleService.test.ts | 312 +++++++++++-- src/services/subtitleService.ts | 99 +++- src/types.ts | 36 ++ 40 files changed, 3783 insertions(+), 404 deletions(-) create mode 100644 docs/plans/2026-03-19-bilingual-ui-design.md create mode 100644 docs/plans/2026-03-19-bilingual-ui.md create mode 100644 docs/plans/2026-03-19-subtitle-jobs-async-design.md create mode 100644 docs/plans/2026-03-19-subtitle-jobs-async.md create mode 100644 docs/plans/2026-03-19-subtitle-properties-panel-design.md create mode 100644 docs/plans/2026-03-19-subtitle-properties-panel.md create mode 100644 docs/plans/2026-03-19-tts-language-contract-design.md create mode 100644 docs/plans/2026-03-19-tts-language-contract.md create mode 100644 docs/plans/2026-03-19-upload-subtitle-defaults-design.md create mode 100644 docs/plans/2026-03-19-upload-subtitle-defaults.md create mode 100644 src/App.test.tsx create mode 100644 src/components/UploadScreen.test.tsx create mode 100644 src/i18n.tsx create mode 100644 src/server/subtitleJobs.test.ts create mode 100644 src/server/subtitleJobs.ts diff --git a/.env b/.env index 8aff301..53bae20 100644 --- a/.env +++ b/.env @@ -3,5 +3,6 @@ ARK_API_KEY="e96194a9-8eda-4a90-a211-6db288045bdc" MINIMAX_API_KEY="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJHcm91cE5hbWUiOiLkuIrmtbfpopzpgJTnp5HmioDmnInpmZDlhazlj7giLCJVc2VyTmFtZSI6IuadqOmqpSIsIkFjY291bnQiOiIiLCJTdWJqZWN0SUQiOiIxNzI4NzEyMzI0OTc5NjI2ODM5IiwiUGhvbmUiOiIxMzM4MTU1OTYxOCIsIkdyb3VwSUQiOiIxNzI4NzEyMzI0OTcxMjM4MjMxIiwiUGFnZU5hbWUiOiIiLCJNYWlsIjoiIiwiQ3JlYXRlVGltZSI6IjIwMjUtMDYtMDYgMTU6MDU6NTUiLCJUb2tlblR5cGUiOjEsImlzcyI6Im1pbmltYXgifQ.aw1AUJnBYxXerJ4qNUaXM3DqPTd94WSVHWRiIpnjImhuCia3Ta1AyANTQTx__2CF5eByHOaHJFHhBCg6KgHUEaR6TiWFn0fWwXaU7XgnHwbvD4pNAmF_uYxMKbi-a6IyIGNyFdEMy22V5JEqfY4okAco5U96cnSOQZH7lyIBpvOsesjZU6L9q6Tf2jvlcnO9QG8GPg2DVpeL8Q3zLuYWezN4Wk6N-ISwQmZUwBYL3BhYamsFqCdSEyMd_uYQ_aQJa5tmlQqpimtALiutFshPUXB6VsvXEO6q-lCZ6Tg8QWwlFHkmEtUMQw4pWoX25d7Us06VFUhvV6pOzvM7yqCaWw" VITE_BASE_URL=/video_translate/ VITE_API_BASE_PATH=/video_translate/api +DOUBAO_MODEL="doubao-seed-2-0-pro-260215" DOUBAO_TIMEOUT_MS=900000 VITE_ARK_API_KEY="e96194a9-8eda-4a90-a211-6db288045bdc" diff --git a/docs/plans/2026-03-19-bilingual-ui-design.md b/docs/plans/2026-03-19-bilingual-ui-design.md new file mode 100644 index 0000000..a769d39 --- /dev/null +++ b/docs/plans/2026-03-19-bilingual-ui-design.md @@ -0,0 +1,117 @@ +# Bilingual UI Design + +**Goal:** Make the application UI available in both Chinese and English, defaulting to Chinese and allowing users to switch languages with a button. + +## Context + +The current application UI is written almost entirely in English across the upload screen, trim modal, editor, export modal, and voice market modal. The user wants the product to support both Chinese and English and to switch between them at runtime with a visible button. + +The requested behavior is UI-only internationalization. It should not change the underlying subtitle generation workflow, target-language request payloads, or model/provider behavior. + +## Approaches Considered + +### Option A: Prop-based translations + +Store the current locale in `App` and pass translated strings into every component via props. + +**Pros** +- Very explicit data flow +- No new React context + +**Cons** +- High prop churn across many components +- Harder to maintain as UI grows + +### Option B: Lightweight local i18n context + +Create a small translation module and a React context that exposes: + +- current locale +- locale setter +- translation lookup helper + +Components read strings through a shared hook. + +**Pros** +- Small implementation footprint +- Easy to scale across the existing component tree +- Avoids adding a third-party i18n library for a two-language UI + +**Cons** +- Requires a small amount of context plumbing + +### Recommendation + +Use **Option B**. It keeps the implementation simple while making future UI expansion much easier. + +## Architecture + +### Locale State + +`App` will own the current locale with these rules: + +- supported locales: `zh`, `en` +- default locale: `zh` + +`App` will wrap the UI in a small provider that exposes locale state and a `t(...)` helper. + +### Translation Dictionary + +Create a single front-end translation dictionary that covers current user-visible strings for: + +- upload screen +- trim modal +- editor +- export modal +- voice market modal + +The dictionary should use stable keys rather than raw string matching. + +### Language Switcher + +Add a compact switcher button group in the app shell so the user can toggle: + +- `中文` +- `English` + +The active locale should be visually highlighted. + +### Language List Rendering + +The upload screen's target-language options should display localized labels while still preserving a stable English request value for the backend. This avoids accidentally changing API behavior when the UI language changes. + +Each supported target language entry should therefore include: + +- code +- backend display name or canonical request name +- Chinese label +- English label + +## Scope + +### Included + +- Front-end UI copy +- Language switch button +- Localized supported-language labels in the upload screen +- Chinese default locale + +### Excluded + +- Backend message localization +- API error localization from third-party providers +- Browser-language auto-detection +- Persistence across refreshes + +## Testing Strategy + +Add tests that verify: + +- the app defaults to Chinese +- clicking the language switch updates visible UI labels to English +- upload-screen language options render localized names correctly +- existing component behavior continues to work while localized + +## Rollout Notes + +This design intentionally uses a local translation system instead of a third-party i18n package. That keeps the change fast, understandable, and easy to refactor later if the app grows into a larger multilingual product. diff --git a/docs/plans/2026-03-19-bilingual-ui.md b/docs/plans/2026-03-19-bilingual-ui.md new file mode 100644 index 0000000..0190661 --- /dev/null +++ b/docs/plans/2026-03-19-bilingual-ui.md @@ -0,0 +1,174 @@ +# Bilingual UI Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add Chinese and English UI support with Chinese as the default and a runtime switch button. + +**Architecture:** `App` owns locale state and provides it through a lightweight translation context. UI components read localized strings from a shared dictionary and switch instantly without changing backend-facing request values. + +**Tech Stack:** React, TypeScript, Vite, Vitest, Testing Library + +--- + +### Task 1: Add failing tests for app-level language switching + +**Files:** +- Create: `src/App.test.tsx` + +**Step 1: Write the failing test** + +Add tests that verify: + +- the upload screen defaults to Chinese copy +- clicking the English switch updates visible text to English + +**Step 2: Run test to verify it fails** + +Run: `npm run test -- src/App.test.tsx` +Expected: FAIL because no locale state or switcher exists yet + +**Step 3: Write minimal implementation** + +Do not implement here; continue to later tasks. + +**Step 4: Run test to verify it passes** + +Run after Tasks 2 through 4. + +**Step 5: Commit** + +```bash +git add src/App.test.tsx src/App.tsx src/i18n.ts src/components/*.tsx +git commit -m "test: cover bilingual UI switching" +``` + +### Task 2: Add translation infrastructure + +**Files:** +- Create: `src/i18n.tsx` +- Modify: `src/App.tsx` + +**Step 1: Write the failing test** + +Covered by Task 1. + +**Step 2: Run test to verify it fails** + +Run: `npm run test -- src/App.test.tsx` +Expected: FAIL due to missing locale provider and switch control + +**Step 3: Write minimal implementation** + +- Add locale type and translation dictionary +- Add a small React context and translation hook +- Store locale in `App` with `zh` as default +- Add a visible `中文 / English` switcher + +**Step 4: Run test to verify it passes** + +Run after localized components are updated. + +**Step 5: Commit** + +```bash +git add src/i18n.tsx src/App.tsx src/App.test.tsx +git commit -m "feat: add lightweight UI locale infrastructure" +``` + +### Task 3: Localize the upload flow + +**Files:** +- Modify: `src/components/UploadScreen.tsx` +- Modify: `src/components/TrimModal.tsx` +- Modify: `src/components/UploadScreen.test.tsx` + +**Step 1: Write the failing test** + +Extend or add tests only if needed beyond Task 1. + +**Step 2: Run test to verify it fails** + +Run: `npm run test -- src/App.test.tsx src/components/UploadScreen.test.tsx` +Expected: FAIL because upload copy and language labels remain English-only + +**Step 3: Write minimal implementation** + +- Localize upload-screen copy +- Localize target-language labels while preserving stable backend names +- Localize trim modal copy + +**Step 4: Run test to verify it passes** + +Run: `npm run test -- src/App.test.tsx src/components/UploadScreen.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/components/UploadScreen.tsx src/components/TrimModal.tsx src/components/UploadScreen.test.tsx src/i18n.tsx +git commit -m "feat: localize upload flow" +``` + +### Task 4: Localize editor and modal surfaces + +**Files:** +- Modify: `src/components/EditorScreen.tsx` +- Modify: `src/components/ExportModal.tsx` +- Modify: `src/components/VoiceMarketModal.tsx` +- Modify: `src/components/EditorScreen.test.tsx` + +**Step 1: Write the failing test** + +Reuse the app-level switch test where possible. + +**Step 2: Run test to verify it fails** + +Run: `npm run test -- src/App.test.tsx src/components/EditorScreen.test.tsx` +Expected: FAIL if editor or modal copy remains untranslated + +**Step 3: Write minimal implementation** + +- Localize editor copy and labels +- Localize export modal copy +- Localize voice market modal copy + +**Step 4: Run test to verify it passes** + +Run: `npm run test -- src/App.test.tsx src/components/EditorScreen.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/components/EditorScreen.tsx src/components/ExportModal.tsx src/components/VoiceMarketModal.tsx src/components/EditorScreen.test.tsx src/i18n.tsx +git commit -m "feat: localize editor and modal UI" +``` + +### Task 5: Verify targeted bilingual behavior + +**Files:** +- Modify only if verification exposes gaps + +**Step 1: Write the failing test** + +Only add one if targeted verification finds a missing translation path. + +**Step 2: Run test to verify it fails** + +Only if needed. + +**Step 3: Write minimal implementation** + +Apply the smallest follow-up fix needed. + +**Step 4: Run test to verify it passes** + +Run: `npm run test -- src/App.test.tsx src/components/UploadScreen.test.tsx src/components/EditorScreen.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/App.tsx src/i18n.tsx src/components/UploadScreen.tsx src/components/TrimModal.tsx src/components/EditorScreen.tsx src/components/ExportModal.tsx src/components/VoiceMarketModal.tsx +git commit -m "test: verify bilingual UI" +``` diff --git a/docs/plans/2026-03-19-subtitle-jobs-async-design.md b/docs/plans/2026-03-19-subtitle-jobs-async-design.md new file mode 100644 index 0000000..b9f76ce --- /dev/null +++ b/docs/plans/2026-03-19-subtitle-jobs-async-design.md @@ -0,0 +1,100 @@ +# Subtitle Jobs Async Design + +## Goal + +Replace the synchronous `/api/generate-subtitles` request with an async job flow so long-running subtitle generation can survive gateway timeouts and provide user-visible progress. + +## Chosen Approach + +Use a single-process in-memory job store on the backend and 5-second polling on the frontend. + +The backend will: + +- accept subtitle generation requests +- create a job with `queued` status +- return `202 Accepted` immediately with `jobId` +- continue subtitle generation in the background +- expose `GET /api/generate-subtitles/:jobId` for status and result lookup + +The frontend will: + +- submit the job +- poll every 5 seconds +- map backend `stage` values to a staged progress bar +- stop polling on `succeeded`, `failed`, or timeout + +## Job Model + +Each subtitle job stores: + +- `id` +- `requestId` +- `status` +- `stage` +- `progress` +- `message` +- `createdAt` +- `updatedAt` +- `provider` +- `targetLanguage` +- `fileId` +- `filePath` +- `error` +- `result` + +Statuses: + +- `queued` +- `running` +- `succeeded` +- `failed` + +Stages: + +- `queued` +- `upload_received` +- `preparing` +- `calling_provider` +- `processing_result` +- `succeeded` +- `failed` + +Jobs expire from memory after 1 hour. + +## Progress Strategy + +Progress is stage-based, not byte-accurate. This avoids fake precision while still keeping the user informed. + +Suggested mapping: + +- `queued`: 5 +- `upload_received`: 15 +- `preparing`: 30 +- `calling_provider`: 70 +- `processing_result`: 90 +- `succeeded`: 100 +- `failed`: keep last reported progress + +## Backend Flow + +1. Parse request and validate source input. +2. Create a job. +3. Return `202` with the new job state. +4. Run subtitle generation in a detached async task. +5. Update the job on each pipeline stage. +6. Store final result or error. +7. Clean up uploaded temp files after background completion. + +## Frontend Flow + +1. Doubao still uploads to Ark Files first and waits for file readiness. +2. Frontend posts to `/api/generate-subtitles`. +3. Frontend polls `/api/generate-subtitles/:jobId` every 5 seconds. +4. Progress UI updates from backend job status and stage. +5. Final result is normalized into `SubtitlePipelineResult`. + +## Risks + +- In-memory jobs are lost on restart. +- Single-instance memory state will not scale across multiple backend replicas. +- Upload time can still be slow, but the long model invocation is no longer tied to one HTTP response. diff --git a/docs/plans/2026-03-19-subtitle-jobs-async.md b/docs/plans/2026-03-19-subtitle-jobs-async.md new file mode 100644 index 0000000..01bde59 --- /dev/null +++ b/docs/plans/2026-03-19-subtitle-jobs-async.md @@ -0,0 +1,47 @@ +# Subtitle Jobs Async Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Convert subtitle generation to an async job flow with 5-second polling and a staged progress bar. + +**Architecture:** Add a backend in-memory subtitle job store, make `/api/generate-subtitles` return `202` immediately, expose a job lookup endpoint, and teach the frontend subtitle service and editor UI to poll and render stage-based progress. + +**Tech Stack:** Express, TypeScript, React, Vitest + +--- + +### Task 1: Add backend job store helpers + +**Files:** +- Create: `E:\Downloads\ai-video-dubbing-&-translation\src\server\subtitleJobs.ts` +- Test: `E:\Downloads\ai-video-dubbing-&-translation\src\server\subtitleJobs.test.ts` + +### Task 2: Convert subtitle route to async job submission + +**Files:** +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\server.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\server\subtitleGeneration.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\server\videoSubtitleGeneration.ts` + +### Task 3: Poll subtitle jobs from the frontend + +**Files:** +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\services\subtitleService.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\services\subtitleService.test.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\types.ts` + +### Task 4: Show staged progress in the editor UI + +**Files:** +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\components\EditorScreen.tsx` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\components\EditorScreen.test.tsx` + +### Task 5: Verify focused tests and typecheck + +**Files:** +- Test: `E:\Downloads\ai-video-dubbing-&-translation\src\server\subtitleJobs.test.ts` +- Test: `E:\Downloads\ai-video-dubbing-&-translation\src\services\subtitleService.test.ts` +- Test: `E:\Downloads\ai-video-dubbing-&-translation\src\components\EditorScreen.test.tsx` +- Test: `E:\Downloads\ai-video-dubbing-&-translation\src\server\subtitleGeneration.test.ts` +- Test: `E:\Downloads\ai-video-dubbing-&-translation\src\server\videoSubtitleGeneration.test.ts` + diff --git a/docs/plans/2026-03-19-subtitle-properties-panel-design.md b/docs/plans/2026-03-19-subtitle-properties-panel-design.md new file mode 100644 index 0000000..6ff4d7c --- /dev/null +++ b/docs/plans/2026-03-19-subtitle-properties-panel-design.md @@ -0,0 +1,142 @@ +# Subtitle Properties Panel Design + +**Goal:** Turn the editor's right sidebar into a real subtitle properties panel that edits the selected subtitle's display settings, shows real speaker metadata, and can optionally apply style changes to all subtitles. + +## Context + +The current editor already presents the right side as a properties area, but most of it is still static UI. The `Wife` and `Husband` labels are hard-coded, the text style controls only update a single top-level `textStyles` state object, and the preview overlay always renders with those shared settings instead of reading from the active subtitle. + +That makes the panel look like a subtitle control surface while behaving more like a mock. The user expectation is that this area should control subtitle presentation for the selected item and optionally propagate those style changes across the full subtitle list. + +## Approaches Considered + +### Option A: Keep a single global style object + +Continue storing one `textStyles` object in `EditorScreen` and wire the right panel to that object only. + +**Pros** +- Smallest code change +- Minimal test churn + +**Cons** +- Does not match the "current subtitle properties" mental model +- Makes "Apply to all subtitles" redundant +- Prevents subtitle-level overrides later + +### Option B: Store style state per subtitle with optional bulk apply + +Add a style object to each subtitle, derive the right panel from the active subtitle, and let bulk apply copy style changes to every subtitle. + +**Pros** +- Matches the current UI language and user expectation +- Keeps the implementation scoped to the existing editor screen +- Leaves room for richer per-speaker or per-preset styling later + +**Cons** +- Requires subtitle normalization when data is loaded +- Slightly more state update logic in the editor + +### Recommendation + +Use **Option B**. It gives the right sidebar a truthful interaction model without forcing a much larger style system redesign. + +## Architecture + +### Data Model + +Introduce a `SubtitleTextStyle` shape that extends the current style fields with the controls surfaced by the panel: + +- `fontFamily` +- `fontSize` +- `color` +- `strokeColor` +- `strokeWidth` +- `alignment` +- `isBold` +- `isItalic` +- `isUnderline` + +Each `Subtitle` will gain an optional `textStyle` field. When subtitle data is generated or loaded, the editor will normalize every subtitle to ensure a complete style object is present. + +### Editor State + +`EditorScreen` will: + +1. Keep a single source of truth in `subtitles` +2. Derive `activeSubtitle` from `activeSubtitleId` +3. Replace the old shared `textStyles` state with helper functions that update: + - only the active subtitle + - or every subtitle when "Apply to all subtitles" is enabled + +The checkbox will become real state instead of a static default value. + +### Right Sidebar Behavior + +The properties panel will become data-driven: + +1. Speaker badge/label will read from the active subtitle's speaker metadata +2. The text field will show the active subtitle's translated text as contextual reference instead of a hard-coded name +3. Controls will read and write the active subtitle's `textStyle` +4. The panel will show a friendly empty state if no subtitle is selected + +This keeps the right column focused on subtitle properties without introducing an unrelated metadata editor. + +### Preview Behavior + +The video subtitle overlay will render the style of the subtitle currently visible at the playback position. This means: + +- editing the selected subtitle updates the preview immediately when that subtitle is active +- bulk apply updates every visible subtitle style consistently + +Stroke rendering will be added via CSS `textShadow` so the preview reflects the new sidebar control. + +## Interaction Details + +### Size Presets + +Keep the existing "Normal / Large / Small" control, but wire it to concrete font sizes: + +- `Small` -> 20 +- `Normal` -> 24 +- `Large` -> 30 + +This matches the current UI affordance without adding a freeform number input yet. + +### Apply To All + +When checked, any style change initiated from the right panel copies the changed style keys to all subtitles. It will not overwrite subtitle text, timing, voice, or speaker data. + +### Speaker Information + +The top block will present the active subtitle's speaker identity: + +- badge uses the first letter of the speaker label +- label shows `subtitle.speaker` +- secondary read-only field shows the active subtitle text for context + +This keeps the section meaningful even if there is no separate speaker alias dataset yet. + +## Error Handling + +- If no subtitles are loaded, the right panel stays disabled with helper text +- If a subtitle lacks style data, normalization fills defaults before rendering controls +- If the active subtitle disappears after regeneration, the editor selects the first subtitle as it already does today + +## Testing Strategy + +### Component Tests + +Add tests that verify: + +- the right panel reflects the active subtitle's speaker and style values +- changing a style control updates only the selected subtitle when bulk apply is off +- changing a style control updates every subtitle when bulk apply is on +- the preview overlay renders the visible subtitle using its style values + +### Type Safety + +Update shared types so exports and preview rendering both receive the richer subtitle style data without unsafe casts. + +## Rollout Notes + +This design intentionally stays inside `EditorScreen` and current data structures. It does not add saved presets, per-speaker style inheritance, or backend persistence, which keeps the work focused on making the existing panel real and trustworthy. diff --git a/docs/plans/2026-03-19-subtitle-properties-panel.md b/docs/plans/2026-03-19-subtitle-properties-panel.md new file mode 100644 index 0000000..cce3442 --- /dev/null +++ b/docs/plans/2026-03-19-subtitle-properties-panel.md @@ -0,0 +1,177 @@ +# Subtitle Properties Panel Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make the editor's right sidebar behave like a real current-subtitle properties panel with optional bulk style application. + +**Architecture:** The implementation keeps `EditorScreen` as the orchestration layer while moving subtitle style data into each `Subtitle`. Subtitle data is normalized on load, the right sidebar binds to the active subtitle, and preview/export paths read from subtitle-level style settings instead of one shared top-level state object. + +**Tech Stack:** React, TypeScript, Vite, Vitest, Testing Library, Tailwind utility classes + +--- + +### Task 1: Extend subtitle types for per-subtitle styles + +**Files:** +- Modify: `src/types.ts` + +**Step 1: Write the failing test** + +Use existing component tests in the next task to drive type usage rather than adding a dedicated type-only test. + +**Step 2: Run test to verify it fails** + +Run: `npm run test -- src/components/EditorScreen.test.tsx` +Expected: FAIL after the next test task starts referencing `subtitle.textStyle` + +**Step 3: Write minimal implementation** + +- Add a `SubtitleTextStyle` interface with font family, size, color, stroke color, stroke width, alignment, and font decoration flags +- Add optional `textStyle?: SubtitleTextStyle` to `Subtitle` + +**Step 4: Run test to verify it passes** + +Run after Task 2 implementation. + +**Step 5: Commit** + +```bash +git add src/types.ts src/components/EditorScreen.test.tsx src/components/EditorScreen.tsx +git commit -m "feat: support per-subtitle style settings" +``` + +### Task 2: Add failing editor tests for the properties panel behavior + +**Files:** +- Modify: `src/components/EditorScreen.test.tsx` + +**Step 1: Write the failing test** + +Add tests that: + +- render subtitles with different speakers and styles +- verify the right panel shows the active speaker label +- toggle bold with bulk apply disabled and assert only one subtitle changes +- toggle bulk apply on, change a style, and assert all subtitles update + +**Step 2: Run test to verify it fails** + +Run: `npm run test -- src/components/EditorScreen.test.tsx` +Expected: FAIL because the current panel is static and does not update subtitle-level style data + +**Step 3: Write minimal implementation** + +Do not implement here; proceed to Tasks 3 and 4. + +**Step 4: Run test to verify it passes** + +Run after Tasks 3 and 4. + +**Step 5: Commit** + +```bash +git add src/components/EditorScreen.test.tsx src/components/EditorScreen.tsx src/types.ts +git commit -m "test: cover subtitle properties panel interactions" +``` + +### Task 3: Normalize subtitle style data and bind the sidebar to the active subtitle + +**Files:** +- Modify: `src/components/EditorScreen.tsx` + +**Step 1: Write the failing test** + +Covered by Task 2. + +**Step 2: Run test to verify it fails** + +Run: `npm run test -- src/components/EditorScreen.test.tsx` +Expected: FAIL with missing active-speaker and style-update assertions + +**Step 3: Write minimal implementation** + +- Add a default subtitle style constant and normalization helper +- Normalize generated subtitles before storing them in state +- Derive `activeSubtitle` and `activeSubtitleStyle` +- Replace hard-coded speaker/name fields with active subtitle data +- Add `applyToAllSubtitles` state +- Add helper functions that update subtitle styles per subtitle or across all subtitles + +**Step 4: Run test to verify it passes** + +Run: `npm run test -- src/components/EditorScreen.test.tsx` +Expected: PASS for sidebar interaction assertions + +**Step 5: Commit** + +```bash +git add src/components/EditorScreen.tsx src/components/EditorScreen.test.tsx src/types.ts +git commit -m "feat: wire subtitle properties panel to active subtitle" +``` + +### Task 4: Render subtitle preview with subtitle-level styles + +**Files:** +- Modify: `src/components/EditorScreen.tsx` + +**Step 1: Write the failing test** + +Covered by Task 2 by asserting the preview subtitle reflects changed style controls. + +**Step 2: Run test to verify it fails** + +Run: `npm run test -- src/components/EditorScreen.test.tsx` +Expected: FAIL because the overlay still reads the old shared style state + +**Step 3: Write minimal implementation** + +- Replace shared overlay style usage with the currently visible subtitle's style +- Map size presets to concrete font sizes +- Add stroke rendering through `textShadow` + +**Step 4: Run test to verify it passes** + +Run: `npm run test -- 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: preview subtitle-level style changes" +``` + +### Task 5: Verify targeted editor behavior + +**Files:** +- Modify: `src/components/EditorScreen.tsx` if any verification fixes are needed +- Modify: `src/components/EditorScreen.test.tsx` if any verification fixes are needed + +**Step 1: Write the failing test** + +No new test unless verification exposes a gap. + +**Step 2: Run test to verify it fails** + +Only if a regression is found during verification. + +**Step 3: Write minimal implementation** + +Apply only the smallest follow-up fixes needed to make the targeted suite green. + +**Step 4: Run test to verify it passes** + +Run: `npm run test -- src/components/EditorScreen.test.tsx` +Expected: PASS + +Optional broader check: + +Run: `npm run test -- src/components/EditorScreen.test.tsx src/services/subtitleService.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/components/EditorScreen.tsx src/components/EditorScreen.test.tsx src/types.ts +git commit -m "test: verify subtitle properties panel flow" +``` diff --git a/docs/plans/2026-03-19-tts-language-contract-design.md b/docs/plans/2026-03-19-tts-language-contract-design.md new file mode 100644 index 0000000..0fbb107 --- /dev/null +++ b/docs/plans/2026-03-19-tts-language-contract-design.md @@ -0,0 +1,41 @@ +# TTS Language Contract Design + +## Goal + +Upgrade subtitle generation so the model always produces English subtitle text for display, plus a separate TTS translation and language code for dubbing. + +## Scope + +- Keep current upload and editor UI unchanged for this step. +- Add backend contract support for `ttsText` and `ttsLanguage`. +- Let dubbing prefer `ttsText` over `translatedText`. +- Keep existing calls backward compatible by defaulting `ttsLanguage` to the current target language when none is provided. + +## Design + +### Prompt contract + +- `translatedText` becomes the on-screen subtitle text and must always be English. +- `ttsText` becomes the spoken dubbing text in the requested TTS language. +- `ttsLanguage` must be returned on every subtitle item and must exactly match the requested TTS language code. +- The system and user prompts should clearly separate subtitle language from TTS language. + +### Data model + +- Extend `Subtitle` with optional `ttsText` and `ttsLanguage`. +- Extend raw model subtitle parsing to accept these fields. +- Extend pipeline result metadata to track `ttsLanguage`. + +### Runtime behavior + +- Subtitle generation should accept an optional `ttsLanguage`. +- If not provided, use `targetLanguage` to avoid breaking existing flows. +- Voice catalog selection should use the TTS language, not the subtitle language. +- TTS generation should read `subtitle.ttsText` first, then fall back to `translatedText`, then `text`. + +### Testing + +- Add prompt tests asserting the new system and user prompt text references English subtitles plus TTS language. +- Add parsing tests asserting `ttsText` and `ttsLanguage` are normalized into subtitles. +- Add service tests asserting `ttsLanguage` is forwarded through the subtitle pipeline request body. + diff --git a/docs/plans/2026-03-19-tts-language-contract.md b/docs/plans/2026-03-19-tts-language-contract.md new file mode 100644 index 0000000..c014100 --- /dev/null +++ b/docs/plans/2026-03-19-tts-language-contract.md @@ -0,0 +1,61 @@ +# TTS Language Contract Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add `ttsText` and `ttsLanguage` to subtitle generation so English subtitles and TTS dubbing language are handled separately. + +**Architecture:** Keep the current subtitle pipeline shape, but thread an optional `ttsLanguage` through request parsing, prompt creation, and subtitle normalization. Dubbing should use `ttsText` when available so later UI work can choose a TTS language without reworking the playback layer. + +**Tech Stack:** TypeScript, Vitest, React client services, Node subtitle generation pipeline + +--- + +### Task 1: Lock the new prompt and parsing contract with tests + +**Files:** +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\server\videoSubtitleGeneration.test.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\services\subtitleService.test.ts` + +**Step 1: Write the failing tests** + +- Assert the user prompt mentions `Subtitle language: English` and `TTS language`. +- Assert the system prompt includes `ttsText` and `ttsLanguage`. +- Assert normalized subtitles preserve returned `ttsText` and `ttsLanguage`. +- Assert the client request forwards `ttsLanguage`. + +**Step 2: Run tests to verify they fail** + +Run: +`npm run test -- src/server/videoSubtitleGeneration.test.ts src/services/subtitleService.test.ts` + +**Step 3: Commit** + +Skip commit for now. + +### Task 2: Implement the backend contract + +**Files:** +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\server\videoSubtitleGeneration.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\server\subtitleGeneration.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\server\subtitleRequest.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\types.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\services\subtitleService.ts` +- Modify: `E:\Downloads\ai-video-dubbing-&-translation\src\components\EditorScreen.tsx` + +**Step 1: Write the minimal implementation** + +- Add optional `ttsText` and `ttsLanguage` to subtitle types. +- Accept optional `ttsLanguage` in request parsing and pipeline functions. +- Build prompts with English subtitle language plus requested TTS language. +- Normalize model output into subtitle objects with `ttsText` and `ttsLanguage`. +- Use `ttsText` for TTS generation when present. + +**Step 2: Run tests to verify they pass** + +Run: +`npm run test -- src/server/videoSubtitleGeneration.test.ts src/services/subtitleService.test.ts src/components/EditorScreen.test.tsx` + +**Step 3: Commit** + +Skip commit for now. + diff --git a/docs/plans/2026-03-19-upload-subtitle-defaults-design.md b/docs/plans/2026-03-19-upload-subtitle-defaults-design.md new file mode 100644 index 0000000..1cf986f --- /dev/null +++ b/docs/plans/2026-03-19-upload-subtitle-defaults-design.md @@ -0,0 +1,112 @@ +# Upload Subtitle Defaults Design + +**Goal:** Let users set the initial subtitle position and subtitle size on the upload screen, with defaults matching the current editor behavior, and carry those values into the editor as the starting subtitle style. + +## Context + +The upload screen currently collects the video file, trim range, and target language. The editor later applies a default subtitle style internally, which means users have no chance to define their preferred starting subtitle placement or text size before generation begins. + +The user wants the upload page to support free adjustment of subtitle position and subtitle size, while keeping a sensible default identical to the current editor: subtitle text near the bottom of the video and a medium default size. The upload page should define the initial values only, and the editor should continue using them as the starting style. + +## Approaches Considered + +### Option A: Add preset-only controls on the upload screen + +Offer fixed choices such as top / middle / bottom and small / medium / large. + +**Pros** +- Smallest implementation +- Easy to understand quickly + +**Cons** +- Does not satisfy the request for free adjustment +- Makes it harder for users to match personal subtitle habits + +### Option B: Add free controls with a lightweight preview + +Expose a vertical position control and a size slider on the upload screen, preview the result with a sample subtitle card, and pass the chosen values into the editor. + +**Pros** +- Matches the requested behavior +- Keeps upload-page controls focused on the two initial settings only +- Reuses the editor's existing subtitle style model + +**Cons** +- Requires new state to flow from upload to editor +- Needs a preview so the controls feel meaningful + +### Recommendation + +Use **Option B**. It gives users the flexibility they asked for without overloading the upload screen with full subtitle styling. + +## Architecture + +### Shared Style Data + +Introduce a small shared shape for upload-time subtitle defaults: + +- `fontSize` +- `bottomOffsetPercent` + +This can be represented either as a new dedicated interface or as part of the existing subtitle text style model plus a position field. The important part is that the upload screen stores these values and the editor uses them as its initial defaults for every generated subtitle. + +### Upload Screen + +Add a "Subtitle Defaults" card to the right settings column that includes: + +- a preview frame with a sample subtitle +- a vertical range input for subtitle position +- a horizontal range input for subtitle size +- a reset button that restores the current editor defaults + +The preview should be lightweight and not depend on the selected video file. It only needs to make the chosen position and size visible. + +### App State Flow + +`App` will store the chosen upload defaults alongside: + +- `videoFile` +- `targetLanguage` +- `trimRange` + +When the upload is confirmed, `UploadScreen` passes the subtitle default settings up through `onUpload(...)`. `App` then forwards them to `EditorScreen`. + +### Editor Initialization + +`EditorScreen` will accept the upload-provided subtitle defaults and use them when normalizing generated subtitles. That ensures: + +- all generated subtitles start with the chosen size +- the overlay initially appears at the chosen vertical position +- the existing editor style controls continue from those defaults + +## Interaction Details + +### Default Values + +Match the current editor behavior: + +- `fontSize: 24` +- `bottomOffsetPercent: 10` + +These values must be used both as initial upload-screen state and as the reset target. + +### Position Control + +Use a continuous slider representing distance from the bottom edge of the video preview. This is simpler and more reliable than drag-to-place in a small upload card. + +### Size Control + +Use a continuous slider with a bounded range suitable for subtitle readability, for example 16 to 40 pixels. + +## Testing Strategy + +Add tests that verify: + +- upload-screen controls start from the same defaults as the editor +- changing upload-screen position and size is reflected in the upload preview +- confirming upload passes the selected subtitle defaults up to `App` +- editor initialization applies the upload defaults to the visible subtitle overlay + +## Rollout Notes + +This change intentionally limits upload-time styling to size and vertical position. Full styling such as font family, color, stroke, and per-subtitle overrides remains the editor's responsibility. diff --git a/docs/plans/2026-03-19-upload-subtitle-defaults.md b/docs/plans/2026-03-19-upload-subtitle-defaults.md new file mode 100644 index 0000000..ebcc735 --- /dev/null +++ b/docs/plans/2026-03-19-upload-subtitle-defaults.md @@ -0,0 +1,182 @@ +# Upload Subtitle Defaults Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add upload-screen controls for initial subtitle position and size, then apply those defaults when the editor loads generated subtitles. + +**Architecture:** The upload screen owns temporary subtitle default settings and previews them locally. `App` stores the selected defaults during upload confirmation and passes them into `EditorScreen`, which uses them while normalizing generated subtitles and rendering the overlay. + +**Tech Stack:** React, TypeScript, Vite, Vitest, Testing Library, Tailwind utility classes + +--- + +### Task 1: Add failing tests for upload defaults flow + +**Files:** +- Create: `src/components/UploadScreen.test.tsx` +- Modify: `src/components/EditorScreen.test.tsx` + +**Step 1: Write the failing test** + +Add tests that: + +- verify upload-screen subtitle size and position controls start at the expected defaults +- verify changing them updates the upload preview +- verify upload confirmation passes subtitle defaults through `onUpload` +- verify `EditorScreen` initializes the subtitle overlay with upload-provided defaults + +**Step 2: Run test to verify it fails** + +Run: `npm run test -- src/components/UploadScreen.test.tsx src/components/EditorScreen.test.tsx` +Expected: FAIL because upload defaults do not yet exist and the editor cannot consume them + +**Step 3: Write minimal implementation** + +Do not implement here; continue to Tasks 2 through 4. + +**Step 4: Run test to verify it passes** + +Run after Tasks 2 through 4. + +**Step 5: Commit** + +```bash +git add src/components/UploadScreen.test.tsx src/components/EditorScreen.test.tsx src/components/UploadScreen.tsx src/components/EditorScreen.tsx src/App.tsx src/types.ts +git commit -m "test: cover upload subtitle defaults flow" +``` + +### Task 2: Add shared subtitle-default types and app-level state + +**Files:** +- Modify: `src/types.ts` +- Modify: `src/App.tsx` + +**Step 1: Write the failing test** + +Covered by Task 1. + +**Step 2: Run test to verify it fails** + +Run: `npm run test -- src/components/UploadScreen.test.tsx src/components/EditorScreen.test.tsx` +Expected: FAIL on missing props and missing subtitle-default state + +**Step 3: Write minimal implementation** + +- Add a shared interface for upload subtitle defaults +- Store subtitle defaults in `App` +- Extend the upload callback signature +- Pass the chosen defaults into `EditorScreen` + +**Step 4: Run test to verify it passes** + +Run after Tasks 3 and 4. + +**Step 5: Commit** + +```bash +git add src/types.ts src/App.tsx src/components/UploadScreen.tsx src/components/EditorScreen.tsx +git commit -m "feat: plumb upload subtitle defaults through app state" +``` + +### Task 3: Implement upload-screen subtitle defaults UI + +**Files:** +- Modify: `src/components/UploadScreen.tsx` +- Test: `src/components/UploadScreen.test.tsx` + +**Step 1: Write the failing test** + +Covered by Task 1. + +**Step 2: Run test to verify it fails** + +Run: `npm run test -- src/components/UploadScreen.test.tsx` +Expected: FAIL because the controls and preview do not exist + +**Step 3: Write minimal implementation** + +- Add subtitle size and vertical position state with current-editor default values +- Add a preview card showing sample subtitle text +- Add range inputs and a reset button +- Ensure upload confirmation sends the chosen defaults via `onUpload` + +**Step 4: Run test to verify it passes** + +Run: `npm run test -- src/components/UploadScreen.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/components/UploadScreen.tsx src/components/UploadScreen.test.tsx +git commit -m "feat: add upload-screen subtitle default controls" +``` + +### Task 4: Apply upload defaults inside the editor + +**Files:** +- Modify: `src/components/EditorScreen.tsx` +- Modify: `src/components/EditorScreen.test.tsx` + +**Step 1: Write the failing test** + +Covered by Task 1. + +**Step 2: Run test to verify it fails** + +Run: `npm run test -- src/components/EditorScreen.test.tsx` +Expected: FAIL because the editor still uses hard-coded defaults + +**Step 3: Write minimal implementation** + +- Accept upload subtitle defaults as a prop +- Use them while normalizing subtitles +- Render the overlay at the chosen bottom offset +- Keep existing editor controls working from these defaults + +**Step 4: Run test to verify it passes** + +Run: `npm run test -- src/components/EditorScreen.test.tsx` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/components/EditorScreen.tsx src/components/EditorScreen.test.tsx src/App.tsx src/types.ts +git commit -m "feat: initialize editor subtitles from upload defaults" +``` + +### Task 5: Verify targeted behavior + +**Files:** +- Modify: `src/components/UploadScreen.tsx` if verification reveals issues +- Modify: `src/components/EditorScreen.tsx` if verification reveals issues + +**Step 1: Write the failing test** + +Only add one if targeted verification reveals a missing edge case. + +**Step 2: Run test to verify it fails** + +Only if a regression is found. + +**Step 3: Write minimal implementation** + +Apply the smallest follow-up fix needed to make the targeted flow green. + +**Step 4: Run test to verify it passes** + +Run: `npm run test -- src/components/UploadScreen.test.tsx src/components/EditorScreen.test.tsx` +Expected: PASS + +Optional broader check: + +Run: `npm run test -- src/components/UploadScreen.test.tsx src/components/EditorScreen.test.tsx src/lib/exportPayload.test.ts` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/components/UploadScreen.tsx src/components/UploadScreen.test.tsx src/components/EditorScreen.tsx src/components/EditorScreen.test.tsx src/App.tsx src/types.ts +git commit -m "test: verify upload subtitle default behavior" +``` diff --git a/server.ts b/server.ts index d24fb36..442ac67 100644 --- a/server.ts +++ b/server.ts @@ -20,7 +20,15 @@ import { DEFAULT_EXPORT_TEXT_STYLES, shiftSubtitlesToExportTimeline, } from './src/server/exportVideo'; -import { formatLogContext, serializeError } from './src/server/errorLogging'; +import { formatLogContext, logEvent, serializeError } from './src/server/errorLogging'; +import { + createSubtitleJob, + createSubtitleJobStore, + getSubtitleJob, + pruneExpiredSubtitleJobs, + toSubtitleJobResponse, + updateSubtitleJob, +} from './src/server/subtitleJobs'; import { TextStyles } from './src/types'; const upload = multer({ @@ -35,6 +43,33 @@ if (!fs.existsSync('uploads')) { fs.mkdirSync('uploads'); } +const summarizeRequestHeaders = (headers: express.Request['headers']) => ({ + host: headers.host, + 'content-type': headers['content-type'], + 'content-length': headers['content-length'], + 'user-agent': headers['user-agent'], + 'x-forwarded-for': headers['x-forwarded-for'], + 'x-forwarded-proto': headers['x-forwarded-proto'], +}); + +const SUBTITLE_JOB_TTL_MS = 60 * 60 * 1000; +const subtitleJobStore = createSubtitleJobStore(); + +const deriveSubtitleErrorStatus = (message: string) => { + const lowerMessage = message.toLowerCase(); + return 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; +}; + dotenv.config(); const ffmpegPath = process.env.FFMPEG_PATH?.trim(); @@ -203,78 +238,235 @@ async function startServer() { const videoPath = req.file?.path; const requestId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const startedAt = Date.now(); + const requestContext = { + requestId, + method: req.method, + path: req.originalUrl, + fileName: req.file?.originalname, + fileSize: req.file?.size, + contentType: req.headers['content-type'], + contentLength: req.headers['content-length'], + host: req.headers.host, + }; try { - const { provider, targetLanguage, fileId } = parseSubtitleRequest(req.body); + logEvent({ + level: 'info', + message: '[subtitle] request received', + context: requestContext, + details: { + headers: summarizeRequestHeaders(req.headers), + bodyKeys: Object.keys(req.body || {}).slice(0, 20), + hasUploadFile: Boolean(req.file), + }, + }); + + const { provider, targetLanguage, ttsLanguage, fileId } = parseSubtitleRequest(req.body); if (!videoPath && !fileId) { + logEvent({ + level: 'warn', + message: '[subtitle] request rejected: missing video source', + context: { + ...requestContext, + durationMs: Date.now() - startedAt, + }, + details: { + fileId, + parsedProvider: provider, + targetLanguage, + ttsLanguage, + }, + }); return res.status(400).json({ error: 'No video file provided' }); } - console.info( - `[subtitle] request started ${formatLogContext({ - requestId, - provider, - targetLanguage, - fileName: req.file?.originalname, - fileSize: req.file?.size, - })}`, - ); - const result = await generateSubtitlePipeline({ - videoPath, - fileId, + pruneExpiredSubtitleJobs(subtitleJobStore, SUBTITLE_JOB_TTL_MS); + const subtitleJob = createSubtitleJob(subtitleJobStore, { + requestId, provider, targetLanguage, - env: process.env, - requestId, + ttsLanguage, + fileId, + filePath: videoPath, }); - console.info( - `[subtitle] request succeeded ${formatLogContext({ + updateSubtitleJob(subtitleJobStore, subtitleJob.id, { + status: 'running', + stage: videoPath ? 'upload_received' : 'queued', + progress: videoPath ? 15 : 5, + message: videoPath ? 'Upload received' : 'Queued', + }); + + logEvent({ + level: 'info', + message: `[subtitle] job accepted ${formatLogContext({ + jobId: subtitleJob.id, requestId, provider, targetLanguage, - durationMs: Date.now() - startedAt, - subtitleCount: result.subtitles.length, + ttsLanguage, + fileName: req.file?.originalname, + fileSize: req.file?.size, + fileId, })}`, - ); - - res.json({ - ...result, - provider, - requestId, + context: { + jobId: subtitleJob.id, + requestId, + provider, + targetLanguage, + ttsLanguage, + fileName: req.file?.originalname, + fileSize: req.file?.size, + fileId, + }, }); + + res.status(202).json(toSubtitleJobResponse(getSubtitleJob(subtitleJobStore, subtitleJob.id)!)); + + void (async () => { + try { + const result = await generateSubtitlePipeline({ + videoPath, + fileId, + provider, + targetLanguage, + ttsLanguage, + env: process.env, + requestId, + onProgress: (progress) => { + updateSubtitleJob(subtitleJobStore, subtitleJob.id, { + status: progress.status, + stage: progress.stage, + progress: progress.progress, + message: progress.message, + }); + }, + }); + + updateSubtitleJob(subtitleJobStore, subtitleJob.id, { + status: 'succeeded', + stage: 'succeeded', + progress: 100, + message: 'Subtitle generation completed', + result: { + ...result, + provider, + requestId, + }, + }); + + logEvent({ + level: 'info', + message: `[subtitle] background job succeeded ${formatLogContext({ + jobId: subtitleJob.id, + requestId, + provider, + targetLanguage, + durationMs: Date.now() - startedAt, + subtitleCount: result.subtitles.length, + })}`, + context: { + jobId: subtitleJob.id, + requestId, + provider, + targetLanguage, + durationMs: Date.now() - startedAt, + subtitleCount: result.subtitles.length, + quality: result.quality, + alignmentEngine: result.alignmentEngine, + }, + }); + } catch (error: any) { + const message = error instanceof Error ? error.message : 'Failed to generate subtitles'; + const status = deriveSubtitleErrorStatus(message); + + updateSubtitleJob(subtitleJobStore, subtitleJob.id, { + status: 'failed', + stage: 'failed', + message, + error: message, + }); + + logEvent({ + level: 'error', + message: `[subtitle] background job failed ${formatLogContext({ + jobId: subtitleJob.id, + requestId, + durationMs: Date.now() - startedAt, + fileName: req.file?.originalname, + fileSize: req.file?.size, + status, + })}`, + context: { + ...requestContext, + jobId: subtitleJob.id, + durationMs: Date.now() - startedAt, + status, + }, + details: { + error: serializeError(error), + headers: summarizeRequestHeaders(req.headers), + bodyKeys: Object.keys(req.body || {}).slice(0, 20), + }, + }); + } finally { + if (videoPath && fs.existsSync(videoPath)) { + fs.unlinkSync(videoPath); + updateSubtitleJob(subtitleJobStore, subtitleJob.id, { + filePath: undefined, + }); + logEvent({ + level: 'info', + message: '[subtitle] uploaded temp file cleaned', + context: { + jobId: subtitleJob.id, + requestId, + videoPath, + durationMs: Date.now() - startedAt, + }, + }); + } + } + })(); } 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; + const status = deriveSubtitleErrorStatus(message); - console.error( - `[subtitle] request failed ${formatLogContext({ + logEvent({ + level: 'error', + message: `[subtitle] request failed ${formatLogContext({ requestId, durationMs: Date.now() - startedAt, fileName: req.file?.originalname, fileSize: req.file?.size, status, })}`, - serializeError(error), - ); + context: { + ...requestContext, + durationMs: Date.now() - startedAt, + status, + }, + details: { + error: serializeError(error), + headers: summarizeRequestHeaders(req.headers), + bodyKeys: Object.keys(req.body || {}).slice(0, 20), + }, + }); res.status(status).json({ error: message, requestId }); - } finally { - if (videoPath && fs.existsSync(videoPath)) fs.unlinkSync(videoPath); } }); + app.get('/api/generate-subtitles/:jobId', (req, res) => { + pruneExpiredSubtitleJobs(subtitleJobStore, SUBTITLE_JOB_TTL_MS); + const subtitleJob = getSubtitleJob(subtitleJobStore, req.params.jobId); + + if (!subtitleJob) { + return res.status(404).json({ error: 'Subtitle job not found.' }); + } + + return res.json(toSubtitleJobResponse(subtitleJob)); + }); + app.post('/api/export-video', upload.single('video'), async (req, res) => { const tempFiles: string[] = []; try { diff --git a/src/App.test.tsx b/src/App.test.tsx new file mode 100644 index 0000000..acdb016 --- /dev/null +++ b/src/App.test.tsx @@ -0,0 +1,26 @@ +// @vitest-environment jsdom + +import React from 'react'; +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import App from './App'; + +describe('App bilingual UI', () => { + afterEach(() => { + cleanup(); + }); + + it('defaults to Chinese and switches to English from the language toggle', async () => { + render(); + + expect(screen.getByLabelText('switch-ui-language-zh')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByText('字幕语言')).toBeInTheDocument(); + expect(screen.getByText('上传视频')).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('switch-ui-language-en')); + + expect(screen.getByLabelText('switch-ui-language-en')).toHaveAttribute('aria-pressed', 'true'); + expect(screen.getByText('Subtitle Language')).toBeInTheDocument(); + expect(screen.getByText('Upload Video')).toBeInTheDocument(); + }); +}); diff --git a/src/App.tsx b/src/App.tsx index 5322b44..c7e8b25 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,16 +6,30 @@ import React, { useState } from 'react'; import UploadScreen from './components/UploadScreen'; import EditorScreen from './components/EditorScreen'; +import { DEFAULT_SUBTITLE_DEFAULTS, SubtitleDefaults } from './types'; +import { I18nProvider, useI18n } from './i18n'; -function App() { +function AppContent() { + const { locale, setLocale, m } = useI18n(); const [currentView, setCurrentView] = useState<'upload' | 'editor'>('upload'); const [videoFile, setVideoFile] = useState(null); - const [targetLanguage, setTargetLanguage] = useState('en'); + const [targetLanguage, setTargetLanguage] = useState('English'); + const [ttsLanguage, setTtsLanguage] = useState('English'); const [trimRange, setTrimRange] = useState<{start: number, end: number} | null>(null); + const [subtitleDefaults, setSubtitleDefaults] = useState(DEFAULT_SUBTITLE_DEFAULTS); - const handleVideoUpload = (file: File, lang: string, startTime?: number, endTime?: number) => { + const handleVideoUpload = ( + file: File, + lang: string, + selectedTtsLanguage: string, + initialSubtitleDefaults: SubtitleDefaults, + startTime?: number, + endTime?: number, + ) => { setVideoFile(file); setTargetLanguage(lang); + setTtsLanguage(selectedTtsLanguage); + setSubtitleDefaults(initialSubtitleDefaults); if (startTime !== undefined && endTime !== undefined) { setTrimRange({ start: startTime, end: endTime }); } else { @@ -26,13 +40,46 @@ function App() { return (
+
+
+
+ + +
+
+
{currentView === 'upload' ? ( ) : ( setCurrentView('upload')} /> )} @@ -40,4 +87,12 @@ function App() { ); } +function App() { + return ( + + + + ); +} + export default App; diff --git a/src/components/EditorScreen.test.tsx b/src/components/EditorScreen.test.tsx index dc930bd..4b79516 100644 --- a/src/components/EditorScreen.test.tsx +++ b/src/components/EditorScreen.test.tsx @@ -4,6 +4,13 @@ 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'; +import { Subtitle } from '../types'; +import { I18nProvider } from '../i18n'; + +const defaultSubtitleDefaults = { + fontSize: 24, + bottomOffsetPercent: 10, +}; const { generateSubtitlePipelineMock, generateTTSMock } = vi.hoisted(() => ({ generateSubtitlePipelineMock: vi.fn(), @@ -19,6 +26,58 @@ vi.mock('../services/ttsService', () => ({ })); describe('EditorScreen', () => { + const renderEditorScreen = (props: React.ComponentProps) => + render( + + + , + ); + + const styledSubtitles: Subtitle[] = [ + { + id: 'sub-1', + startTime: 0, + endTime: 2, + originalText: 'Original one', + translatedText: 'Styled first subtitle', + speaker: 'Young Woman', + voiceId: 'voice-1', + textStyle: { + fontFamily: 'Arial', + fontSize: 24, + color: '#ffffff', + backgroundColor: 'transparent', + alignment: 'center', + isBold: false, + isItalic: false, + isUnderline: false, + strokeColor: '#000000', + strokeWidth: 2, + }, + }, + { + id: 'sub-2', + startTime: 2, + endTime: 4, + originalText: 'Original two', + translatedText: 'Styled second subtitle', + speaker: 'Young Man', + voiceId: 'voice-2', + textStyle: { + fontFamily: 'Roboto', + fontSize: 20, + color: '#00ff00', + backgroundColor: 'transparent', + alignment: 'center', + isBold: false, + isItalic: false, + isUnderline: false, + strokeColor: '#000000', + strokeWidth: 2, + }, + }, + ]; + afterEach(() => { cleanup(); }); @@ -50,28 +109,26 @@ describe('EditorScreen', () => { }); it('shows a low-precision notice for fallback subtitle results', async () => { - render( - {}} - />, - ); + renderEditorScreen({ + videoFile: new File(['video'], 'clip.mp4', { type: 'video/mp4' }), + targetLanguage: 'en', + trimRange: null, + initialSubtitleDefaults: defaultSubtitleDefaults, + onBack: () => {}, + }); 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( - {}} - />, - ); + renderEditorScreen({ + videoFile: new File(['video'], 'clip.mp4', { type: 'video/mp4' }), + targetLanguage: 'en', + trimRange: null, + initialSubtitleDefaults: defaultSubtitleDefaults, + onBack: () => {}, + }); await waitFor(() => expect(generateSubtitlePipelineMock).toHaveBeenCalledWith( @@ -79,6 +136,9 @@ describe('EditorScreen', () => { 'en', 'doubao', null, + undefined, + expect.any(Function), + undefined, ), ); @@ -92,24 +152,182 @@ describe('EditorScreen', () => { 'en', 'gemini', null, + undefined, + expect.any(Function), + undefined, ), ); }); it('only auto-generates subtitles once in StrictMode', async () => { render( - - {}} - /> - , + + + {}} + /> + + , ); await waitFor(() => expect(generateSubtitlePipelineMock).toHaveBeenCalled()); expect(generateSubtitlePipelineMock).toHaveBeenCalledTimes(1); }); + + it('shows subtitle generation progress updates', async () => { + generateSubtitlePipelineMock.mockImplementation( + (_file, _lang, _provider, _trim, _fetch, onProgress) => + new Promise(() => { + onProgress?.({ + jobId: 'job-1', + requestId: 'req-1', + status: 'running', + stage: 'calling_provider', + progress: 70, + message: 'Calling provider', + }); + }), + ); + + renderEditorScreen({ + videoFile: new File(['video'], 'clip.mp4', { type: 'video/mp4' }), + targetLanguage: 'en', + trimRange: null, + initialSubtitleDefaults: defaultSubtitleDefaults, + onBack: () => {}, + }); + + expect(await screen.findByText(/70%/i)).toBeInTheDocument(); + expect(screen.getByText(/Calling provider/i)).toBeInTheDocument(); + }); + + it('shows the active subtitle speaker and updates only the selected subtitle style by default', async () => { + generateSubtitlePipelineMock.mockResolvedValue({ + subtitles: styledSubtitles, + speakers: [], + quality: 'full', + }); + + renderEditorScreen({ + videoFile: new File(['video'], 'clip.mp4', { type: 'video/mp4' }), + targetLanguage: 'en', + trimRange: null, + initialSubtitleDefaults: defaultSubtitleDefaults, + onBack: () => {}, + }); + + expect(await screen.findByText('Young Woman')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: /toggle bold/i })); + + const overlay = screen.getByTestId('video-subtitle-overlay'); + expect(overlay).toHaveStyle({ fontWeight: '700' }); + + fireEvent.click(screen.getByDisplayValue('Styled second subtitle')); + expect(await screen.findByText('Young Man')).toBeInTheDocument(); + + const colorInput = screen.getByLabelText(/subtitle color/i); + fireEvent.change(colorInput, { target: { value: '#ff0000' } }); + + fireEvent.click(screen.getByDisplayValue('Styled first subtitle')); + expect(screen.getByLabelText(/subtitle color/i)).toHaveValue('#ffffff'); + }); + + it('applies style changes to every subtitle when apply to all subtitles is enabled', async () => { + generateSubtitlePipelineMock.mockResolvedValue({ + subtitles: styledSubtitles, + speakers: [], + quality: 'full', + }); + + renderEditorScreen({ + videoFile: new File(['video'], 'clip.mp4', { type: 'video/mp4' }), + targetLanguage: 'en', + trimRange: null, + initialSubtitleDefaults: defaultSubtitleDefaults, + onBack: () => {}, + }); + + await screen.findByText('Young Woman'); + + fireEvent.click(screen.getByLabelText(/apply style changes to all subtitles/i)); + fireEvent.change(screen.getByLabelText(/subtitle color/i), { + target: { value: '#ff0000' }, + }); + + fireEvent.click(screen.getByDisplayValue('Styled second subtitle')); + expect(screen.getByLabelText(/subtitle color/i)).toHaveValue('#ff0000'); + }); + + it('initializes the overlay from upload subtitle defaults', async () => { + generateSubtitlePipelineMock.mockResolvedValue({ + subtitles: [ + { + id: 'sub-default', + startTime: 0, + endTime: 2, + originalText: 'Original', + translatedText: 'Uses upload defaults', + speaker: 'Narrator', + voiceId: 'voice-1', + }, + ], + speakers: [], + quality: 'full', + }); + + renderEditorScreen({ + videoFile: new File(['video'], 'clip.mp4', { type: 'video/mp4' }), + targetLanguage: 'en', + trimRange: null, + initialSubtitleDefaults: { fontSize: 32, bottomOffsetPercent: 18 }, + onBack: () => {}, + }); + + const overlay = await screen.findByTestId('video-subtitle-overlay'); + expect(overlay).toHaveStyle({ fontSize: '32px' }); + expect(overlay.parentElement).toHaveStyle({ bottom: '18%' }); + }); + + it('uses ttsText when generating dubbing audio', async () => { + generateSubtitlePipelineMock.mockResolvedValue({ + subtitles: [ + { + id: 'sub-tts', + startTime: 0, + endTime: 2, + originalText: 'Original', + translatedText: 'English subtitle', + ttsText: 'Bonjour a tous', + ttsLanguage: 'fr', + speaker: 'Narrator', + voiceId: 'voice-1', + }, + ], + speakers: [], + quality: 'full', + }); + generateTTSMock.mockResolvedValue('data:audio/mp3;base64,AAA'); + + renderEditorScreen({ + videoFile: new File(['video'], 'clip.mp4', { type: 'video/mp4' }), + targetLanguage: 'en', + trimRange: null, + initialSubtitleDefaults: defaultSubtitleDefaults, + onBack: () => {}, + }); + + await screen.findAllByDisplayValue('English subtitle'); + + fireEvent.click(screen.getByRole('button', { name: /generate dubbing/i })); + + await waitFor(() => + expect(generateTTSMock).toHaveBeenCalledWith('Bonjour a tous', 'voice-1'), + ); + }); }); diff --git a/src/components/EditorScreen.tsx b/src/components/EditorScreen.tsx index 2b27917..fbdfa9e 100644 --- a/src/components/EditorScreen.tsx +++ b/src/components/EditorScreen.tsx @@ -3,13 +3,61 @@ 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 { DEFAULT_SUBTITLE_DEFAULTS, LlmProvider, PipelineQuality, Subtitle, SubtitleDefaults, SubtitleGenerationProgress, TextStyles } from '../types'; import { generateSubtitlePipeline } from '../services/subtitleService'; import { generateTTS } from '../services/ttsService'; import { MINIMAX_VOICES } from '../voices'; import { apiUrl } from '../lib/apiBasePath'; +import { useI18n } from '../i18n'; -export default function EditorScreen({ videoFile, targetLanguage, trimRange, onBack }: { videoFile: File | null; targetLanguage: string; trimRange?: {start: number, end: number} | null; onBack: () => void }) { +const DEFAULT_TEXT_STYLE: TextStyles = { + fontFamily: 'MiSans-Late', + fontSize: 24, + color: '#FFFFFF', + backgroundColor: 'transparent', + strokeColor: '#000000', + strokeWidth: 2, + alignment: 'center', + isBold: false, + isItalic: false, + isUnderline: false, +}; + +const FONT_SIZE_PRESETS = { + small: 20, + normal: 24, + large: 30, +} as const; + +const normalizeTextStyle = (textStyle?: Partial): TextStyles => ({ + ...DEFAULT_TEXT_STYLE, + ...textStyle, +}); + +const normalizeSubtitle = (subtitle: Subtitle, initialSubtitleDefaults: SubtitleDefaults): Subtitle => ({ + ...subtitle, + textStyle: normalizeTextStyle({ + fontSize: initialSubtitleDefaults.fontSize, + ...subtitle.textStyle, + }), +}); + +export default function EditorScreen({ + videoFile, + targetLanguage, + ttsLanguage, + trimRange, + initialSubtitleDefaults = DEFAULT_SUBTITLE_DEFAULTS, + onBack, +}: { + videoFile: File | null; + targetLanguage: string; + ttsLanguage?: string; + trimRange?: {start: number, end: number} | null; + initialSubtitleDefaults?: SubtitleDefaults; + onBack: () => void; +}) { + const { m } = useI18n(); const [subtitles, setSubtitles] = useState([]); const [activeSubtitleId, setActiveSubtitleId] = useState(''); const [showVoiceMarket, setShowVoiceMarket] = useState(false); @@ -21,6 +69,8 @@ export default function EditorScreen({ videoFile, targetLanguage, trimRange, onB const [generationError, setGenerationError] = useState(null); const [subtitleQuality, setSubtitleQuality] = useState('fallback'); const [llmProvider, setLlmProvider] = useState('doubao'); + const [generationProgress, setGenerationProgress] = useState(null); + const [applyToAllSubtitles, setApplyToAllSubtitles] = useState(false); // Video Player State const videoRef = useRef(null); @@ -84,12 +134,25 @@ export default function EditorScreen({ videoFile, targetLanguage, trimRange, onB if (!videoFile) return; setIsGenerating(true); setGenerationError(null); + setGenerationProgress({ + jobId: 'pending', + requestId: 'pending', + status: 'queued', + stage: 'queued', + progress: 5, + message: m.editor.aiAnalyzing, + }); try { const pipelineResult = await generateSubtitlePipeline( videoFile, targetLanguage, llmProvider, trimRange, + undefined, + (progress) => { + setGenerationProgress(progress); + }, + ttsLanguage, ); const generatedSubs = pipelineResult.subtitles; setSubtitleQuality(pipelineResult.quality); @@ -105,15 +168,17 @@ export default function EditorScreen({ videoFile, targetLanguage, trimRange, onB })); } - setSubtitles(adjustedSubs); - if (adjustedSubs.length > 0) { - setActiveSubtitleId(adjustedSubs[0].id); + const normalizedSubs = adjustedSubs.map((subtitle) => normalizeSubtitle(subtitle, initialSubtitleDefaults)); + + setSubtitles(normalizedSubs); + if (normalizedSubs.length > 0) { + setActiveSubtitleId(normalizedSubs[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."; + let errorMessage = m.editor.failedToGenerateSubtitles; const errString = err instanceof Error ? err.message : JSON.stringify(err); if ( @@ -123,7 +188,7 @@ export default function EditorScreen({ videoFile, targetLanguage, trimRange, onB err?.status === 429 || err?.error?.code === 429 ) { - errorMessage = "You have exceeded your Volcengine API quota. Please check your plan and billing details."; + errorMessage = m.editor.exceededQuota; } else if (err instanceof Error) { errorMessage = err.message; } @@ -133,8 +198,9 @@ export default function EditorScreen({ videoFile, targetLanguage, trimRange, onB setActiveSubtitleId(''); } finally { setIsGenerating(false); + setGenerationProgress(null); } - }, [videoFile, targetLanguage, trimRange, llmProvider]); + }, [videoFile, targetLanguage, ttsLanguage, trimRange, llmProvider, initialSubtitleDefaults, m.editor.aiAnalyzing, m.editor.exceededQuota, m.editor.failedToGenerateSubtitles]); // Generate subtitles on mount useEffect(() => { @@ -142,6 +208,7 @@ export default function EditorScreen({ videoFile, targetLanguage, trimRange, onB fileName: videoFile?.name || '', fileSize: videoFile?.size || 0, targetLanguage, + ttsLanguage, trimRange, llmProvider, }); @@ -152,18 +219,7 @@ export default function EditorScreen({ videoFile, targetLanguage, trimRange, onB autoGenerationKeyRef.current = autoGenerationKey; fetchSubtitles(); - }, [fetchSubtitles, videoFile, targetLanguage, trimRange, llmProvider]); - - const [textStyles, setTextStyles] = useState({ - fontFamily: 'MiSans-Late', - fontSize: 24, - color: '#FFFFFF', - backgroundColor: 'transparent', - alignment: 'center', - isBold: false, - isItalic: false, - isUnderline: false, - }); + }, [fetchSubtitles, videoFile, targetLanguage, ttsLanguage, trimRange, llmProvider]); const togglePlay = async () => { if (videoRef.current) { @@ -228,7 +284,7 @@ export default function EditorScreen({ videoFile, targetLanguage, trimRange, onB if (sub.audioUrl) continue; try { - const textToSpeak = sub.translatedText || sub.text; + const textToSpeak = sub.ttsText || sub.translatedText || sub.originalText; const audioUrl = await generateTTS(textToSpeak, sub.voiceId); updatedSubtitles[i] = { ...sub, audioUrl }; // Update state incrementally so user sees progress @@ -415,12 +471,60 @@ export default function EditorScreen({ videoFile, targetLanguage, trimRange, onB const displayDuration = trimRange ? trimRange.end - trimRange.start : duration; const displayCurrentTime = trimRange ? Math.max(0, currentTime - trimRange.start) : currentTime; + const activeSubtitle = useMemo( + () => subtitles.find((subtitle) => subtitle.id === activeSubtitleId) ?? null, + [subtitles, activeSubtitleId], + ); + const activeSubtitleStyle = activeSubtitle?.textStyle ?? DEFAULT_TEXT_STYLE; + const currentSubtitle = useMemo( + () => subtitles.find((subtitle) => displayCurrentTime >= subtitle.startTime && displayCurrentTime <= subtitle.endTime) ?? null, + [subtitles, displayCurrentTime], + ); + const currentSubtitleStyle = currentSubtitle?.textStyle ?? DEFAULT_TEXT_STYLE; + const currentSubtitleText = currentSubtitle?.translatedText || ''; + const currentFontSizePreset = (Object.entries(FONT_SIZE_PRESETS).find( + ([, fontSize]) => fontSize === activeSubtitleStyle.fontSize, + )?.[0] ?? 'normal') as keyof typeof FONT_SIZE_PRESETS; + const exportTextStyles = subtitles[0]?.textStyle ?? DEFAULT_TEXT_STYLE; + const fontSizePresetLabels: Record = { + small: m.editor.small, + normal: m.editor.normal, + large: m.editor.large, + }; + const alignmentOptions: Array<{ value: TextStyles['alignment']; label: string }> = [ + { value: 'left', label: m.editor.alignLeft }, + { value: 'center', label: m.editor.alignCenter }, + { value: 'right', label: m.editor.alignRight }, + ]; // 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 || ''; + const updateSubtitleStyles = useCallback((updates: Partial) => { + if (!activeSubtitleId && !applyToAllSubtitles) { + return; + } + + setSubtitles((previousSubtitles) => + previousSubtitles.map((subtitle) => { + if (!applyToAllSubtitles && subtitle.id !== activeSubtitleId) { + return subtitle; + } + + return { + ...subtitle, + textStyle: { + ...normalizeTextStyle(subtitle.textStyle), + ...updates, + }, + }; + }), + ); + }, [activeSubtitleId, applyToAllSubtitles]); + + const activeSpeakerInitial = activeSubtitle?.speaker?.trim().charAt(0).toUpperCase() || 'S'; + const activeSubtitleContext = activeSubtitle?.translatedText || activeSubtitle?.originalText || ''; + const subtitleTextShadow = `${currentSubtitleStyle.strokeWidth}px 0 ${currentSubtitleStyle.strokeColor}, -${currentSubtitleStyle.strokeWidth}px 0 ${currentSubtitleStyle.strokeColor}, 0 ${currentSubtitleStyle.strokeWidth}px ${currentSubtitleStyle.strokeColor}, 0 -${currentSubtitleStyle.strokeWidth}px ${currentSubtitleStyle.strokeColor}`; return (
@@ -432,7 +536,7 @@ export default function EditorScreen({ videoFile, targetLanguage, trimRange, onB
M
- Translate 1.0 + {m.app.productName}
@@ -449,18 +553,18 @@ export default function EditorScreen({ videoFile, targetLanguage, trimRange, onB className="flex items-center gap-2 px-3 py-1.5 bg-red-50 text-red-600 rounded-md text-sm font-medium hover:bg-red-100" > - Watch Video + {m.editor.watchVideo}
@@ -471,19 +575,19 @@ export default function EditorScreen({ videoFile, targetLanguage, trimRange, onB
- - + +

- Tip: After modifying the subtitle text, you need to regenerate the dubbing to take effect! + {m.editor.tip}

-
-
-
- W + {activeSubtitle ? ( + <> +
+
+
+ {activeSpeakerInitial} +
+ {activeSubtitle.speaker} +
+
- Wife -
- -
-
-

Text Styles

- - {/* Style Presets Grid */} -
- {['T', 'T', 'T', 'T', 'T', 'T', 'T', 'T', 'T', 'T', 'T', 'T'].map((item, i) => ( - - ))} -
+
+

{m.editor.textStyles}

+ +
+ {alignmentOptions.map((alignment) => ( + + ))} +
- {/* Font Family */} -
- -
+
+ +
- {/* Font Size & Alignment */} -
- -
- - - -
-
+
+ +
+ + + +
+
- {/* Colors */} -
-
- Color -
- setTextStyles({...textStyles, color: e.target.value})} - className="w-6 h-6 rounded border border-gray-300 p-0 cursor-pointer" - /> - 100% -
-
-
- Stroke -
-
- 100% +
+
+ {m.editor.color} +
+ updateSubtitleStyles({ color: e.target.value })} + className="w-6 h-6 rounded border border-gray-300 p-0 cursor-pointer" + /> + 100% +
+
+
+ {m.editor.stroke} +
+ updateSubtitleStyles({ strokeColor: e.target.value })} + className="w-6 h-6 rounded border border-gray-300 p-0 cursor-pointer" + /> + 100% +
+
+ + ) : ( +
+ {m.editor.selectSubtitleToEdit}
-
+ )}
@@ -822,7 +992,7 @@ export default function EditorScreen({ videoFile, targetLanguage, trimRange, onB
- Stretch the dubbing to control the speed + {m.editor.stretchDubbing}
{/* Zoom slider placeholder */} @@ -845,7 +1015,7 @@ export default function EditorScreen({ videoFile, targetLanguage, trimRange, onB {/* Video Track */}
- Video Track + {m.editor.videoTrack}
@@ -952,7 +1122,7 @@ export default function EditorScreen({ videoFile, targetLanguage, trimRange, onB subtitles={subtitles} bgmUrl={bgmUrl} bgmBase64={bgmBase64} - textStyles={textStyles} + textStyles={exportTextStyles} trimRange={trimRange} /> )} diff --git a/src/components/ExportModal.tsx b/src/components/ExportModal.tsx index 69cbb23..9c0212f 100644 --- a/src/components/ExportModal.tsx +++ b/src/components/ExportModal.tsx @@ -4,6 +4,7 @@ import axios from 'axios'; import { Subtitle, TextStyles } from '../types'; import { buildExportPayload } from '../lib/exportPayload'; import { apiUrl } from '../lib/apiBasePath'; +import { useI18n } from '../i18n'; interface ExportModalProps { onClose: () => void; @@ -16,6 +17,7 @@ interface ExportModalProps { } export default function ExportModal({ onClose, videoFile, subtitles, bgmUrl, bgmBase64, textStyles, trimRange }: ExportModalProps) { + const { m } = useI18n(); const [isExporting, setIsExporting] = useState(false); const [progress, setProgress] = useState(0); const [isDone, setIsDone] = useState(false); @@ -81,7 +83,7 @@ export default function ExportModal({ onClose, videoFile, subtitles, bgmUrl, bgm } } catch (err: any) { console.error('Export Error:', err); - setError(err.response?.data?.error || err.message || 'An error occurred during export'); + setError(err.response?.data?.error || err.message || m.exportModal.errorDefault); } finally { setIsExporting(false); } @@ -103,7 +105,7 @@ export default function ExportModal({ onClose, videoFile, subtitles, bgmUrl, bgm
{/* Header */}
-

Export Video

+

{m.exportModal.title}

@@ -117,7 +119,7 @@ export default function ExportModal({ onClose, videoFile, subtitles, bgmUrl, bgm