improved tools for ai

This commit is contained in:
2026-04-15 16:36:21 -06:00
parent 4f90750497
commit d11e26cf2d
25 changed files with 2618 additions and 33 deletions

Binary file not shown.

View File

@ -0,0 +1,16 @@
# backend_health_check
# cmd: /home/dillon/_code/TalkEdit/.venv312/bin/python3.12 -c import importlib; importlib.import_module('backend.main'); print('backend import OK')
Traceback (most recent call last):
File "<string>", line 1, in <module>
File "/usr/lib/python3.12/importlib/__init__.py", line 90, in import_module
return _bootstrap._gcd_import(name[level:], package, level)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "<frozen importlib._bootstrap>", line 1387, in _gcd_import
File "<frozen importlib._bootstrap>", line 1360, in _find_and_load
File "<frozen importlib._bootstrap>", line 1331, in _find_and_load_unlocked
File "<frozen importlib._bootstrap>", line 935, in _load_unlocked
File "<frozen importlib._bootstrap_external>", line 999, in exec_module
File "<frozen importlib._bootstrap>", line 488, in _call_with_frames_removed
File "/home/dillon/_code/TalkEdit/backend/main.py", line 12, in <module>
from routers import transcribe, export, ai, captions, audio
ModuleNotFoundError: No module named 'routers'

View File

@ -0,0 +1,3 @@
# backend_python_version
# cmd: /home/dillon/_code/TalkEdit/.venv312/bin/python3.12 --version
Python 3.12.13

View File

@ -0,0 +1,3 @@
# env_git_head
# cmd: git -C /home/dillon/_code/TalkEdit rev-parse --short HEAD
4f90750

View File

@ -0,0 +1,10 @@
# env_git_status
# cmd: git -C /home/dillon/_code/TalkEdit status --short
M frontend/src/App.tsx
M frontend/src/components/VolumePanel.tsx
M frontend/src/components/WaveformTimeline.tsx
M frontend/src/store/editorStore.ts
?? .diagnostics/
?? AI_dev.md
?? docs/
?? scripts/

View File

@ -0,0 +1,3 @@
# env_node_version
# cmd: node --version
v22.18.0

View File

@ -0,0 +1,3 @@
# env_npm_version
# cmd: npm --version
10.9.3

View File

@ -0,0 +1,3 @@
# env_uname
# cmd: uname -a
Linux cachyos-x 6.19.10-1-cachyos #1 SMP PREEMPT_DYNAMIC Wed, 25 Mar 2026 23:30:07 +0000 x86_64 GNU/Linux

View File

@ -0,0 +1,11 @@
# frontend_build
# cmd: bash -lc cd '/home/dillon/_code/TalkEdit/frontend' && npm run -s build
vite v6.4.1 building for production...
transforming...
✓ 1606 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 1.20 kB │ gzip: 0.57 kB
dist/assets/index-gyhcOzhr.css 19.31 kB │ gzip: 4.48 kB
dist/assets/index-B5NnH24A.js 354.13 kB │ gzip: 108.13 kB
✓ built in 2.43s

View File

@ -0,0 +1,3 @@
# frontend_lint
# cmd: bash -lc cd '/home/dillon/_code/TalkEdit/frontend' && npm run -s lint
sh: line 1: eslint: command not found

View File

@ -0,0 +1,72 @@
# list_recent_files
# cmd: find /home/dillon/_code/TalkEdit -maxdepth 2 -type f
/home/dillon/_code/TalkEdit/.git/description
/home/dillon/_code/TalkEdit/.git/packed-refs
/home/dillon/_code/TalkEdit/.git/COMMIT_EDITMSG
/home/dillon/_code/TalkEdit/.git/FETCH_HEAD
/home/dillon/_code/TalkEdit/.git/ORIG_HEAD
/home/dillon/_code/TalkEdit/.git/REBASE_HEAD
/home/dillon/_code/TalkEdit/.git/HEAD
/home/dillon/_code/TalkEdit/.git/config
/home/dillon/_code/TalkEdit/.git/index
/home/dillon/_code/TalkEdit/backend/requirements.txt
/home/dillon/_code/TalkEdit/backend/.python-version
/home/dillon/_code/TalkEdit/backend/dev_main.py
/home/dillon/_code/TalkEdit/backend/video_editor.py
/home/dillon/_code/TalkEdit/backend/audio_cleaner.py
/home/dillon/_code/TalkEdit/backend/diarization.py
/home/dillon/_code/TalkEdit/backend/ai_provider.py
/home/dillon/_code/TalkEdit/backend/caption_generator.py
/home/dillon/_code/TalkEdit/backend/background_removal.py
/home/dillon/_code/TalkEdit/backend/main.py
/home/dillon/_code/TalkEdit/frontend/postcss.config.js
/home/dillon/_code/TalkEdit/frontend/tailwind.config.js
/home/dillon/_code/TalkEdit/frontend/tsconfig.json
/home/dillon/_code/TalkEdit/frontend/vite.config.ts
/home/dillon/_code/TalkEdit/frontend/frontend_dev.log
/home/dillon/_code/TalkEdit/frontend/index.html
/home/dillon/_code/TalkEdit/frontend/package-lock.json
/home/dillon/_code/TalkEdit/frontend/package.json
/home/dillon/_code/TalkEdit/frontend/tsconfig.tsbuildinfo
/home/dillon/_code/TalkEdit/shared/project-schema.json
/home/dillon/_code/TalkEdit/node_modules/.package-lock.json
/home/dillon/_code/TalkEdit/src-tauri/.gitignore
/home/dillon/_code/TalkEdit/src-tauri/Cargo.toml
/home/dillon/_code/TalkEdit/src-tauri/build.rs
/home/dillon/_code/TalkEdit/src-tauri/tauri_dev.log
/home/dillon/_code/TalkEdit/src-tauri/Cargo.lock
/home/dillon/_code/TalkEdit/src-tauri/tauri.conf.json
/home/dillon/_code/TalkEdit/.dockerignore
/home/dillon/_code/TalkEdit/.gitattributes
/home/dillon/_code/TalkEdit/FIX-GITHUB-ACTIONS.md
/home/dillon/_code/TalkEdit/LICENSE
/home/dillon/_code/TalkEdit/M4A-SUPPORT.md
/home/dillon/_code/TalkEdit/package-lock.json
/home/dillon/_code/TalkEdit/TECH_FEATURES.md
/home/dillon/_code/TalkEdit/FFmpeg_COMPLIANCE.md
/home/dillon/_code/TalkEdit/transcribe.py
/home/dillon/_code/TalkEdit/test_api.py
/home/dillon/_code/TalkEdit/.vscode/settings.json
/home/dillon/_code/TalkEdit/.venv312/pyvenv.cfg
/home/dillon/_code/TalkEdit/webview.log
/home/dillon/_code/TalkEdit/.gitmodules
/home/dillon/_code/TalkEdit/split_audio.sh
/home/dillon/_code/TalkEdit/venv/.gitignore
/home/dillon/_code/TalkEdit/venv/pyvenv.cfg
/home/dillon/_code/TalkEdit/.gitignore
/home/dillon/_code/TalkEdit/FEATURES.md
/home/dillon/_code/TalkEdit/README.md
/home/dillon/_code/TalkEdit/close
/home/dillon/_code/TalkEdit/electron/main.js
/home/dillon/_code/TalkEdit/electron/preload.js
/home/dillon/_code/TalkEdit/electron/python-bridge.js
/home/dillon/_code/TalkEdit/idea summary.md
/home/dillon/_code/TalkEdit/open
/home/dillon/_code/TalkEdit/package.json
/home/dillon/_code/TalkEdit/plan.md
/home/dillon/_code/TalkEdit/.github/copilot-instructions.md
/home/dillon/_code/TalkEdit/AI_dev.md
/home/dillon/_code/TalkEdit/docs/spec-template.md
/home/dillon/_code/TalkEdit/docs/ai-policy.md
/home/dillon/_code/TalkEdit/scripts/validate-all.sh
/home/dillon/_code/TalkEdit/scripts/collect-diagnostics.sh

