forgot to add stuff
This commit is contained in:
23
.github/pull_request_template.md
vendored
Normal file
23
.github/pull_request_template.md
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
## Summary
|
||||
|
||||
Describe what changed and why.
|
||||
|
||||
## Spec Link (Required For Feature Changes)
|
||||
|
||||
- Spec file in `docs/specs/`: <!-- e.g. docs/specs/2026-04-15-speed-adjustment.md -->
|
||||
|
||||
## Acceptance Criteria Checklist
|
||||
|
||||
- [ ] Acceptance criteria reviewed against the linked spec
|
||||
- [ ] User-visible behavior verified for this change
|
||||
- [ ] Backward compatibility impact assessed
|
||||
|
||||
## Validation
|
||||
|
||||
- [ ] `./scripts/validate-all.sh` passes locally
|
||||
- [ ] Added/updated tests for changed behavior
|
||||
|
||||
## Risk And Rollback
|
||||
|
||||
- Risk level: Low / Medium / High
|
||||
- Rollback plan:
|
||||
58
.github/workflows/validate-all.yml
vendored
Normal file
58
.github/workflows/validate-all.yml
vendored
Normal file
@ -0,0 +1,58 @@
|
||||
name: Validate All
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
validate-all:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
frontend/package-lock.json
|
||||
package-lock.json
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Enforce feature spec policy (PR only)
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: ./scripts/check-feature-spec.sh
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: |
|
||||
cd frontend
|
||||
npm install
|
||||
|
||||
- name: Run validate-all
|
||||
env:
|
||||
SKIP_BACKEND_IMPORT_SMOKE: '1'
|
||||
run: ./scripts/validate-all.sh
|
||||
|
||||
- name: Collect diagnostics on failure
|
||||
if: failure()
|
||||
run: ./scripts/collect-diagnostics.sh
|
||||
|
||||
- name: Upload diagnostics artifact
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: diagnostics
|
||||
path: .diagnostics
|
||||
@ -18,7 +18,7 @@ Features are grouped by priority. Check off items as they are implemented.
|
||||
|
||||
- [x] [#006] **Volume / gain control** — per-selection or global audio gain slider. Every editor has this. Descript users constantly complain it's missing. Backend: `ffmpeg -af volume=Xdb`.
|
||||
|
||||
- [ ] [#007] **Speed adjustment** — slow down or speed up a selection or the whole clip. Backend: `ffmpeg -filter:v setpts` + `atempo`. Common use case: slightly speed up boring sections.
|
||||
- [ ] [#007] **Speed adjustment (4th zone type)** — add speed zones as the fourth editable timeline/transcript zone type (after cut, mute, gain), allowing slow/fast playback per range or globally. Backend: `ffmpeg -filter:v setpts` + `atempo`. Common use case: slightly speed up boring sections.
|
||||
|
||||
- [ ] [#008] **Cut preview** — before committing a delete, play what the audio will sound like with that section removed (pre-listen across the edit point). Pure frontend using Web Audio API — splice the AudioBuffer and play the join.
|
||||
|
||||
|
||||
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
57
backend/tests/test_cache_utils.py
Normal file
57
backend/tests/test_cache_utils.py
Normal file
@ -0,0 +1,57 @@
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from backend.utils import cache as cache_utils
|
||||
|
||||
|
||||
class CacheUtilsTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp_dir = tempfile.TemporaryDirectory()
|
||||
self._old_cache_dir = cache_utils.CACHE_DIR
|
||||
cache_utils.CACHE_DIR = Path(self._tmp_dir.name) / "cache"
|
||||
|
||||
self._work_dir = Path(self._tmp_dir.name) / "work"
|
||||
self._work_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._src_file = self._work_dir / "sample.txt"
|
||||
self._src_file.write_text("hello", encoding="utf-8")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
cache_utils.CACHE_DIR = self._old_cache_dir
|
||||
self._tmp_dir.cleanup()
|
||||
|
||||
def test_get_file_hash_returns_none_for_missing_file(self) -> None:
|
||||
missing = self._work_dir / "missing.txt"
|
||||
self.assertIsNone(cache_utils.get_file_hash(missing))
|
||||
|
||||
def test_save_and_load_round_trip(self) -> None:
|
||||
payload = {"value": 123, "ok": True}
|
||||
saved = cache_utils.save_to_cache(self._src_file, payload, model="m1", operation="transcribe")
|
||||
self.assertTrue(saved)
|
||||
|
||||
loaded = cache_utils.load_from_cache(self._src_file, model="m1", operation="transcribe")
|
||||
self.assertEqual(payload, loaded)
|
||||
|
||||
def test_load_from_cache_respects_max_age(self) -> None:
|
||||
payload = {"value": 999}
|
||||
self.assertTrue(cache_utils.save_to_cache(self._src_file, payload, operation="transcribe"))
|
||||
|
||||
time.sleep(0.02)
|
||||
expired = cache_utils.load_from_cache(self._src_file, operation="transcribe", max_age=0.001)
|
||||
self.assertIsNone(expired)
|
||||
|
||||
def test_clear_cache_deletes_files(self) -> None:
|
||||
self.assertTrue(cache_utils.save_to_cache(self._src_file, {"a": 1}, operation="transcribe"))
|
||||
self.assertTrue(cache_utils.save_to_cache(self._src_file, {"a": 2}, operation="summarize"))
|
||||
|
||||
deleted_count = cache_utils.clear_cache()
|
||||
self.assertGreaterEqual(deleted_count, 1)
|
||||
|
||||
size_bytes, file_count = cache_utils.get_cache_size()
|
||||
self.assertEqual(size_bytes, 0)
|
||||
self.assertEqual(file_count, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
451
backend/tests/test_router_contracts.py
Normal file
451
backend/tests/test_router_contracts.py
Normal file
@ -0,0 +1,451 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.main import app
|
||||
from routers import audio as audio_router
|
||||
|
||||
|
||||
class RouterContractTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.client = TestClient(app)
|
||||
|
||||
def setUp(self) -> None:
|
||||
audio_router._waveform_cache.clear()
|
||||
|
||||
def test_health_endpoint(self) -> None:
|
||||
res = self.client.get("/health")
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.json(), {"status": "ok"})
|
||||
|
||||
def test_file_endpoint_full_content(self) -> None:
|
||||
with TemporaryDirectory() as tmp:
|
||||
file_path = Path(tmp) / "sample.wav"
|
||||
file_path.write_bytes(b"abcdefghij")
|
||||
|
||||
res = self.client.get("/file", params={"path": str(file_path)})
|
||||
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.content, b"abcdefghij")
|
||||
self.assertEqual(res.headers.get("accept-ranges"), "bytes")
|
||||
|
||||
def test_file_endpoint_range_request(self) -> None:
|
||||
with TemporaryDirectory() as tmp:
|
||||
file_path = Path(tmp) / "sample.wav"
|
||||
file_path.write_bytes(b"abcdefghij")
|
||||
|
||||
res = self.client.get(
|
||||
"/file",
|
||||
params={"path": str(file_path)},
|
||||
headers={"Range": "bytes=2-5"},
|
||||
)
|
||||
|
||||
self.assertEqual(res.status_code, 206)
|
||||
self.assertEqual(res.content, b"cdef")
|
||||
self.assertEqual(res.headers.get("content-range"), "bytes 2-5/10")
|
||||
|
||||
def test_file_endpoint_missing_file(self) -> None:
|
||||
res = self.client.get("/file", params={"path": "/tmp/does-not-exist.wav"})
|
||||
|
||||
self.assertEqual(res.status_code, 404)
|
||||
self.assertIn("File not found", res.json()["detail"])
|
||||
|
||||
@patch("routers.audio.subprocess.run")
|
||||
def test_audio_waveform_cache_miss_then_hit(self, mock_subprocess_run) -> None:
|
||||
with TemporaryDirectory() as tmp:
|
||||
media_file = Path(tmp) / "input.mp4"
|
||||
media_file.write_bytes(b"fake-media")
|
||||
|
||||
def fake_ffmpeg(cmd, capture_output, text):
|
||||
out_path = Path(cmd[-1])
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_bytes(b"fake-wav")
|
||||
return SimpleNamespace(returncode=0, stderr="")
|
||||
|
||||
mock_subprocess_run.side_effect = fake_ffmpeg
|
||||
|
||||
res1 = self.client.get("/audio/waveform", params={"path": str(media_file)})
|
||||
self.assertEqual(res1.status_code, 200)
|
||||
self.assertTrue(res1.headers.get("content-type", "").startswith("audio/wav"))
|
||||
|
||||
res2 = self.client.get("/audio/waveform", params={"path": str(media_file)})
|
||||
self.assertEqual(res2.status_code, 200)
|
||||
self.assertTrue(res2.headers.get("content-type", "").startswith("audio/wav"))
|
||||
|
||||
self.assertEqual(mock_subprocess_run.call_count, 1)
|
||||
|
||||
@patch("routers.audio.subprocess.run")
|
||||
def test_audio_waveform_ffmpeg_failure_returns_500(self, mock_subprocess_run) -> None:
|
||||
with TemporaryDirectory() as tmp:
|
||||
media_file = Path(tmp) / "input.mp4"
|
||||
media_file.write_bytes(b"fake-media")
|
||||
|
||||
mock_subprocess_run.return_value = SimpleNamespace(returncode=1, stderr="ffmpeg failed")
|
||||
|
||||
res = self.client.get("/audio/waveform", params={"path": str(media_file)})
|
||||
|
||||
self.assertEqual(res.status_code, 500)
|
||||
self.assertIn("Failed to extract audio", res.json()["detail"])
|
||||
|
||||
@patch("routers.ai.detect_filler_words")
|
||||
def test_ai_filler_removal_contract(self, mock_detect_filler_words) -> None:
|
||||
mock_detect_filler_words.return_value = {
|
||||
"wordIndices": [2, 5],
|
||||
"fillerWords": [
|
||||
{"index": 2, "word": "um", "reason": "filler"},
|
||||
{"index": 5, "word": "uh", "reason": "filler"},
|
||||
],
|
||||
}
|
||||
|
||||
payload = {
|
||||
"transcript": "Hello um world uh",
|
||||
"words": [
|
||||
{"index": 0, "word": "Hello"},
|
||||
{"index": 1, "word": "um"},
|
||||
{"index": 2, "word": "world"},
|
||||
],
|
||||
"provider": "ollama",
|
||||
"model": "llama3",
|
||||
}
|
||||
res = self.client.post("/ai/filler-removal", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertIn("wordIndices", res.json())
|
||||
mock_detect_filler_words.assert_called_once()
|
||||
|
||||
@patch("routers.ai.detect_filler_words")
|
||||
def test_ai_filler_removal_error_returns_500(self, mock_detect_filler_words) -> None:
|
||||
mock_detect_filler_words.side_effect = RuntimeError("ai-filler-fail")
|
||||
|
||||
payload = {
|
||||
"transcript": "Hello world",
|
||||
"words": [{"index": 0, "word": "Hello"}],
|
||||
"provider": "ollama",
|
||||
}
|
||||
res = self.client.post("/ai/filler-removal", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 500)
|
||||
self.assertEqual(res.json()["detail"], "ai-filler-fail")
|
||||
|
||||
@patch("routers.ai.create_clip_suggestion")
|
||||
def test_ai_create_clip_contract(self, mock_create_clip_suggestion) -> None:
|
||||
mock_create_clip_suggestion.return_value = {
|
||||
"title": "Best Moment",
|
||||
"startWordIndex": 10,
|
||||
"endWordIndex": 40,
|
||||
"startTime": 12.3,
|
||||
"endTime": 48.8,
|
||||
"reason": "Strong hook",
|
||||
}
|
||||
|
||||
payload = {
|
||||
"transcript": "Long transcript...",
|
||||
"words": [{"index": 0, "word": "hello"}],
|
||||
"provider": "ollama",
|
||||
"target_duration": 45,
|
||||
}
|
||||
res = self.client.post("/ai/create-clip", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.json()["title"], "Best Moment")
|
||||
mock_create_clip_suggestion.assert_called_once()
|
||||
|
||||
@patch("routers.ai.create_clip_suggestion")
|
||||
def test_ai_create_clip_error_returns_500(self, mock_create_clip_suggestion) -> None:
|
||||
mock_create_clip_suggestion.side_effect = RuntimeError("ai-clip-fail")
|
||||
|
||||
payload = {
|
||||
"transcript": "Hello world",
|
||||
"words": [{"index": 0, "word": "hello"}],
|
||||
"provider": "ollama",
|
||||
}
|
||||
res = self.client.post("/ai/create-clip", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 500)
|
||||
self.assertEqual(res.json()["detail"], "ai-clip-fail")
|
||||
|
||||
@patch("routers.ai.AIProvider.list_ollama_models")
|
||||
def test_ai_ollama_models_contract(self, mock_list_ollama_models) -> None:
|
||||
mock_list_ollama_models.return_value = ["llama3", "qwen2.5"]
|
||||
|
||||
res = self.client.get("/ai/ollama-models?base_url=http://localhost:11434")
|
||||
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.json(), {"models": ["llama3", "qwen2.5"]})
|
||||
mock_list_ollama_models.assert_called_once_with("http://localhost:11434")
|
||||
|
||||
@patch("routers.ai.AIProvider.list_ollama_models")
|
||||
def test_ai_ollama_models_unhandled_error_returns_500(self, mock_list_ollama_models) -> None:
|
||||
mock_list_ollama_models.side_effect = RuntimeError("ollama-unreachable")
|
||||
|
||||
local_client = TestClient(app, raise_server_exceptions=False)
|
||||
res = local_client.get("/ai/ollama-models")
|
||||
|
||||
self.assertEqual(res.status_code, 500)
|
||||
|
||||
@patch("routers.transcribe.transcribe_audio")
|
||||
def test_transcribe_success(self, mock_transcribe) -> None:
|
||||
mock_transcribe.return_value = {"words": [], "segments": [], "language": "en"}
|
||||
|
||||
payload = {
|
||||
"file_path": "/tmp/input.wav",
|
||||
"model": "base",
|
||||
"use_gpu": False,
|
||||
"use_cache": True,
|
||||
}
|
||||
res = self.client.post("/transcribe", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.json(), {"words": [], "segments": [], "language": "en"})
|
||||
mock_transcribe.assert_called_once()
|
||||
|
||||
@patch("routers.transcribe.diarize_and_label")
|
||||
@patch("routers.transcribe.transcribe_audio")
|
||||
def test_transcribe_with_diarization(self, mock_transcribe, mock_diarize) -> None:
|
||||
mock_transcribe.return_value = {"words": [{"word": "hi", "start": 0.0, "end": 0.2}], "segments": []}
|
||||
mock_diarize.return_value = {"words": [{"word": "hi", "start": 0.0, "end": 0.2, "speaker": "SPEAKER_00"}], "segments": []}
|
||||
|
||||
payload = {
|
||||
"file_path": "/tmp/input.wav",
|
||||
"model": "base",
|
||||
"diarize": True,
|
||||
"hf_token": "hf_xxx",
|
||||
"num_speakers": 2,
|
||||
}
|
||||
res = self.client.post("/transcribe", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertIn("words", res.json())
|
||||
mock_transcribe.assert_called_once()
|
||||
mock_diarize.assert_called_once()
|
||||
|
||||
@patch("routers.transcribe.transcribe_audio")
|
||||
def test_transcribe_file_not_found_returns_404(self, mock_transcribe) -> None:
|
||||
mock_transcribe.side_effect = FileNotFoundError("missing")
|
||||
|
||||
payload = {
|
||||
"file_path": "/tmp/missing.wav",
|
||||
"model": "base",
|
||||
}
|
||||
res = self.client.post("/transcribe", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 404)
|
||||
self.assertIn("File not found", res.json()["detail"])
|
||||
|
||||
@patch("routers.transcribe.transcribe_audio")
|
||||
def test_transcribe_runtime_failure_returns_500(self, mock_transcribe) -> None:
|
||||
mock_transcribe.side_effect = RuntimeError("boom")
|
||||
|
||||
payload = {
|
||||
"file_path": "/tmp/in.wav",
|
||||
"model": "base",
|
||||
}
|
||||
res = self.client.post("/transcribe", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 500)
|
||||
self.assertEqual(res.json()["detail"], "boom")
|
||||
|
||||
@patch("routers.captions.generate_srt")
|
||||
def test_captions_plain_response(self, mock_generate_srt) -> None:
|
||||
mock_generate_srt.return_value = "1\n00:00:00,000 --> 00:00:01,000\nHello\n"
|
||||
|
||||
payload = {
|
||||
"words": [{"word": "Hello", "start": 0.0, "end": 1.0}],
|
||||
"format": "srt",
|
||||
}
|
||||
res = self.client.post("/captions", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertIn("Hello", res.text)
|
||||
mock_generate_srt.assert_called_once()
|
||||
|
||||
@patch("routers.captions.save_captions")
|
||||
@patch("routers.captions.generate_srt")
|
||||
def test_captions_save_output_path(self, mock_generate_srt, mock_save) -> None:
|
||||
mock_generate_srt.return_value = "1\n00:00:00,000 --> 00:00:01,000\nHello\n"
|
||||
mock_save.return_value = "/tmp/out.srt"
|
||||
|
||||
payload = {
|
||||
"words": [{"word": "Hello", "start": 0.0, "end": 1.0}],
|
||||
"format": "srt",
|
||||
"output_path": "/tmp/out.srt",
|
||||
}
|
||||
res = self.client.post("/captions", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.json(), {"status": "ok", "output_path": "/tmp/out.srt"})
|
||||
mock_save.assert_called_once()
|
||||
|
||||
def test_captions_unknown_format_returns_400(self) -> None:
|
||||
payload = {
|
||||
"words": [{"word": "Hello", "start": 0.0, "end": 1.0}],
|
||||
"format": "txt",
|
||||
}
|
||||
res = self.client.post("/captions", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertIn("Unknown format", res.json()["detail"])
|
||||
|
||||
@patch("routers.captions.generate_srt")
|
||||
def test_captions_internal_error_returns_500(self, mock_generate_srt) -> None:
|
||||
mock_generate_srt.side_effect = RuntimeError("caption-fail")
|
||||
|
||||
payload = {
|
||||
"words": [{"word": "Hello", "start": 0.0, "end": 1.0}],
|
||||
"format": "srt",
|
||||
}
|
||||
res = self.client.post("/captions", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 500)
|
||||
self.assertEqual(res.json()["detail"], "caption-fail")
|
||||
|
||||
@patch("routers.audio.is_deepfilter_available")
|
||||
@patch("routers.audio.clean_audio")
|
||||
def test_audio_clean_contract(self, mock_clean_audio, mock_is_deepfilter_available) -> None:
|
||||
mock_clean_audio.return_value = "/tmp/cleaned.wav"
|
||||
mock_is_deepfilter_available.return_value = True
|
||||
|
||||
payload = {
|
||||
"input_path": "/tmp/in.wav",
|
||||
"output_path": "/tmp/cleaned.wav",
|
||||
}
|
||||
res = self.client.post("/audio/clean", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 200)
|
||||
body = res.json()
|
||||
self.assertEqual(body["status"], "ok")
|
||||
self.assertEqual(body["output_path"], "/tmp/cleaned.wav")
|
||||
self.assertEqual(body["engine"], "deepfilternet")
|
||||
|
||||
@patch("routers.audio.clean_audio")
|
||||
def test_audio_clean_error_returns_500(self, mock_clean_audio) -> None:
|
||||
mock_clean_audio.side_effect = RuntimeError("clean-fail")
|
||||
|
||||
payload = {
|
||||
"input_path": "/tmp/in.wav",
|
||||
"output_path": "/tmp/cleaned.wav",
|
||||
}
|
||||
res = self.client.post("/audio/clean", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 500)
|
||||
self.assertEqual(res.json()["detail"], "clean-fail")
|
||||
|
||||
@patch("routers.audio.detect_silence_ranges")
|
||||
def test_audio_detect_silence_contract(self, mock_detect_silence_ranges) -> None:
|
||||
mock_detect_silence_ranges.return_value = [{"start": 1.2, "end": 2.1, "duration": 0.9}]
|
||||
|
||||
payload = {
|
||||
"input_path": "/tmp/in.wav",
|
||||
"min_silence_ms": 500,
|
||||
"silence_db": -35.0,
|
||||
}
|
||||
res = self.client.post("/audio/detect-silence", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 200)
|
||||
body = res.json()
|
||||
self.assertEqual(body["status"], "ok")
|
||||
self.assertEqual(body["count"], 1)
|
||||
self.assertEqual(len(body["ranges"]), 1)
|
||||
|
||||
@patch("routers.audio.detect_silence_ranges")
|
||||
def test_audio_detect_silence_error_returns_500(self, mock_detect_silence_ranges) -> None:
|
||||
mock_detect_silence_ranges.side_effect = RuntimeError("silence-fail")
|
||||
|
||||
payload = {
|
||||
"input_path": "/tmp/in.wav",
|
||||
"min_silence_ms": 500,
|
||||
"silence_db": -35.0,
|
||||
}
|
||||
res = self.client.post("/audio/detect-silence", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 500)
|
||||
self.assertEqual(res.json()["detail"], "silence-fail")
|
||||
|
||||
@patch("routers.audio.is_deepfilter_available")
|
||||
def test_audio_capabilities_contract(self, mock_is_deepfilter_available) -> None:
|
||||
mock_is_deepfilter_available.return_value = False
|
||||
|
||||
res = self.client.get("/audio/capabilities")
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.json(), {"deepfilternet_available": False})
|
||||
|
||||
@patch("routers.export.export_stream_copy")
|
||||
def test_export_fast_contract(self, mock_export_stream_copy) -> None:
|
||||
mock_export_stream_copy.return_value = "/tmp/out.mp4"
|
||||
|
||||
payload = {
|
||||
"input_path": "/tmp/in.mp4",
|
||||
"output_path": "/tmp/out.mp4",
|
||||
"keep_segments": [{"start": 0.0, "end": 2.0}],
|
||||
"mode": "fast",
|
||||
"captions": "none",
|
||||
}
|
||||
res = self.client.post("/export", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.json(), {"status": "ok", "output_path": "/tmp/out.mp4"})
|
||||
mock_export_stream_copy.assert_called_once()
|
||||
|
||||
@patch("routers.export.save_captions")
|
||||
@patch("routers.export.generate_srt")
|
||||
@patch("routers.export.export_stream_copy")
|
||||
def test_export_sidecar_caption_contract(self, mock_export_stream_copy, mock_generate_srt, mock_save_captions) -> None:
|
||||
mock_export_stream_copy.return_value = "/tmp/out.mp4"
|
||||
mock_generate_srt.return_value = "1\n00:00:00,000 --> 00:00:01,000\nHello\n"
|
||||
|
||||
payload = {
|
||||
"input_path": "/tmp/in.mp4",
|
||||
"output_path": "/tmp/out.mp4",
|
||||
"keep_segments": [{"start": 0.0, "end": 2.0}],
|
||||
"mode": "fast",
|
||||
"captions": "sidecar",
|
||||
"words": [{"word": "Hello", "start": 0.0, "end": 1.0}],
|
||||
"deleted_indices": [],
|
||||
}
|
||||
res = self.client.post("/export", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 200)
|
||||
body = res.json()
|
||||
self.assertEqual(body["status"], "ok")
|
||||
self.assertEqual(body["output_path"], "/tmp/out.mp4")
|
||||
self.assertEqual(body["srt_path"], "/tmp/out.srt")
|
||||
mock_save_captions.assert_called_once()
|
||||
|
||||
def test_export_missing_segments_returns_400(self) -> None:
|
||||
payload = {
|
||||
"input_path": "/tmp/in.mp4",
|
||||
"output_path": "/tmp/out.mp4",
|
||||
"keep_segments": [],
|
||||
"mode": "fast",
|
||||
"captions": "none",
|
||||
}
|
||||
res = self.client.post("/export", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 400)
|
||||
self.assertIn("No segments to export", res.json()["detail"])
|
||||
|
||||
@patch("routers.export.export_stream_copy")
|
||||
def test_export_runtime_error_returns_500(self, mock_export_stream_copy) -> None:
|
||||
mock_export_stream_copy.side_effect = RuntimeError("export-fail")
|
||||
|
||||
payload = {
|
||||
"input_path": "/tmp/in.mp4",
|
||||
"output_path": "/tmp/out.mp4",
|
||||
"keep_segments": [{"start": 0.0, "end": 2.0}],
|
||||
"mode": "fast",
|
||||
"captions": "none",
|
||||
}
|
||||
res = self.client.post("/export", json=payload)
|
||||
|
||||
self.assertEqual(res.status_code, 500)
|
||||
self.assertEqual(res.json()["detail"], "export-fail")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
18
docs/specs/README.md
Normal file
18
docs/specs/README.md
Normal file
@ -0,0 +1,18 @@
|
||||
# Feature Specs
|
||||
|
||||
Place one feature spec document in this folder for each feature or major behavior change.
|
||||
|
||||
Use [docs/spec-template.md](../spec-template.md) as the canonical template.
|
||||
|
||||
Recommended naming format:
|
||||
|
||||
- `YYYY-MM-DD-short-feature-name.md`
|
||||
|
||||
Examples:
|
||||
|
||||
- `2026-04-15-gain-zones-and-visibility-filters.md`
|
||||
- `2026-04-16-speed-adjustment.md`
|
||||
|
||||
CI policy:
|
||||
|
||||
- Pull requests that change app code are expected to include at least one changed spec file in this folder.
|
||||
237
frontend/src/components/ZoneEditor.tsx
Normal file
237
frontend/src/components/ZoneEditor.tsx
Normal file
@ -0,0 +1,237 @@
|
||||
import { useState } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { Trash2, Scissors, Volume2, SlidersHorizontal } from 'lucide-react';
|
||||
|
||||
export default function ZoneEditor() {
|
||||
const [viewMode, setViewMode] = useState<'all' | 'cut' | 'mute' | 'gain'>('all');
|
||||
|
||||
const {
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
gainRanges,
|
||||
globalGainDb,
|
||||
setGlobalGainDb,
|
||||
removeCutRange,
|
||||
removeMuteRange,
|
||||
removeGainRange,
|
||||
updateGainRange,
|
||||
} = useEditorStore();
|
||||
|
||||
const totalZones = cutRanges.length + muteRanges.length + gainRanges.length;
|
||||
|
||||
const getZoneTypeColor = (type: 'cut' | 'mute' | 'gain') => {
|
||||
switch (type) {
|
||||
case 'cut':
|
||||
return 'border-red-500/40 bg-red-500/5';
|
||||
case 'mute':
|
||||
return 'border-orange-500/40 bg-orange-500/5';
|
||||
case 'gain':
|
||||
return 'border-amber-500/40 bg-amber-500/5';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<div className="w-4 h-4 rounded bg-editor-accent/30" />
|
||||
Zone Editor
|
||||
</h3>
|
||||
<p className="text-xs text-editor-text-muted">
|
||||
Manage all timeline zones ({totalZones} total)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex items-center gap-1 rounded bg-editor-surface border border-editor-border p-1">
|
||||
<button
|
||||
onClick={() => setViewMode('all')}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
viewMode === 'all'
|
||||
? 'bg-editor-accent text-white'
|
||||
: 'text-editor-text-muted hover:text-editor-text'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('cut')}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
viewMode === 'cut'
|
||||
? 'bg-red-500/30 text-red-500'
|
||||
: 'text-editor-text-muted hover:text-editor-text'
|
||||
}`}
|
||||
>
|
||||
Cut
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('mute')}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
viewMode === 'mute'
|
||||
? 'bg-orange-500/30 text-orange-500'
|
||||
: 'text-editor-text-muted hover:text-editor-text'
|
||||
}`}
|
||||
>
|
||||
Mute
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('gain')}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
viewMode === 'gain'
|
||||
? 'bg-amber-500/30 text-amber-500'
|
||||
: 'text-editor-text-muted hover:text-editor-text'
|
||||
}`}
|
||||
>
|
||||
Gain
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalZones === 0 ? (
|
||||
<div className="p-4 rounded border border-dashed border-editor-border text-center">
|
||||
<p className="text-xs text-editor-text-muted">
|
||||
No zones yet. Create zones from the toolbar or by highlighting words.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Cut Zones */}
|
||||
{(viewMode === 'all' || viewMode === 'cut') && cutRanges.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-red-500/80 flex items-center gap-2">
|
||||
<Scissors className="w-3.5 h-3.5" />
|
||||
Cut Zones ({cutRanges.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{cutRanges.map((range) => (
|
||||
<div
|
||||
key={range.id}
|
||||
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group ${getZoneTypeColor('cut')}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{range.start.toFixed(2)}s – {range.end.toFixed(2)}s
|
||||
</div>
|
||||
<div className="text-editor-text-muted text-[10px]">{range.id}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeCutRange(range.id)}
|
||||
className="p-1 rounded hover:bg-red-500/20 text-red-500/70 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Delete cut zone"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mute Zones */}
|
||||
{(viewMode === 'all' || viewMode === 'mute') && muteRanges.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-orange-500/80 flex items-center gap-2">
|
||||
<Volume2 className="w-3.5 h-3.5" />
|
||||
Mute Zones ({muteRanges.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{muteRanges.map((range) => (
|
||||
<div
|
||||
key={range.id}
|
||||
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group ${getZoneTypeColor('mute')}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{range.start.toFixed(2)}s – {range.end.toFixed(2)}s
|
||||
</div>
|
||||
<div className="text-editor-text-muted text-[10px]">{range.id}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeMuteRange(range.id)}
|
||||
className="p-1 rounded hover:bg-orange-500/20 text-orange-500/70 hover:text-orange-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Delete mute zone"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gain Zones */}
|
||||
{(viewMode === 'all' || viewMode === 'gain') && gainRanges.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-amber-500/80 flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-3.5 h-3.5" />
|
||||
Gain Zones ({gainRanges.length})
|
||||
</div>
|
||||
|
||||
{/* Global Gain Slider */}
|
||||
<div className="px-2 py-2 rounded border border-amber-500/20 bg-amber-500/5 space-y-2">
|
||||
<label className="text-xs text-editor-text-muted font-medium">Global Gain</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={-24}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={globalGainDb}
|
||||
onChange={(e) => setGlobalGainDb(Number(e.target.value))}
|
||||
className="flex-1 h-1.5"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={-24}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={globalGainDb}
|
||||
onChange={(e) => setGlobalGainDb(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
|
||||
className="w-14 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||
/>
|
||||
<span className="text-xs text-amber-500/80 font-medium w-6 text-right">dB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{gainRanges.map((range) => (
|
||||
<div
|
||||
key={range.id}
|
||||
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group ${getZoneTypeColor('gain')}`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{range.start.toFixed(2)}s – {range.end.toFixed(2)}s
|
||||
</div>
|
||||
<div className="text-editor-text-muted text-[10px]">
|
||||
{range.gainDb > 0 ? '+' : ''}{range.gainDb.toFixed(1)} dB
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={-24}
|
||||
max={24}
|
||||
step={0.5}
|
||||
value={range.gainDb}
|
||||
onChange={(e) => updateGainRange(range.id, Number(e.target.value) || 0)}
|
||||
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||
title="Gain dB"
|
||||
/>
|
||||
<button
|
||||
onClick={() => removeGainRange(range.id)}
|
||||
className="p-1 rounded hover:bg-amber-500/20 text-amber-500/70 hover:text-amber-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Delete gain zone"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
frontend/src/store/editorStore.test.ts
Normal file
32
frontend/src/store/editorStore.test.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import { useEditorStore } from './editorStore';
|
||||
|
||||
|
||||
describe('editorStore basics', () => {
|
||||
beforeEach(() => {
|
||||
useEditorStore.getState().reset();
|
||||
});
|
||||
|
||||
test('clamps global gain to valid bounds', () => {
|
||||
const state = useEditorStore.getState();
|
||||
|
||||
state.setGlobalGainDb(100);
|
||||
expect(useEditorStore.getState().globalGainDb).toBe(24);
|
||||
|
||||
state.setGlobalGainDb(-100);
|
||||
expect(useEditorStore.getState().globalGainDb).toBe(-24);
|
||||
});
|
||||
|
||||
test('adds gain range to store', () => {
|
||||
const state = useEditorStore.getState();
|
||||
|
||||
state.addGainRange(1.2, 2.4, 3.5);
|
||||
|
||||
const ranges = useEditorStore.getState().gainRanges;
|
||||
expect(ranges.length).toBe(1);
|
||||
expect(ranges[0].start).toBe(1.2);
|
||||
expect(ranges[0].end).toBe(2.4);
|
||||
expect(ranges[0].gainDb).toBe(3.5);
|
||||
});
|
||||
});
|
||||
63
scripts/check-feature-spec.sh
Executable file
63
scripts/check-feature-spec.sh
Executable file
@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
log() {
|
||||
printf '[check-feature-spec] %s\n' "$1"
|
||||
}
|
||||
|
||||
BASE_SHA="${BASE_SHA:-}"
|
||||
if [[ -z "$BASE_SHA" ]]; then
|
||||
if git rev-parse --verify origin/main >/dev/null 2>&1; then
|
||||
BASE_SHA="$(git merge-base origin/main HEAD)"
|
||||
else
|
||||
log "No BASE_SHA and origin/main unavailable; skipping check."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! git rev-parse --verify "$BASE_SHA" >/dev/null 2>&1; then
|
||||
log "BASE_SHA '$BASE_SHA' not found; skipping check."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
changed_files="$(git diff --name-only "$BASE_SHA"...HEAD)"
|
||||
if [[ -z "$changed_files" ]]; then
|
||||
log "No changed files; nothing to enforce."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
code_changed=0
|
||||
spec_changed=0
|
||||
|
||||
while IFS= read -r path; do
|
||||
[[ -z "$path" ]] && continue
|
||||
|
||||
case "$path" in
|
||||
frontend/src/*|backend/*|src-tauri/src/*|shared/project-schema.json)
|
||||
code_changed=1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
docs/specs/*.md)
|
||||
spec_changed=1
|
||||
;;
|
||||
esac
|
||||
done <<< "$changed_files"
|
||||
|
||||
if [[ "$code_changed" -eq 0 ]]; then
|
||||
log "No app code changes detected; spec file not required."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$spec_changed" -eq 1 ]]; then
|
||||
log "Spec requirement satisfied."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log "Code changes detected without spec update in docs/specs/."
|
||||
log "Add or update at least one spec file using docs/spec-template.md."
|
||||
exit 1
|
||||
Reference in New Issue
Block a user