Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
## What does this PR do?

<!-- A clear description of the change and WHY it's needed. -->

## Type of change

- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)
- [ ] Documentation update
- [ ] Refactor / code cleanup
- [ ] Translation / i18n

## How to test

<!-- Steps for the reviewer to verify your changes work. -->

1.
2.
3.

## Checklist

- [ ] I've tested this locally
- [ ] Tests pass (`pytest tests/ -v`)
- [ ] I've added tests for new functionality (if applicable)
- [ ] The code follows the existing style
- [ ] I've updated documentation (if applicable)

## Screenshots (if UI changes)

<!-- Paste before/after screenshots here -->
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: CI

on:
pull_request:
branches: [main]
push:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install ffmpeg
run: sudo apt-get update && sudo apt-get install -y ffmpeg

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest

- name: Run tests
run: pytest tests/ -v --tb=short
130 changes: 130 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Contributing to FieldCut

Thanks for your interest in contributing! FieldCut is an open-source audio journalism tool, and we welcome contributions of all kinds — bug fixes, new features, documentation improvements, and translations.

## Getting Started

### 1. Fork & Clone

```bash
# Fork via GitHub UI, then:
git clone https://github.com/YOUR_USERNAME/FieldCut.git
cd FieldCut
```

### 2. Set Up Your Environment

```bash
# Create a virtual environment
python3 -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate

# Install dependencies
pip install -r requirements.txt
pip install pytest # for running tests

# Install ffmpeg (required for audio processing)
# macOS: brew install ffmpeg
# Ubuntu: sudo apt install ffmpeg
# Windows: https://ffmpeg.org/download.html
```

### 3. Configure API Keys (optional for most development)

Copy `.env.example` to `.env` and add your keys. You only need the OpenAI key if you're testing transcription features:

```bash
cp .env.example .env
# Edit .env with your keys
```

### 4. Run the App

```bash
python app.py
# Open http://localhost:5555
```

## Running Tests

```bash
# Run all tests
pytest tests/ -v

# Run a specific test file
pytest tests/test_routes.py -v

# Run a specific test
pytest tests/test_helpers.py::TestMergeSegments::test_single_segment_unchanged -v
```

Tests don't require API keys — external services are mocked or skipped. You do need `ffmpeg` installed for the pipeline tests.

## Making Changes

### Branch Naming

Create a branch from `main` with a descriptive name:

```bash
git checkout -b feature/waveform-zoom # New feature
git checkout -b fix/clip-boundary-bug # Bug fix
git checkout -b docs/setup-instructions # Documentation
```

### Commit Messages

Use [conventional commits](https://www.conventionalcommits.org/):

```
feat: add clip boundary snapping to word boundaries
fix: prevent crash when source file is deleted mid-cut
docs: add Arabic translation instructions
chore: update pytest to 8.x
refactor: extract audio processing into separate module
test: add tests for assembly gap calculation
```

### Code Style

- **Backend:** Python, follow existing patterns in `app.py`
- **Frontend:** Vanilla JS in `templates/index.html` and `static/lang.js` — no frameworks, no build step
- **Keep it simple** — this is a tool for journalists, not a tech demo
- **Error handling:** Use `friendly_error()` for user-facing errors, always provide a helpful message
- **Tests:** Add tests for new functionality

### Adding a New Language

FieldCut has built-in i18n. To add a language:

1. Open `static/lang.js`
2. Copy the `en` block
3. Translate all values (keep keys unchanged)
4. Set `_meta.name` to the language's own name and `_meta.dir` to `"ltr"` or `"rtl"`

## Submitting a Pull Request

1. Push your branch to your fork
2. Open a PR against `main` on the original repo
3. Fill in the PR template — describe what you changed and why
4. Make sure CI passes (tests run automatically)
5. Wait for review

### What Makes a Good PR

- **Small and focused** — one concern per PR
- **Tests included** — if you added or changed behavior
- **Description explains why** — not just what you changed
- **Screenshots** for UI changes

## Reporting Bugs

Open an issue with:
- What you expected to happen
- What actually happened
- Steps to reproduce
- Your OS, Python version, and browser

## Questions?

Open an issue or start a discussion. We're happy to help!
1 change: 1 addition & 0 deletions tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# FieldCut tests
98 changes: 98 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Shared fixtures for FieldCut tests."""

import os
import sys
import json
import shutil
import struct
import tempfile
import subprocess
import pytest

# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from app import app as flask_app


@pytest.fixture
def app(tmp_path):
"""Create a test Flask app with isolated project directory."""
flask_app.config["TESTING"] = True

# Redirect project directory to temp
import app as app_module
original_dir = app_module._active_project_dir
test_project_dir = str(tmp_path / "projects" / "_session")
app_module.set_project_dir(test_project_dir)

yield flask_app

# Restore
app_module.set_project_dir(original_dir)


@pytest.fixture
def client(app):
"""Flask test client."""
return app.test_client()


@pytest.fixture
def sample_wav(tmp_path):
"""Generate a short valid WAV file (1 second of silence) for testing."""
wav_path = str(tmp_path / "test_audio.wav")
# 1 second, 44100 Hz, mono, 16-bit PCM silence
sample_rate = 44100
duration = 1
n_samples = sample_rate * duration
samples = struct.pack(f"<{n_samples}h", *([0] * n_samples))

# WAV header
data_size = len(samples)
with open(wav_path, "wb") as f:
f.write(b"RIFF")
f.write(struct.pack("<I", 36 + data_size))
f.write(b"WAVE")
f.write(b"fmt ")
f.write(struct.pack("<I", 16)) # chunk size
f.write(struct.pack("<H", 1)) # PCM
f.write(struct.pack("<H", 1)) # mono
f.write(struct.pack("<I", sample_rate))
f.write(struct.pack("<I", sample_rate * 2)) # byte rate
f.write(struct.pack("<H", 2)) # block align
f.write(struct.pack("<H", 16)) # bits per sample
f.write(b"data")
f.write(struct.pack("<I", data_size))
f.write(samples)

return wav_path


@pytest.fixture
def sample_state(tmp_path):
"""Create a pre-populated state for testing clip/assembly operations."""
return {
"transcript": [
{"id": 0, "start": 0.0, "end": 2.5, "text": "Hello world", "speaker": "S1"},
{"id": 1, "start": 2.5, "end": 5.0, "text": "How are you", "speaker": "S2"},
{"id": 2, "start": 5.0, "end": 8.0, "text": "I am fine thanks", "speaker": "S1"},
],
"words": [
{"word": "Hello", "start": 0.0, "end": 0.5},
{"word": "world", "start": 0.5, "end": 1.0},
{"word": "How", "start": 2.5, "end": 2.8},
{"word": "are", "start": 2.8, "end": 3.0},
{"word": "you", "start": 3.0, "end": 3.5},
],
"text_clips": [],
"clips": [],
"narration_transcript": [],
"narration_words": [],
"narr_text_clips": [],
"narration": [],
"source_file": None,
"status": "transcribed",
"phase": 1,
"speaker_names": {"S1": "S1", "S2": "S2"},
}
Loading
Loading