327
AI_dev_plan.md Normal file
View File

@ -0,0 +1,327 @@
# AI Dev Roadmap
## Purpose
This document defines how TalkEdit can evolve toward highly autonomous AI-driven implementation and debugging.
Goal: AI can execute most engineering work end-to-end with minimal human feedback while preserving safety, quality, and product intent.
## Scope
- Frontend: React + TypeScript + Vite
- Desktop host: Tauri
- Backend: FastAPI + Python services
- Media pipeline: FFmpeg, transcription, audio processing
## Autonomy Target
- Near-term target: 80-90% autonomous execution for well-scoped work.
- Mid-term target: 90-95% for low/medium-risk features with CI gates.
- 100% no-feedback autonomy is not realistic for ambiguous product decisions, legal/security tradeoffs, or high-risk migrations.
## Core Principles
1. Specs are executable and machine-readable.
2. Tests are the primary source of truth for completion.
3. Every failure is diagnosable from logs/artifacts.
4. AI has bounded permissions and policy guardrails.
5. AI updates docs and memory as part of done criteria.
## Execution Status (2026-04-15)
### Completed
1. Added roadmap companion docs:
- `docs/spec-template.md`
- `docs/ai-policy.md`
- `docs/runbooks/error-codes.md`
2. Added operational scripts:
- `scripts/validate-all.sh`
- `scripts/collect-diagnostics.sh`
3. Ran Step 1 validation script (`./scripts/validate-all.sh`).
4. Ran Step 2 diagnostics script (`./scripts/collect-diagnostics.sh`).
5. Captured diagnostics archive:
- `.diagnostics/diag_20260415_163239.tar.gz`
6. Renamed roadmap file to `AI_dev_plan.md`.
### Current Blockers
1. Frontend lint baseline is not green yet.
2. Remaining lint issues are mostly pre-existing unused vars and hook dependency warnings across app components.
### Next Actions
1. Triage existing lint findings into:
- safe autofix
- manual low-risk cleanup
- intentional warnings to suppress with justification
2. Reach green `./scripts/validate-all.sh` in local dev.
3. Add CI workflow to enforce `validate-all` on pull requests.
## Roadmap Phases
## Phase 0: Foundation (1-2 weeks)
### Deliverables
1. Deterministic dev and test environment.
2. Baseline lint/type/test commands working in CI and local.
3. Standardized log format across frontend, backend, and Tauri host.
### Tasks
1. Stabilize toolchain commands:
- frontend lint/typecheck/test
- backend lint/typecheck/test
- workspace e2e smoke command
2. Add a single script for local validation, for example `npm run validate:all`.
3. Introduce structured logging fields:
- timestamp
- request/job id
- subsystem (frontend/backend/host)
- error code
4. Add reproducible media fixtures for tests under a dedicated test-fixtures path.
### Exit Criteria
- Fresh clone can run validation with one command.
- CI produces deterministic pass/fail on clean branches.
- Failures include enough context to reproduce without manual guessing.
## Phase 1: Spec + Test Contracts (2-4 weeks)
### Deliverables
1. Feature spec template used for all new work.
2. API and schema contracts versioned and validated.
3. Regression harness for previous bugs.
### Tasks
1. Create `docs/spec-template.md` with required sections:
- user story
- acceptance criteria
- non-goals
- edge cases
- rollback behavior
2. Add contract tests for backend routers:
- transcribe
- export
- captions
- audio
3. Add project schema validation tests for `shared/project-schema.json` and project load/save behavior.
4. For each resolved bug, add a regression test before closing issue.
### Exit Criteria
- New feature PRs must include spec and tests.
- Breaking contract changes are detected automatically in CI.
## Phase 2: Observability and Self-Debugging (2-3 weeks)
### Deliverables
1. Unified diagnostics bundle command.
2. AI-readable failure artifacts from CI and local runs.
3. Error taxonomy and runbook mapping.
### Tasks
1. Implement diagnostics command to collect:
- frontend logs
- backend logs
- Tauri logs
- failing test outputs
- environment metadata
2. Define error codes for common classes:
- media decode
- FFmpeg pipeline
- transcription model
- project load/save
- network/IPC bridge
3. Add runbook table mapping error codes to probable causes and first fixes.
### Exit Criteria
- Agent can identify likely root cause from artifacts without asking for manual logs.
- 80%+ of recurring failures map to known error classes.
## Phase 3: Controlled Autonomous Implementation (3-5 weeks)
### Deliverables
1. Policy file defining what AI can edit/run without approval.
2. Autonomous task loop for implement -> validate -> fix -> revalidate.
3. Automatic PR summary with risk and assumptions.
### Tasks
1. Add policy file (for example `docs/ai-policy.md`):
- allowed directories for autonomous edits
- blocked files requiring approval
- blocked commands
2. Add task template for AI execution:
- parse feature spec
- locate impacted modules
- implement smallest changes
- run validation suite
- retry up to N fix cycles
- produce summary + residual risks
3. Require AI to update:
- copilot instructions
- changelog/roadmap note
- regression tests when bugfixing
### Exit Criteria
- Low-risk feature tasks complete end-to-end without human intervention.
- CI gate pass rate for autonomous PRs remains above agreed threshold (for example 95%).
## Phase 4: High-Autonomy with Human Escalation (ongoing)
### Deliverables
1. Explicit escalation triggers for ambiguity and risk.
2. Broader autonomous scope with mandatory gates.
3. Drift monitoring for quality, velocity, and regressions.
### Tasks
1. Define escalation triggers:
- user-visible behavior changes without clear spec
- API/schema breakage
- security-sensitive modifications
- destructive migrations
2. Add quality dashboards:
- flaky tests
- escaped defects
- mean time to recovery
- autonomous task success rate
3. Monthly calibration:
- adjust autonomy scope
- update policies
- prune stale runbooks and memories
### Exit Criteria
- Autonomous throughput increases while defect rate stays stable or improves.
- Human review focuses on strategy and product decisions, not routine implementation/debugging.
## Required Engineering Systems
## 1. Spec System
Minimum implementation:
1. `docs/spec-template.md`
2. `docs/specs/` folder with one file per feature
3. CI check that new feature PRs include a spec reference
## 2. Test System
Minimum implementation:
1. Frontend unit tests for stores/components/hook logic.
2. Backend unit+integration tests for routers/services.
3. E2E smoke tests for core workflow:
- open media
- transcribe
- edit zones
- export
4. Regression tests required for every bugfix.
## 3. Environment System
Minimum implementation:
1. Locked dependencies and pinned runtimes.
2. Single bootstrap script.
3. Fixture media files for deterministic test runs.
## 4. Observability System
Minimum implementation:
1. Structured logs.
2. Standard error codes.
3. Diagnostics bundle command.
4. CI artifact retention for failed runs.
## 5. Governance System
Minimum implementation:
1. Protected branch + required checks.
2. Secret and dependency scanning.
3. Policy-based approval requirements for high-risk changes.
## Suggested Repository Additions
1. `AI_dev_plan.md` (this file)
2. `docs/spec-template.md`
3. `docs/ai-policy.md`
4. `docs/runbooks/error-codes.md`
5. `docs/runbooks/debug-playbooks.md`
6. `scripts/validate-all.sh`
7. `scripts/collect-diagnostics.sh`
## Definition of Done for Autonomous Tasks
A task is complete only if all items pass:
1. Feature spec acceptance criteria satisfied.
2. Relevant tests added/updated and passing.
3. No lint/type errors in changed scope.
4. Docs and instructions updated if behavior changed.
5. Risk summary and assumptions recorded.
## Escalation Rules (Must Ask Human)
AI must stop and ask when:
1. Requirement ambiguity changes user-visible behavior.
2. Multiple valid product decisions exist without clear preference.
3. Security/privacy/compliance implications are uncertain.
4. Data loss or destructive migration is possible.
5. CI remains failing after bounded auto-fix attempts.
## Metrics to Track
1. Autonomous task success rate.
2. Reopen rate of AI-completed tasks.
3. Regression rate per release.
4. Flaky test percentage.
5. Mean time to diagnose and resolve failures.
## 30-Day Execution Plan
Week 1:
1. Baseline scripts and deterministic environment.
2. Restore lint/test commands to green status.
3. Add structured logging and IDs.
Week 2:
1. Spec template and mandatory spec policy.
2. Contract tests for core backend routes.
3. First diagnostics bundle version.
Week 3:
1. AI policy and bounded autonomous edit/run loop.
2. Regression-test-first bugfix workflow.
3. CI artifact enrichment and runbook mapping.
Week 4:
1. Pilot autonomous feature tasks in low-risk areas.
2. Measure success/failure patterns.
3. Expand scope only if quality gates hold.
## Notes for TalkEdit
1. Keep router files thin and service logic isolated to improve AI edit precision.
2. Preserve compatibility in desktop bridge contracts to avoid frontend breakage.
3. Treat export/transcription pipeline changes as high-risk and always require regression tests.
4. Keep Linux WebKit startup and media URL consistency as explicit regression targets.

