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
33 changes: 33 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Tests

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

jobs:
test:
name: Tests (${{ matrix.os }}, py${{ matrix.python-version }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.11", "3.13"]
env:
QT_QPA_PLATFORM: offscreen
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install Qt runtime libraries
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libegl1 libgl1 libxkbcommon0 libpulse0 libpulse-mainloop-glib0
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements.txt pytest
- name: Run tests
run: python -m pytest -v
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
*.srt
*.csv
*.txt
!requirements*.txt
!characters.example.txt
*.vat
*.exe

Expand Down Expand Up @@ -39,3 +41,8 @@ Thumbs.db
*.backup
*.spec
__pycache__/

# Per-user state written by the app
settings.json
.pytest_cache/
.pytest_tmp/
27 changes: 20 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
- **Editable Note column:** Fill per-line notes either from right panel Note input or directly in the table.
- **Player controls:** Built-in play/pause button, timeline slider, and current/total time display.
- **Coding template options:** Optional inheritance of Listener/Target from the previous line.
- **Autosave + restore:** Periodic autosave with optional restore prompt on startup.
- **Unsaved-change protection:** Save/discard/cancel prompt when closing.
- **Autosave + restore:** Every 15 seconds unsaved work is copied to a hidden recovery file (`.<name>.autosave.vat`) next to the project, or next to the subtitles before the first save. Your project file is only written when you save. After a crash, the next session offers to restore the recovery file.
- **Unsaved-change protection:** Save/discard/cancel prompt when closing. Choosing not to save discards the changes and the recovery file.
- **Safer writes:** Manual save and autosave use atomic write replacement to reduce data corruption risk.
- **CSV import/export:** Save and load annotation progress in UTF-8 BOM CSV.

Expand All @@ -39,7 +39,7 @@
1. **Clone the Repository**
```
git clone https://github.com/MediaCompLab/ciga-annotator.git
cd ciga/ciga-annotator
cd ciga-annotator
```

2. **Install Dependencies**
Expand All @@ -49,22 +49,26 @@

## Getting Started

1. **Run the Application**
1. **Run the Application** (from the repository root)
```
python src/main.py
python run.py
```

If you are on Windows and get a Qt DLL load error in a Conda terminal, run with your system Python explicitly, for example:
```
c:/python313/python.exe src/main.py
c:/python313/python.exe run.py
```

2. **Select Files**
Select your video and SRT files. Character file is optional.
Select your video and SRT files. Character file is optional; see `characters.example.txt`.
If provided, each line can be:
- `CharacterName`
- `CharacterName,ShortcutKey`

Character names cannot contain commas, because commas separate characters in the exported CSV
and in CIGA's input. Without a character file, the annotator keeps its list in `characters.txt`
next to the application.

3. **Start Annotation**
Click "Start Annotation" to begin. The player auto-pauses at subtitle boundaries for coding.

Expand All @@ -85,6 +89,15 @@
5. Use `Inherit Listener/Target from previous line` to reduce repeated coding in dialogue runs.
6. Use `N` (or `Next Uncoded` button) to jump through remaining work quickly.

## Development

```
pip install -r requirements-dev.txt
python -m pytest
```

To build the Windows executable: `python build.py`.

## Contributing

Contributions are welcome! To contribute:
Expand Down
Binary file added assets/logo_black.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/logo_white.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 3 additions & 4 deletions build.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import PyInstaller.__main__
from pathlib import Path
import shutil
import os

Expand All @@ -14,9 +13,9 @@
'--windowed',
'--onefile',
'--clean',
'--icon=../logo_black.png',
f'--add-data=../logo_black.png{os.pathsep}.',
f'--add-data=../logo_white.png{os.pathsep}.',
'--icon=assets/logo_black.png',
f'--add-data=assets/logo_black.png{os.pathsep}.',
f'--add-data=assets/logo_white.png{os.pathsep}.',
'--collect-all=PySide6',
'--collect-all=shiboken6',
])
File renamed without changes.
39 changes: 0 additions & 39 deletions extract.py

This file was deleted.

7 changes: 7 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[pytest]
pythonpath = .
testpaths = tests
# Same as CIGA: keep pytest's temporary directories inside the checkout, because an
# unreadable entry in the system temp directory makes every tmp_path test error.
addopts = --basetemp=.pytest_tmp
tmp_path_retention_policy = none
3 changes: 3 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-r requirements.txt
pytest
pyinstaller
1 change: 0 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
PySide6>=6.10,<7
chardet>=5,<8
pyinstaller
20 changes: 0 additions & 20 deletions settings.json

This file was deleted.

16 changes: 9 additions & 7 deletions src/core/app_settings.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import copy
import json
import os
import sys
from pathlib import Path
from PySide6.QtCore import Qt