73
docs/ai-policy.md Normal file
View File

@ -0,0 +1,73 @@
# AI Execution Policy
Purpose: define what autonomous AI can do in this repository without explicit human approval.
## Default Mode
- AI may implement and debug within approved scope.
- AI must run validation commands after code changes.
- AI must stop and escalate when blocked by policy or ambiguity.
## Allowed Autonomous Actions
1. Edit frontend, backend, shared schema, docs, and scripts.
2. Add/modify tests related to the task.
3. Run non-destructive validation commands.
4. Update project docs and Copilot instructions when behavior changes.
## Restricted Actions (Require Approval)
1. Security/privacy-sensitive logic changes.
2. Data migrations or destructive file operations.
3. Credential handling changes or secrets management changes.
4. Breaking API/schema changes.
5. Build/release signing, packaging, and deployment automation changes.
## Prohibited Actions
1. Destructive git commands (`git reset --hard`, force pushing protected branches).
2. Deleting user project/media data.
3. Bypassing required checks in CI.
## Required Validation Workflow
For each autonomous task:
1. Implement smallest safe change set.
2. Run lint/type/test/build checks for impacted scope.
3. Inspect errors and fix with bounded retries.
4. Re-run checks until green or escalated.
5. Produce concise summary with risks and assumptions.
## Escalation Triggers
AI must ask a human when:
1. Requirements are ambiguous and affect user-visible behavior.
2. Multiple product choices are plausible with no clear preference.
3. Potential legal, security, or compliance impact exists.
4. CI remains failing after 3 repair attempts in the same area.
5. A requested operation conflicts with this policy.
## Required Artifacts In AI PR/Change Summary
1. What changed.
2. Why it changed.
3. Validation commands and outcome.
4. Residual risks.
5. Follow-up tasks.
## Risk Levels
- Low: docs, styling, isolated refactors, non-critical bugfixes.
- Medium: feature additions with contract-stable behavior.
- High: API/schema/security/export pipeline/transcription pipeline changes.
High-risk changes require explicit human review before merge.
## TalkEdit-Specific Rules
1. Preserve compatibility for desktop bridge contracts unless explicitly approved.
2. Keep routers thin and business logic in backend services.
3. Export/transcription pipeline changes must include regression tests.
4. Linux WebKit startup behavior and media URL consistency are mandatory regression targets.

View File

@ -0,0 +1,113 @@
# Error Codes Runbook
Purpose: provide consistent, AI-readable error categories for faster autonomous debugging.
## Format
Use codes in this format: `<SUBSYSTEM>-<CATEGORY>-<ID>`
Examples:
- `BE-EXPORT-001`
- `FE-WAVEFORM-002`
- `HOST-BRIDGE-003`
## Backend (FastAPI / Services)
### Export
- `BE-EXPORT-001`: Export request validation failed.
- Symptoms: HTTP 400, missing/invalid ranges.
- Likely causes: malformed payload, empty segments.
- First checks: request body shape, keep/mute/gain ranges.
- `BE-EXPORT-002`: FFmpeg command failed.
- Symptoms: HTTP 500, stderr contains filter/codec error.
- Likely causes: invalid filter chain, unsupported codec/container.
- First checks: generated FFmpeg args, source media codec, target format.
- `BE-EXPORT-003`: Caption burn-in/subtitle generation failed.
- Symptoms: burn-in export fails while plain export works.
- Likely causes: ASS generation issue, subtitle path/temp file cleanup race.
- First checks: ASS file generation, temp file lifecycle.
### Transcription
- `BE-TRANSCRIBE-001`: Model unavailable or download failure.
- Symptoms: transcription never starts or exits early.
- Likely causes: missing model, network/cache issue.
- First checks: model cache path, ensure-model logs.
- `BE-TRANSCRIBE-002`: Inference pipeline runtime failure.
- Symptoms: mid-run crash, partial output.
- Likely causes: CUDA/CPU mismatch, unsupported media, resource exhaustion.
- First checks: environment, GPU availability, media decoding logs.
### Audio / Waveform
- `BE-AUDIO-001`: Waveform endpoint failed.
- Symptoms: waveform panel shows unavailable/error.
- Likely causes: decode error, invalid file path, unsupported media input.
- First checks: `audio/waveform` response body, file existence, FFmpeg decode path.
## Frontend (React)
### Timeline / Zones
- `FE-TIMELINE-001`: Zone interaction state inconsistency.
- Symptoms: cannot drag/select/delete zones predictably.
- Likely causes: stale selection/editing state, hidden/selected mismatch.
- First checks: zone mode flags, selectedZone state transitions.
- `FE-TIMELINE-002`: Visibility filter mismatch.
- Symptoms: hidden zones still interactive or selected.
- Likely causes: hit-testing ignores visibility flags.
- First checks: hit-test filters and selected-zone reset logic.
### Media UI
- `FE-WAVEFORM-001`: Waveform fetch failed.
- Symptoms: warning banner with URL/error.
- Likely causes: backend unavailable, bad path encoding, CORS/proxy issue.
- First checks: backend health endpoint, waveform URL, network tab logs.
- `FE-PROJECT-001`: Project load mismatch.
- Symptoms: loaded media/transcript differs from saved data.
- Likely causes: schema drift, fallback URL mismatch.
- First checks: project schema fields, loadVideo/loadProject URL parity.
## Host / Bridge (Tauri)
- `HOST-BRIDGE-001`: Desktop API bridge unavailable.
- Symptoms: open/save/transcribe actions no-op or throw.
- Likely causes: bridge init error, host command mismatch.
- First checks: bridge initialization, command names, runtime environment.
- `HOST-WEBKIT-001`: Linux WebKit startup/render regression.
- Symptoms: noisy startup errors, UI load issues.
- Likely causes: CSP/font regressions, unsupported protocol calls.
- First checks: CSP config, remote font usage, bridge fallback behavior.
## Logging Guidance
When raising errors, include:
1. Error code.
2. Human message.
3. Correlation/request id.
4. Relevant paths/ids (sanitized).
5. Suggested first-check hints.
Example structured payload:
```json
{
"code": "BE-EXPORT-002",
"message": "FFmpeg export failed",
"requestId": "exp_20260415_001",
"context": {
"format": "mp4",
"mode": "reencode"
}
}
```

113
docs/spec-template.md Normal file
View File

@ -0,0 +1,113 @@
# Feature Spec Template
Use this template for every net-new feature and major behavior change.
## Metadata
- Spec ID: SPEC-YYYYMMDD-<short-name>
- Owner:
- Date:
- Status: draft | approved | in-progress | done
- Related issue/PR:
## Problem Statement
Describe the user problem in 2-5 sentences.
## User Story
As a <user type>, I want <capability>, so that <outcome>.
## Scope
### In Scope
1.
2.
3.
### Out of Scope
1.
2.
## Functional Requirements
1.
2.
3.
## Acceptance Criteria
1. Given <state>, when <action>, then <result>.
2. Given <state>, when <action>, then <result>.
3. Failure handling is deterministic and user-visible.
## UX Notes
- Entry points (toolbar/panel/command):
- Empty/loading/error states:
- Keyboard shortcuts / accessibility expectations:
## API And Data Contracts
- Endpoints impacted:
- Request/response changes:
- Backward compatibility plan:
- Project schema impact (`shared/project-schema.json`):
## Architecture Impact
- Frontend files/components likely affected:
- Backend routers/services likely affected:
- Tauri/bridge changes required:
## Risks
1.
2.
## Test Plan
### Unit Tests
1.
2.
### Integration Tests
1.
2.
### E2E / Smoke Tests
1.
2.
### Regression Tests
List known regressions this spec must prevent.
## Observability
- New logs/error codes:
- Metrics/traces needed:
- Diagnostics artifacts expected on failure:
## Rollout Plan
1. Development and internal validation.
2. Staged rollout or feature flag (if applicable).
3. Rollback path.
## Open Questions
1.
2.
## Definition Of Done
1. Acceptance criteria pass.
2. Tests added and green.
3. Docs/instructions updated.
4. Risks and assumptions recorded in PR summary.

26
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,26 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist', 'node_modules'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-explicit-any': 'off',
},
},
);

File diff suppressed because it is too large Load Diff

View File

@ -22,14 +22,20 @@
"zustand": "^5.0.0" "zustand": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4",
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2",
"@types/react": "^19.0.0", "@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0", "@vitejs/plugin-react": "^4.3.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
"typescript-eslint": "^8.58.2",
"vite": "^6.0.0" "vite": "^6.0.0"
} }
} }

View File