def get_base_dir():
if getattr(sys, 'frozen', False):
Expand Down Expand Up @@ -35,19 +35,21 @@ def get_base_dir():

class AppSettings:
def __init__(self):
self.settings = DEFAULT_SETTINGS.copy()
# Deep copy: a shallow copy shares the nested "hotkeys" dict, so rebinding a
# key would also rewrite DEFAULT_SETTINGS and break "Reset to Default".
self.settings = copy.deepcopy(DEFAULT_SETTINGS)
self.load()

def load(self):
if os.path.exists(SETTINGS_FILE):
try:
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
loaded = json.load(f)
# Merge loaded into default to preserve missing keys
self.settings.update(loaded)
if "hotkeys" in loaded:
for k, v in loaded["hotkeys"].items():
self.settings["hotkeys"][k] = v
# Merge loaded into the defaults so missing keys (including individual
# hotkeys) keep their default values.
loaded_hotkeys = loaded.pop("hotkeys", {})
self.settings.update(loaded)
self.settings["hotkeys"].update(loaded_hotkeys)
except Exception as e:
print(f"Failed to load settings: {e}")

Expand Down
13 changes: 13 additions & 0 deletions src/core/characters.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ def get_base_dir():
else:
return Path(__file__).parent.parent.parent

def validate_character_name(name):
"""Return an error message for an unusable character name, or None if it is valid.

Commas separate characters in exported CSV cells and in CIGA's speaker/listener
columns, so a name containing one would silently become two characters.
"""
if not name or not name.strip():
return "Character name cannot be empty."
if ',' in name:
return "Character names cannot contain commas; commas separate characters in the exported CSV."
return None


def read_characters(char_file):
if not char_file or not os.path.exists(char_file):
return []
Expand Down
71 changes: 45 additions & 26 deletions src/core/parsers.py
Original file line number Diff line number Diff line change
@@ -1,43 +1,62 @@
import re
import chardet

def parse_srt(srt_file):
print('srt_file', srt_file)
pattern = re.compile(
r'(\d+)\s+([\d:,]+)\s*-->\s*([\d:,]+)\s*(.*?)\s*(?=\n\d+|\Z)',
re.DOTALL
)

with open(srt_file, 'rb') as f:
raw_data = f.read()
# "00:00:01,000 --> 00:00:02,000", with "." also accepted as the millisecond separator
# and anything after the end time (cue settings such as "X1:40") ignored.
TIMING_PATTERN = re.compile(
r'^\s*(\d+:\d{1,2}:\d{1,2}[,.]\d+)\s*-->\s*(\d+:\d{1,2}:\d{1,2}[,.]\d+)'
)
BLOCK_SEPARATOR = re.compile(r'\n[ \t]*\n')

result = chardet.detect(raw_data)
encoding = result['encoding']
if not encoding:
encoding = 'utf-8'

def _decode(raw_data):
encoding = chardet.detect(raw_data)['encoding'] or 'utf-8'
try:
content = raw_data.decode(encoding)
except (UnicodeDecodeError, LookupError):
content = raw_data.decode('latin-1')
return content.lstrip('').replace('\r\n', '\n').replace('\r', '\n')

content = content.replace('\r\n', '\n').replace('\r', '\n')

matches = re.findall(pattern, content)
subtitles = []
for match in matches:
index = int(match[0])
start_time = srt_time_to_milliseconds(match[1])
end_time = srt_time_to_milliseconds(match[2])
text = match[3].replace('\n', ' ').strip()
subtitles.append({
def _parse_block(block, fallback_index):
"""Parse one cue: an optional index line, the timing line, then the text lines."""
lines = block.strip('\n').split('\n')
for position, line in enumerate(lines[:2]):
timing = TIMING_PATTERN.match(line)
if not timing:
continue
index = fallback_index
if position == 1 and lines[0].strip().isdigit():
index = int(lines[0].strip())
text = ' '.join(part.strip() for part in lines[position + 1:] if part.strip())
return {
'index': index,
'start_time': start_time,
'end_time': end_time,
'text': text
})
'start_time': srt_time_to_milliseconds(timing.group(1)),
'end_time': srt_time_to_milliseconds(timing.group(2)),
'text': text,
}
return None


def parse_srt(srt_file):
"""Parse an SRT file into cues.

Cues are split on blank lines rather than on "a newline followed by digits",
so a text line that starts with a number stays part of its cue.
"""
with open(srt_file, 'rb') as f:
content = _decode(f.read())

subtitles = []
for block in BLOCK_SEPARATOR.split(content):
if not block.strip():
continue
cue = _parse_block(block, len(subtitles) + 1)
if cue is not None:
subtitles.append(cue)
return subtitles


def srt_time_to_milliseconds(srt_time):
try:
h, m, s_ms = srt_time.split(':')
Expand Down
Loading
Loading