@ -22,6 +22,7 @@ import {
Scissors, Scissors,
VolumeX, VolumeX,
Volume2, Volume2,
SlidersHorizontal,
FilePlus2, FilePlus2,
RefreshCw, RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
@ -57,6 +58,7 @@ export default function App() {
selectedWordIndices, selectedWordIndices,
addCutRange, addCutRange,
addMuteRange, addMuteRange,
addGainRange,
} = useEditorStore(); } = useEditorStore();
const [activePanel, setActivePanel] = useState<Panel>(null); const [activePanel, setActivePanel] = useState<Panel>(null);
@ -64,6 +66,8 @@ export default function App() {
const [whisperModel, setWhisperModel] = useState('base'); const [whisperModel, setWhisperModel] = useState('base');
const [cutMode, setCutMode] = useState(false); const [cutMode, setCutMode] = useState(false);
const [muteMode, setMuteMode] = useState(false); const [muteMode, setMuteMode] = useState(false);
const [gainMode, setGainMode] = useState(false);
const [gainModeDb, setGainModeDb] = useState(3);
const [showReprocessConfirm, setShowReprocessConfirm] = useState(false); const [showReprocessConfirm, setShowReprocessConfirm] = useState(false);
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false); const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null); const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
@ -133,12 +137,13 @@ export default function App() {
useKeyboardShortcuts(); useKeyboardShortcuts();
// Handle Escape key to exit cut/mute modes // Handle Escape key to exit timeline zone modes
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
setCutMode(false); setCutMode(false);
setMuteMode(false); setMuteMode(false);
setGainMode(false);
} }
}; };
@ -236,6 +241,7 @@ export default function App() {
setManualPath(''); setManualPath('');
setCutMode(false); setCutMode(false);
setMuteMode(false); setMuteMode(false);
setGainMode(false);
}); });
}; };
@ -348,6 +354,7 @@ export default function App() {
// Toggle cut mode // Toggle cut mode
setCutMode(!cutMode); setCutMode(!cutMode);
setMuteMode(false); // Exit mute mode setMuteMode(false); // Exit mute mode
setGainMode(false); // Exit gain mode
} }
}; };
@ -362,6 +369,20 @@ export default function App() {
// Toggle mute mode // Toggle mute mode
setMuteMode(!muteMode); setMuteMode(!muteMode);
setCutMode(false); // Exit cut mode setCutMode(false); // Exit cut mode
setGainMode(false); // Exit gain mode
}
};
const handleGain = () => {
if (selectedWordIndices.length > 0) {
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
const startTime = words[sorted[0]].start;
const endTime = words[sorted[sorted.length - 1]].end;
addGainRange(startTime, endTime, gainModeDb);
} else {
setGainMode(!gainMode);
setCutMode(false);
setMuteMode(false);
} }
}; };
@ -518,6 +539,12 @@ export default function App() {
onClick={handleMute} onClick={handleMute}
active={muteMode} active={muteMode}
/> />
<ToolbarButton
icon={<SlidersHorizontal className="w-4 h-4" />}
label="Gain Zone"
onClick={handleGain}
active={gainMode}
/>
<ToolbarButton <ToolbarButton
icon={<Volume2 className="w-4 h-4" />} icon={<Volume2 className="w-4 h-4" />}
label="Volume" label="Volume"
@ -635,14 +662,21 @@ export default function App() {
{/* Waveform timeline */} {/* Waveform timeline */}
<div className="h-32 border-t border-editor-border shrink-0"> <div className="h-32 border-t border-editor-border shrink-0">
<WaveformTimeline cutMode={cutMode} muteMode={muteMode} /> <WaveformTimeline cutMode={cutMode} muteMode={muteMode} gainMode={gainMode} gainModeDb={gainModeDb} />
</div> </div>
</div> </div>
{/* Right panel (AI / Export / Settings) */} {/* Right panel (AI / Export / Settings) */}
{activePanel && ( {activePanel && (
<div className="w-80 border-l border-editor-border overflow-y-auto shrink-0"> <div className="w-80 border-l border-editor-border overflow-y-auto shrink-0">
{activePanel === 'volume' && <VolumePanel />} {activePanel === 'volume' && (
<VolumePanel
gainMode={gainMode}
onToggleGainMode={handleGain}
timelineGainDb={gainModeDb}
onTimelineGainDbChange={setGainModeDb}
/>
)}
{activePanel === 'silence' && <SilenceTrimmerPanel />} {activePanel === 'silence' && <SilenceTrimmerPanel />}
{activePanel === 'ai' && <AIPanel />} {activePanel === 'ai' && <AIPanel />}
{activePanel === 'export' && <ExportDialog />} {activePanel === 'export' && <ExportDialog />}

View File

@ -2,7 +2,19 @@ import { useMemo, useState } from 'react';
import { useEditorStore } from '../store/editorStore'; import { useEditorStore } from '../store/editorStore';
import { Trash2, Volume2 } from 'lucide-react'; import { Trash2, Volume2 } from 'lucide-react';
export default function VolumePanel() { interface VolumePanelProps {
gainMode: boolean;
onToggleGainMode: () => void;
timelineGainDb: number;
onTimelineGainDbChange: (gainDb: number) => void;
}
export default function VolumePanel({
gainMode,
onToggleGainMode,
timelineGainDb,
onTimelineGainDbChange,
}: VolumePanelProps) {
const { const {
words, words,
selectedWordIndices, selectedWordIndices,
@ -65,6 +77,34 @@ export default function VolumePanel() {
</div> </div>
</div> </div>
<div className="space-y-2 pt-1 border-t border-editor-border">
<label className="text-xs text-editor-text-muted font-medium">Timeline Gain Zone (dB)</label>
<div className="flex items-center gap-2">
<input
type="number"
min={-24}
max={24}
step={0.5}
value={timelineGainDb}
onChange={(e) => onTimelineGainDbChange(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
className="w-24 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
/>
<button
onClick={onToggleGainMode}
className={`px-3 py-1.5 text-xs rounded transition-colors ${
gainMode
? 'bg-editor-accent text-white hover:bg-editor-accent-hover'
: 'bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30'
}`}
>
{gainMode ? 'Exit Zone Mode' : 'Add Gain Zones'}
</button>
</div>
<p className="text-[11px] text-editor-text-muted">
In gain zone mode, drag on the timeline to create a zone with this dB value.
</p>
</div>
<div className="space-y-2 pt-1 border-t border-editor-border"> <div className="space-y-2 pt-1 border-t border-editor-border">
<label className="text-xs text-editor-text-muted font-medium">Selection Gain (dB)</label> <label className="text-xs text-editor-text-muted font-medium">Selection Gain (dB)</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@ -103,7 +103,17 @@ function pickInterval(pxPerSec: number): { major: number; minor: number } {
return { major, minor }; return { major, minor };
} }
export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boolean; muteMode: boolean }) { export default function WaveformTimeline({
cutMode,
muteMode,
gainMode,
gainModeDb,
}: {
cutMode: boolean;
muteMode: boolean;
gainMode: boolean;
gainModeDb: number;
}) {
const waveCanvasRef = useRef<HTMLCanvasElement>(null); const waveCanvasRef = useRef<HTMLCanvasElement>(null);
const headCanvasRef = useRef<HTMLCanvasElement>(null); const headCanvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -116,13 +126,17 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
const deletedRanges = useEditorStore((s) => s.deletedRanges); const deletedRanges = useEditorStore((s) => s.deletedRanges);
const cutRanges = useEditorStore((s) => s.cutRanges); const cutRanges = useEditorStore((s) => s.cutRanges);
const muteRanges = useEditorStore((s) => s.muteRanges); const muteRanges = useEditorStore((s) => s.muteRanges);
const gainRanges = useEditorStore((s) => s.gainRanges);
const setCurrentTime = useEditorStore((s) => s.setCurrentTime); const setCurrentTime = useEditorStore((s) => s.setCurrentTime);
const addCutRange = useEditorStore((s) => s.addCutRange); const addCutRange = useEditorStore((s) => s.addCutRange);
const addMuteRange = useEditorStore((s) => s.addMuteRange); const addMuteRange = useEditorStore((s) => s.addMuteRange);
const addGainRange = useEditorStore((s) => s.addGainRange);
const updateCutRange = useEditorStore((s) => s.updateCutRange); const updateCutRange = useEditorStore((s) => s.updateCutRange);
const updateMuteRange = useEditorStore((s) => s.updateMuteRange); const updateMuteRange = useEditorStore((s) => s.updateMuteRange);
const updateGainRangeBounds = useEditorStore((s) => s.updateGainRangeBounds);
const removeCutRange = useEditorStore((s) => s.removeCutRange); const removeCutRange = useEditorStore((s) => s.removeCutRange);
const removeMuteRange = useEditorStore((s) => s.removeMuteRange); const removeMuteRange = useEditorStore((s) => s.removeMuteRange);
const removeGainRange = useEditorStore((s) => s.removeGainRange);
const waveformDataRef = useRef<WaveformData | null>(null); const waveformDataRef = useRef<WaveformData | null>(null);
const zoomRef = useRef(1); // 1 = show all, >1 = zoomed in const zoomRef = useRef(1); // 1 = show all, >1 = zoomed in
@ -135,10 +149,13 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
const selectionStartRef = useRef<number | null>(null); const selectionStartRef = useRef<number | null>(null);
const [selectionStart, setSelectionStart] = useState<number | null>(null); const [selectionStart, setSelectionStart] = useState<number | null>(null);
const [selectionEnd, setSelectionEnd] = useState<number | null>(null); const [selectionEnd, setSelectionEnd] = useState<number | null>(null);
const [selectedZone, setSelectedZone] = useState<{type: 'cut' | 'mute', id: string} | null>(null); const [selectedZone, setSelectedZone] = useState<{type: 'cut' | 'mute' | 'gain', id: string} | null>(null);
const [editingZone, setEditingZone] = useState<{type: 'cut' | 'mute', id: string, edge: 'start' | 'end' | 'move'} | null>(null); const [editingZone, setEditingZone] = useState<{type: 'cut' | 'mute' | 'gain', id: string, edge: 'start' | 'end' | 'move'} | null>(null);
const [hoverCursor, setHoverCursor] = useState<string>('crosshair'); const [hoverCursor, setHoverCursor] = useState<string>('crosshair');
const editingZoneRef = useRef<{type: 'cut' | 'mute', id: string, edge: 'start' | 'end' | 'move'} | null>(null); const editingZoneRef = useRef<{type: 'cut' | 'mute' | 'gain', id: string, edge: 'start' | 'end' | 'move'} | null>(null);
const [showCutZones, setShowCutZones] = useState(true);
const [showMuteZones, setShowMuteZones] = useState(true);
const [showGainZones, setShowGainZones] = useState(true);
useEffect(() => { useEffect(() => {
if (!videoUrl || !videoPath) return; if (!videoUrl || !videoPath) return;
@ -304,7 +321,7 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
} }
// Draw cut ranges (red overlays) // Draw cut ranges (red overlays)
for (const range of cutRanges) { for (const range of showCutZones ? cutRanges : []) {
const x1 = (range.start - scroll) * pxPerSec; const x1 = (range.start - scroll) * pxPerSec;
const x2 = (range.end - scroll) * pxPerSec; const x2 = (range.end - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'cut' && selectedZone.id === range.id; const isSelected = selectedZone?.type === 'cut' && selectedZone.id === range.id;
@ -329,7 +346,7 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
} }
// Draw mute ranges (blue overlays) // Draw mute ranges (blue overlays)
for (const range of muteRanges) { for (const range of showMuteZones ? muteRanges : []) {
const x1 = (range.start - scroll) * pxPerSec; const x1 = (range.start - scroll) * pxPerSec;
const x2 = (range.end - scroll) * pxPerSec; const x2 = (range.end - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'mute' && selectedZone.id === range.id; const isSelected = selectedZone?.type === 'mute' && selectedZone.id === range.id;
@ -353,15 +370,45 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
} }
} }
// Draw selection overlay (when in cut/mute mode) // Draw gain ranges (amber overlays)
if ((cutMode || muteMode) && selectionStart !== null && selectionEnd !== null) { for (const range of showGainZones ? gainRanges : []) {
const x1 = (range.start - scroll) * pxPerSec;
const x2 = (range.end - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'gain' && selectedZone.id === range.id;
ctx.fillStyle = isSelected ? 'rgba(245, 158, 11, 0.55)' : 'rgba(245, 158, 11, 0.35)';
ctx.fillRect(x1, waveTop, x2 - x1, waveH);
if (isSelected) {
ctx.strokeStyle = '#f59e0b';
ctx.lineWidth = 2;
ctx.strokeRect(x1, waveTop, x2 - x1, waveH);
ctx.fillStyle = '#f59e0b';
ctx.beginPath();
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
ctx.fill();
ctx.beginPath();
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
ctx.fill();
}
}
// Draw selection overlay (when in zone mode)
if ((cutMode || muteMode || gainMode) && selectionStart !== null && selectionEnd !== null) {
const x1 = (Math.min(selectionStart, selectionEnd) - scroll) * pxPerSec; const x1 = (Math.min(selectionStart, selectionEnd) - scroll) * pxPerSec;
const x2 = (Math.max(selectionStart, selectionEnd) - scroll) * pxPerSec; const x2 = (Math.max(selectionStart, selectionEnd) - scroll) * pxPerSec;
ctx.fillStyle = cutMode ? 'rgba(239, 68, 68, 0.5)' : 'rgba(59, 130, 246, 0.5)'; const fillColor = cutMode
? 'rgba(239, 68, 68, 0.5)'
: muteMode
? 'rgba(59, 130, 246, 0.5)'
: 'rgba(245, 158, 11, 0.5)';
const strokeColor = cutMode ? '#ef4444' : muteMode ? '#3b82f6' : '#f59e0b';
ctx.fillStyle = fillColor;
ctx.fillRect(x1, waveTop, x2 - x1, waveH); ctx.fillRect(x1, waveTop, x2 - x1, waveH);
// Add border // Add border
ctx.strokeStyle = cutMode ? '#ef4444' : '#3b82f6'; ctx.strokeStyle = strokeColor;
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.strokeRect(x1, waveTop, x2 - x1, waveH); ctx.strokeRect(x1, waveTop, x2 - x1, waveH);
} }
@ -389,7 +436,21 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
ctx.lineTo(x, mid + max * amp); ctx.lineTo(x, mid + max * amp);
} }
ctx.stroke(); ctx.stroke();
}, [deletedRanges, cutRanges, muteRanges, selectionStart, selectionEnd, cutMode, muteMode, selectedZone]); }, [
deletedRanges,
cutRanges,
muteRanges,
gainRanges,
selectionStart,
selectionEnd,
cutMode,
muteMode,
gainMode,
selectedZone,
showCutZones,
showMuteZones,
showGainZones,
]);
// Keep the ref in sync with the latest drawStaticWaveform closure // Keep the ref in sync with the latest drawStaticWaveform closure
useEffect(() => { useEffect(() => {
@ -537,7 +598,7 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
const handleSize = forHover ? 6 : 8; // Smaller hit area for hover, larger for click const handleSize = forHover ? 6 : 8; // Smaller hit area for hover, larger for click
// Check cut ranges // Check cut ranges
for (const range of cutRanges) { for (const range of showCutZones ? cutRanges : []) {
const rangeX1 = (range.start - scroll) * pxPerSec; const rangeX1 = (range.start - scroll) * pxPerSec;
const rangeX2 = (range.end - scroll) * pxPerSec; const rangeX2 = (range.end - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'cut' && selectedZone.id === range.id; const isSelected = selectedZone?.type === 'cut' && selectedZone.id === range.id;
@ -579,7 +640,7 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
} }
// Check mute ranges // Check mute ranges
for (const range of muteRanges) { for (const range of showMuteZones ? muteRanges : []) {
const rangeX1 = (range.start - scroll) * pxPerSec; const rangeX1 = (range.start - scroll) * pxPerSec;
const rangeX2 = (range.end - scroll) * pxPerSec; const rangeX2 = (range.end - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'mute' && selectedZone.id === range.id; const isSelected = selectedZone?.type === 'mute' && selectedZone.id === range.id;
@ -620,8 +681,43 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
} }
} }
// Check gain ranges
for (const range of showGainZones ? gainRanges : []) {
const rangeX1 = (range.start - scroll) * pxPerSec;
const rangeX2 = (range.end - scroll) * pxPerSec;
const isSelected = selectedZone?.type === 'gain' && selectedZone.id === range.id;
if (forHover && isSelected) {
if (Math.abs(x - rangeX1) <= handleSize) {
return { type: 'gain' as const, id: range.id, edge: 'start' as const };
}
if (Math.abs(x - rangeX2) <= handleSize) {
return { type: 'gain' as const, id: range.id, edge: 'end' as const };
}
} else if (!forHover) {
if (isSelected) {
if (Math.abs(x - rangeX1) <= handleSize) {
return { type: 'gain' as const, id: range.id, edge: 'start' as const };
}
if (Math.abs(x - rangeX2) <= handleSize) {
return { type: 'gain' as const, id: range.id, edge: 'end' as const };
}
} else {
if (Math.abs(x - rangeX1) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) {
return { type: 'gain' as const, id: range.id, edge: 'start' as const };
}
if (Math.abs(x - rangeX2) <= handleSize && Math.abs(y - (waveTop + waveH / 2)) <= handleSize) {
return { type: 'gain' as const, id: range.id, edge: 'end' as const };
}
}
if (x >= rangeX1 && x <= rangeX2) {
return { type: 'gain' as const, id: range.id, edge: 'move' as const };
}
}
}
return null; return null;
}, [cutRanges, muteRanges, selectedZone]); }, [cutRanges, muteRanges, gainRanges, selectedZone, showCutZones, showMuteZones, showGainZones]);
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => { const handleMouseMove = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
if (isDragging) return; // Don't change cursor while dragging if (isDragging) return; // Don't change cursor while dragging
@ -656,7 +752,9 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
const startTime = clientXToTime(e.clientX); const startTime = clientXToTime(e.clientX);
const originalRange = zoneHit.type === 'cut' const originalRange = zoneHit.type === 'cut'
? cutRanges.find(r => r.id === zoneHit.id) ? cutRanges.find(r => r.id === zoneHit.id)
: muteRanges.find(r => r.id === zoneHit.id); : zoneHit.type === 'mute'
? muteRanges.find(r => r.id === zoneHit.id)
: gainRanges.find(r => r.id === zoneHit.id);
if (!originalRange) return; if (!originalRange) return;
@ -686,8 +784,10 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
if (newStart < newEnd) { if (newStart < newEnd) {
if (editingZoneRef.current.type === 'cut') { if (editingZoneRef.current.type === 'cut') {
updateCutRange(editingZoneRef.current.id, newStart, newEnd); updateCutRange(editingZoneRef.current.id, newStart, newEnd);
} else { } else if (editingZoneRef.current.type === 'mute') {
updateMuteRange(editingZoneRef.current.id, newStart, newEnd); updateMuteRange(editingZoneRef.current.id, newStart, newEnd);
} else {
updateGainRangeBounds(editingZoneRef.current.id, newStart, newEnd);
} }
} }
}; };
@ -710,7 +810,7 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
setSelectedZone(null); setSelectedZone(null);
setEditingZone(null); setEditingZone(null);
if (cutMode || muteMode) { if (cutMode || muteMode || gainMode) {
// Range selection mode // Range selection mode
const startTime = clientXToTime(e.clientX); const startTime = clientXToTime(e.clientX);
selectionStartRef.current = startTime; selectionStartRef.current = startTime;
@ -737,6 +837,8 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
addCutRange(start, end); addCutRange(start, end);
} else if (muteMode) { } else if (muteMode) {
addMuteRange(start, end); addMuteRange(start, end);
} else if (gainMode) {
addGainRange(start, end, gainModeDb);
} }
} }
@ -771,7 +873,7 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
window.addEventListener('mouseup', onUp); window.addEventListener('mouseup', onUp);
} }
}, },
[cutMode, muteMode, clientXToTime, seekToClientX, addCutRange, addMuteRange, selectionEnd, getZoneAtPosition], [cutMode, muteMode, gainMode, gainModeDb, clientXToTime, seekToClientX, addCutRange, addMuteRange, addGainRange, selectionEnd, getZoneAtPosition, cutRanges, muteRanges, gainRanges, duration, updateCutRange, updateMuteRange, updateGainRangeBounds],
); );
// Handle keyboard shortcuts for zone editing // Handle keyboard shortcuts for zone editing
@ -793,8 +895,10 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
e.stopImmediatePropagation(); e.stopImmediatePropagation();
if (selectedZone.type === 'cut') { if (selectedZone.type === 'cut') {
removeCutRange(selectedZone.id); removeCutRange(selectedZone.id);
} else { } else if (selectedZone.type === 'mute') {
removeMuteRange(selectedZone.id); removeMuteRange(selectedZone.id);
} else {
removeGainRange(selectedZone.id);
} }
setSelectedZone(null); setSelectedZone(null);
setEditingZone(null); setEditingZone(null);
@ -806,7 +910,14 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
// Capture phase ensures zone delete runs before app-level bubble shortcuts. // Capture phase ensures zone delete runs before app-level bubble shortcuts.
window.addEventListener('keydown', handleKeyDown, { capture: true }); window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
}, [selectedZone, removeCutRange, removeMuteRange]); }, [selectedZone, removeCutRange, removeMuteRange, removeGainRange]);
useEffect(() => {
if (!selectedZone) return;
if (selectedZone.type === 'cut' && !showCutZones) setSelectedZone(null);
if (selectedZone.type === 'mute' && !showMuteZones) setSelectedZone(null);
if (selectedZone.type === 'gain' && !showGainZones) setSelectedZone(null);
}, [selectedZone, showCutZones, showMuteZones, showGainZones]);
if (!videoUrl) { if (!videoUrl) {
return ( return (
@ -818,14 +929,42 @@ export default function WaveformTimeline({ cutMode, muteMode }: { cutMode: boole
return ( return (
<div ref={containerRef} className="w-full h-full flex flex-col"> <div ref={containerRef} className="w-full h-full flex flex-col">
<div className="flex items-center justify-between px-3 py-1 shrink-0"> <div className="flex items-center justify-between px-3 py-1 shrink-0 gap-3">
<div className="flex items-center gap-2">
<span className="text-[10px] text-editor-text-muted font-medium uppercase tracking-wider"> <span className="text-[10px] text-editor-text-muted font-medium uppercase tracking-wider">
Timeline Timeline
</span> </span>
{cutMode && <span className="text-[10px] text-red-400">Cut mode</span>}
{muteMode && <span className="text-[10px] text-blue-400">Mute mode</span>}
{gainMode && <span className="text-[10px] text-amber-400">Gain mode ({gainModeDb.toFixed(1)} dB)</span>}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowCutZones((v) => !v)}
className={`px-1.5 py-0.5 rounded text-[10px] border ${showCutZones ? 'border-red-500/60 text-red-300 bg-red-500/10' : 'border-editor-border text-editor-text-muted'}`}
title="Toggle cut zones"
>
Cut
</button>
<button
onClick={() => setShowMuteZones((v) => !v)}
className={`px-1.5 py-0.5 rounded text-[10px] border ${showMuteZones ? 'border-blue-500/60 text-blue-300 bg-blue-500/10' : 'border-editor-border text-editor-text-muted'}`}
title="Toggle mute zones"
>
Mute
</button>
<button
onClick={() => setShowGainZones((v) => !v)}
className={`px-1.5 py-0.5 rounded text-[10px] border ${showGainZones ? 'border-amber-500/60 text-amber-300 bg-amber-500/10' : 'border-editor-border text-editor-text-muted'}`}
title="Toggle gain zones"
>
Gain
</button>
<span className="text-[10px] text-editor-text-muted"> <span className="text-[10px] text-editor-text-muted">
Scroll · Ctrl+Scroll to zoom Scroll · Ctrl+Scroll to zoom
</span> </span>
</div> </div>
</div>
{audioError ? ( {audioError ? (
<div className="flex-1 flex items-start justify-center gap-2 text-editor-text-muted text-xs p-3 overflow-auto"> <div className="flex-1 flex items-start justify-center gap-2 text-editor-text-muted text-xs p-3 overflow-auto">
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 shrink-0" /> <AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 shrink-0" />

View File

@ -65,6 +65,7 @@ interface EditorActions {
addGainRange: (start: number, end: number, gainDb: number) => void; addGainRange: (start: number, end: number, gainDb: number) => void;
updateCutRange: (id: string, start: number, end: number) => void; updateCutRange: (id: string, start: number, end: number) => void;
updateMuteRange: (id: string, start: number, end: number) => void; updateMuteRange: (id: string, start: number, end: number) => void;
updateGainRangeBounds: (id: string, start: number, end: number) => void;
updateGainRange: (id: string, gainDb: number) => void; updateGainRange: (id: string, gainDb: number) => void;
removeCutRange: (id: string) => void; removeCutRange: (id: string) => void;
removeMuteRange: (id: string) => void; removeMuteRange: (id: string) => void;
@ -299,6 +300,15 @@ export const useEditorStore = create<EditorState & EditorActions>()(
}); });
}, },
updateGainRangeBounds: (id, start, end) => {
const { gainRanges } = get();
set({
gainRanges: gainRanges.map((r) =>
r.id === id ? { ...r, start, end } : r
),
});
},
updateGainRange: (id, gainDb) => { updateGainRange: (id, gainDb) => {
const { gainRanges } = get(); const { gainRanges } = get();
set({ set({

View File

@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/DevPanel.tsx","./src/components/ExportDialog.tsx","./src/components/SettingsPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/WaveformTimeline.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/dev-logger.ts","./src/lib/tauri-bridge.ts","./src/store/aiStore.ts","./src/store/editorStore.ts","./src/types/project.ts"],"errors":true,"version":"5.9.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/DevPanel.tsx","./src/components/ExportDialog.tsx","./src/components/SettingsPanel.tsx","./src/components/SilenceTrimmerPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/VolumePanel.tsx","./src/components/WaveformTimeline.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/dev-logger.ts","./src/lib/tauri-bridge.ts","./src/store/aiStore.ts","./src/store/editorStore.ts","./src/types/project.ts"],"version":"5.9.3"}

72
scripts/collect-diagnostics.sh Executable file
View File

@ -0,0 +1,72 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT_BASE="$ROOT_DIR/.diagnostics"
TS="$(date +%Y%m%d_%H%M%S)"
OUT_DIR="$OUT_BASE/diag_$TS"
mkdir -p "$OUT_DIR"
log() {
printf '[collect-diagnostics] %s\n' "$1"
}
capture_cmd() {
local name="$1"
shift
{
echo "# $name"
echo "# cmd: $*"
"$@"
} >"$OUT_DIR/$name.txt" 2>&1 || true
}
log "output: $OUT_DIR"
capture_cmd "env_uname" uname -a
capture_cmd "env_node_version" node --version
capture_cmd "env_npm_version" npm --version
capture_cmd "env_git_status" git -C "$ROOT_DIR" status --short
capture_cmd "env_git_head" git -C "$ROOT_DIR" rev-parse --short HEAD
if [[ -f "$ROOT_DIR/webview.log" ]]; then
cp "$ROOT_DIR/webview.log" "$OUT_DIR/webview.log" || true
fi
if [[ -f "$ROOT_DIR/backend.log" ]]; then
cp "$ROOT_DIR/backend.log" "$OUT_DIR/backend.log" || true
fi
if [[ -f "$ROOT_DIR/frontend/package.json" ]]; then
capture_cmd "frontend_lint" bash -lc "cd '$ROOT_DIR/frontend' && npm run -s lint"
capture_cmd "frontend_build" bash -lc "cd '$ROOT_DIR/frontend' && npm run -s build"
fi
PY=""
for p in \
"$ROOT_DIR/.venv312/bin/python3.12" \
"$ROOT_DIR/.venv312/bin/python" \
"$ROOT_DIR/.venv/bin/python3" \
"$ROOT_DIR/.venv/bin/python" \
"$ROOT_DIR/venv/bin/python3" \
"$ROOT_DIR/venv/bin/python"; do
if [[ -x "$p" ]]; then
PY="$p"
break
fi
done
if [[ -n "$PY" ]]; then
capture_cmd "backend_python_version" "$PY" --version
capture_cmd "backend_health_check" "$PY" -c "import importlib; importlib.import_module('backend.main'); print('backend import OK')"
fi
capture_cmd "list_recent_files" find "$ROOT_DIR" -maxdepth 2 -type f | head -n 200
if command -v tar >/dev/null 2>&1; then
tar -czf "$OUT_DIR.tar.gz" -C "$OUT_BASE" "diag_$TS"
log "archive: $OUT_DIR.tar.gz"
fi
log "done"

93
scripts/validate-all.sh Executable file
View File

@ -0,0 +1,93 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
log() {
printf '[validate-all] %s\n' "$1"
}
run_if_present() {
local cmd="$1"
if command -v ${cmd%% *} >/dev/null 2>&1; then
eval "$cmd"
return 0
fi
return 1
}
log "root: $ROOT_DIR"
cd "$ROOT_DIR"
log "Step 1/5: frontend dependency check"
if [[ ! -d "frontend/node_modules" ]]; then
log "frontend/node_modules missing; install with: cd frontend && npm install"
fi
log "Step 2/5: frontend lint"
if [[ -f "frontend/package.json" ]]; then
(
cd frontend
if npm run -s lint; then
log "frontend lint: OK"
else
log "frontend lint failed"
exit 1
fi
)
else
log "frontend/package.json not found; skipping"
fi
log "Step 3/5: frontend build"
if [[ -f "frontend/package.json" ]]; then
(
cd frontend
if npm run -s build; then
log "frontend build: OK"
else
log "frontend build failed"
exit 1
fi
)
fi
log "Step 4/5: backend syntax check"
PY=""
for p in \
"$ROOT_DIR/.venv312/bin/python3.12" \
"$ROOT_DIR/.venv312/bin/python" \
"$ROOT_DIR/.venv/bin/python3" \
"$ROOT_DIR/.venv/bin/python" \
"$ROOT_DIR/venv/bin/python3" \
"$ROOT_DIR/venv/bin/python"; do
if [[ -x "$p" ]]; then
PY="$p"
break
fi
done
if [[ -n "$PY" ]]; then
log "using python: $PY"
"$PY" -m py_compile "$ROOT_DIR/backend/main.py" "$ROOT_DIR/backend/routers/export.py"
log "backend syntax check: OK"
else
log "no project python found (.venv312/.venv/venv); skipping backend syntax check"
fi
log "Step 5/5: backend health import smoke"
if [[ -n "$PY" ]]; then
"$PY" - <<'PYCODE'
import importlib
mods = [
"backend.main",
"backend.routers.export",
"backend.services.video_editor",
]
for m in mods:
importlib.import_module(m)
print("backend import smoke: OK")
PYCODE
fi
log "Validation complete"