Compare commits

6 Commits

Author SHA1 Message Date
Ethanfel 8bc42cabd9 fix: store absolute paths in dataset.json
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:10:06 +02:00
Ethanfel 8148971aae refactor: remove fps from annotation — path + label only
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:09:08 +02:00
Ethanfel a8d8bd7fdc Revert "fix: pass source fps to ffmpeg export via fps filter"
This reverts commit fc9287a5a6.
2026-04-07 14:08:00 +02:00
Ethanfel f0484fac86 Revert "feat: write fps.txt alongside WebP sequence audio"
This reverts commit cf62940b84.
2026-04-07 14:08:00 +02:00
Ethanfel fd25e8fa37 feat: add migration script to generate dataset.json from DB history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:04:29 +02:00
Ethanfel c3c480acc7 feat: replace dataset.tsv with dataset.json annotation file
Each exported clip writes an entry to <folder>/dataset.json containing
its relative path, sound label, and fps. Re-exporting to the same path
updates the existing entry (upsert). Empty labels are skipped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 14:01:50 +02:00
3 changed files with 167 additions and 52 deletions
+31 -24
View File
@@ -5,6 +5,7 @@ locale.setlocale(locale.LC_NUMERIC, "C") # required by libmpv before any import
import sys import sys
import os import os
import re import re
import json
import sqlite3 import sqlite3
import subprocess import subprocess
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -45,7 +46,6 @@ def build_ffmpeg_command(
portrait_ratio: str | None = None, portrait_ratio: str | None = None,
crop_center: float = 0.5, crop_center: float = 0.5,
image_sequence: bool = False, image_sequence: bool = False,
fps: float | None = None,
) -> list[str]: ) -> list[str]:
# -ss before -i: fast input-seeking. Safe here because we always re-encode # -ss before -i: fast input-seeking. Safe here because we always re-encode
# (libx264/aac), so there is no keyframe-alignment issue from pre-input seek. # (libx264/aac), so there is no keyframe-alignment issue from pre-input seek.
@@ -67,8 +67,6 @@ def build_ffmpeg_command(
filters.append( filters.append(
f"scale='if(lt(iw,ih),{short_side},-2)':'if(lt(iw,ih),-2,{short_side})':flags=lanczos" f"scale='if(lt(iw,ih),{short_side},-2)':'if(lt(iw,ih),-2,{short_side})':flags=lanczos"
) )
if fps is not None:
filters.append(f"fps={fps}")
if filters: if filters:
cmd += ["-vf", ",".join(filters)] cmd += ["-vf", ",".join(filters)]
@@ -99,22 +97,40 @@ def build_audio_extract_command(input_path: str, start: float, sequence_dir: str
] ]
def build_annotation_tsv_path(folder: str) -> str: def build_annotation_json_path(folder: str) -> str:
return os.path.join(folder, "dataset.tsv") return os.path.join(folder, "dataset.json")
def append_to_tsv(folder: str, clip_stem: str, label: str) -> None: def upsert_clip_annotation(folder: str, clip_path: str, label: str) -> None:
"""Append one line to <folder>/dataset.tsv (creates file if absent). """Insert or update one entry in <folder>/dataset.json.
Format: ``{clip_stem}\\t{label}`` — matches VGGSound training TSV (2 columns). Each entry stores a path relative to *folder* and the sound label.
Category is stored in the database only, not in the TSV. Matches on ``path``; if an entry for the same clip already exists it is
replaced (overwrite-export case). Nothing is written when *label* is
empty.
""" """
if not label.strip(): if not label.strip():
return return
tsv_path = build_annotation_tsv_path(folder)
os.makedirs(folder, exist_ok=True) os.makedirs(folder, exist_ok=True)
with open(tsv_path, "a", encoding="utf-8") as f: json_path = build_annotation_json_path(folder)
f.write(f"{clip_stem}\t{label}\n") entries: list[dict] = []
if os.path.exists(json_path):
with open(json_path, "r", encoding="utf-8") as f:
try:
entries = json.load(f)
except (json.JSONDecodeError, ValueError):
entries = []
abs_path = os.path.abspath(clip_path)
entry: dict = {"path": abs_path, "label": label}
for i, e in enumerate(entries):
if e.get("path") == abs_path:
entries[i] = entry
break
else:
entries.append(entry)
with open(json_path, "w", encoding="utf-8") as f:
json.dump(entries, f, indent=2, ensure_ascii=False)
f.write("\n")
def build_mask_output_dir(video_path: str) -> str: def build_mask_output_dir(video_path: str) -> str:
@@ -308,8 +324,7 @@ class ExportWorker(QThread):
short_side: int | None = None, short_side: int | None = None,
portrait_ratio: str | None = None, portrait_ratio: str | None = None,
crop_center: float = 0.5, crop_center: float = 0.5,
image_sequence: bool = False, image_sequence: bool = False):
fps: float | None = None):
super().__init__() super().__init__()
self._input = input_path self._input = input_path
self._start = start self._start = start
@@ -318,7 +333,6 @@ class ExportWorker(QThread):
self._portrait_ratio = portrait_ratio self._portrait_ratio = portrait_ratio
self._crop_center = crop_center self._crop_center = crop_center
self._image_sequence = image_sequence self._image_sequence = image_sequence
self._fps = fps
def run(self): def run(self):
try: try:
@@ -330,7 +344,6 @@ class ExportWorker(QThread):
portrait_ratio=self._portrait_ratio, portrait_ratio=self._portrait_ratio,
crop_center=self._crop_center, crop_center=self._crop_center,
image_sequence=self._image_sequence, image_sequence=self._image_sequence,
fps=self._fps,
) )
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode == 0: if result.returncode == 0:
@@ -339,11 +352,6 @@ class ExportWorker(QThread):
self._input, self._start, self._output self._input, self._start, self._output
) )
subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60) subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60)
# Write fps.txt alongside the .wav file.
if self._fps is not None:
fps_path = self._output + ".fps.txt"
with open(fps_path, "w") as f:
f.write(f"{self._fps}\n")
# Audio extraction failure (e.g. no audio stream) is ignored — # Audio extraction failure (e.g. no audio stream) is ignored —
# the frame sequence is the primary output. # the frame sequence is the primary output.
self.finished.emit(self._output) self.finished.emit(self._output)
@@ -1563,7 +1571,6 @@ class MainWindow(QMainWindow):
portrait_ratio=portrait_ratio, portrait_ratio=portrait_ratio,
crop_center=self._crop_center, crop_center=self._crop_center,
image_sequence=image_sequence, image_sequence=image_sequence,
fps=self._fps,
) )
self._export_worker.finished.connect(self._on_export_done) self._export_worker.finished.connect(self._on_export_done)
self._export_worker.error.connect(self._on_export_error) self._export_worker.error.connect(self._on_export_error)
@@ -1579,8 +1586,8 @@ class MainWindow(QMainWindow):
label=label, label=label,
category=category, category=category,
) )
clip_stem = os.path.splitext(os.path.basename(path))[0] folder = self._txt_folder.text()
append_to_tsv(self._txt_folder.text(), clip_stem, label) upsert_clip_annotation(folder, path, label)
# For MP4 exports path is a file; for WebP sequence it is a directory. # For MP4 exports path is a file; for WebP sequence it is a directory.
# build_mask_output_dir handles both correctly via Path.stem. # build_mask_output_dir handles both correctly via Path.stem.
self._last_export_path = path self._last_export_path = path
+38 -28
View File
@@ -1,5 +1,5 @@
import tempfile, os import tempfile, os, json
from main import build_export_path, format_time, build_ffmpeg_command, build_mask_output_dir, build_sequence_dir, build_audio_extract_command, build_annotation_tsv_path, append_to_tsv from main import build_export_path, format_time, build_ffmpeg_command, build_mask_output_dir, build_sequence_dir, build_audio_extract_command, build_annotation_json_path, upsert_clip_annotation
from main import _normalize_filename, ProcessedDB from main import _normalize_filename, ProcessedDB
@@ -217,10 +217,7 @@ def test_ffmpeg_command_image_sequence():
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/seq_001", image_sequence=True) cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/seq_001", image_sequence=True)
assert "-c:v" in cmd assert "-c:v" in cmd
assert cmd[cmd.index("-c:v") + 1] == "libwebp" assert cmd[cmd.index("-c:v") + 1] == "libwebp"
assert "-lossless" in cmd assert "-quality" in cmd
assert cmd[cmd.index("-lossless") + 1] == "1"
assert "-compression_level" in cmd
assert cmd[cmd.index("-compression_level") + 1] == "4"
assert cmd[-1] == "/out/seq_001/frame_%04d.webp" assert cmd[-1] == "/out/seq_001/frame_%04d.webp"
def test_ffmpeg_command_image_sequence_with_resize(): def test_ffmpeg_command_image_sequence_with_resize():
@@ -237,28 +234,47 @@ def test_ffmpeg_command_image_sequence_no_audio():
assert "aac" not in cmd assert "aac" not in cmd
def test_annotation_tsv_path(): def test_annotation_json_path():
assert build_annotation_tsv_path("/out") == "/out/dataset.tsv" assert build_annotation_json_path("/out") == "/out/dataset.json"
def test_append_to_tsv_creates_file(): def test_upsert_creates_file():
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
append_to_tsv(d, "clip_001", "dog barking") clip = os.path.join(d, "clip_001.mp4")
with open(os.path.join(d, "dataset.tsv")) as f: upsert_clip_annotation(d, clip, "dog barking")
lines = f.readlines() with open(os.path.join(d, "dataset.json")) as f:
assert lines == ["clip_001\tdog barking\n"] entries = json.load(f)
assert len(entries) == 1
assert entries[0]["label"] == "dog barking"
assert entries[0]["path"] == clip
def test_append_to_tsv_appends(): def test_upsert_appends_new_clips():
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
append_to_tsv(d, "clip_001", "dog barking") upsert_clip_annotation(d, os.path.join(d, "clip_001.mp4"), "dog barking")
append_to_tsv(d, "clip_002", "cat meowing") upsert_clip_annotation(d, os.path.join(d, "clip_002.mp4"), "cat meowing")
with open(os.path.join(d, "dataset.tsv")) as f: with open(os.path.join(d, "dataset.json")) as f:
lines = f.readlines() entries = json.load(f)
assert len(lines) == 2 assert len(entries) == 2
def test_append_to_tsv_empty_label_skips(): def test_upsert_replaces_existing():
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
append_to_tsv(d, "clip_001", "") clip = os.path.join(d, "clip_001.mp4")
assert not os.path.exists(os.path.join(d, "dataset.tsv")) upsert_clip_annotation(d, clip, "dog barking")
upsert_clip_annotation(d, clip, "cat meowing")
with open(os.path.join(d, "dataset.json")) as f:
entries = json.load(f)
assert len(entries) == 1
assert entries[0]["label"] == "cat meowing"
def test_upsert_empty_label_skips():
with tempfile.TemporaryDirectory() as d:
upsert_clip_annotation(d, os.path.join(d, "clip_001.mp4"), "")
assert not os.path.exists(os.path.join(d, "dataset.json"))
def test_upsert_missing_folder_creates_it():
with tempfile.TemporaryDirectory() as d:
nested = os.path.join(d, "subdir", "deep")
upsert_clip_annotation(nested, os.path.join(nested, "clip_001.mp4"), "dog barking")
assert os.path.exists(os.path.join(nested, "dataset.json"))
def test_db_stores_label_and_category(): def test_db_stores_label_and_category():
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f: with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
@@ -272,9 +288,3 @@ def test_db_stores_label_and_category():
assert row == ("dog barking", "Animal") assert row == ("dog barking", "Animal")
finally: finally:
os.unlink(path) os.unlink(path)
def test_append_to_tsv_missing_folder_creates_it():
with tempfile.TemporaryDirectory() as d:
nested = os.path.join(d, "subdir", "deep")
append_to_tsv(nested, "clip_001", "dog barking")
assert os.path.exists(os.path.join(nested, "dataset.tsv"))
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""One-shot migration: generate dataset.json files from ~/.8cut.db history.
For each export folder that has records in the database, writes (or merges
into) a dataset.json with path (relative to the folder) and label per clip.
Usage:
python tools/migrate_dataset_json.py [--dry-run]
"""
import argparse
import json
import os
import sqlite3
from collections import defaultdict
from pathlib import Path
DB_PATH = Path.home() / ".8cut.db"
def load_db_records(db_path: Path) -> list[dict]:
con = sqlite3.connect(db_path)
con.row_factory = sqlite3.Row
rows = con.execute(
"SELECT output_path, label FROM processed WHERE label != ''"
).fetchall()
con.close()
return [dict(r) for r in rows]
def build_entries_for_folder(folder: str, records: list[dict]) -> list[dict]:
return [
{"path": os.path.abspath(rec["output_path"]), "label": rec["label"]}
for rec in records
]
def merge_into_json(json_path: str, new_entries: list[dict], dry_run: bool) -> int:
"""Merge new_entries into existing json_path, returning count added/updated."""
existing: list[dict] = []
if os.path.exists(json_path):
with open(json_path, "r", encoding="utf-8") as f:
try:
existing = json.load(f)
except (json.JSONDecodeError, ValueError):
existing = []
by_path = {e["path"]: e for e in existing}
changed = 0
for entry in new_entries:
if by_path.get(entry["path"]) != entry:
by_path[entry["path"]] = entry
changed += 1
merged = list(by_path.values())
if not dry_run and changed:
with open(json_path, "w", encoding="utf-8") as f:
json.dump(merged, f, indent=2, ensure_ascii=False)
f.write("\n")
return changed
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dry-run", action="store_true",
help="Show what would be written without writing")
args = parser.parse_args()
if not DB_PATH.exists():
print(f"Database not found: {DB_PATH}")
return
records = load_db_records(DB_PATH)
if not records:
print("No labelled records in database.")
return
# Group records by parent folder of output_path
by_folder: dict[str, list[dict]] = defaultdict(list)
for rec in records:
folder = os.path.dirname(rec["output_path"])
by_folder[folder].append(rec)
total_written = 0
for folder, recs in sorted(by_folder.items()):
entries = build_entries_for_folder(folder, recs)
json_path = os.path.join(folder, "dataset.json")
changed = merge_into_json(json_path, entries, args.dry_run)
status = "(dry run) " if args.dry_run else ""
print(f"{status}{json_path}: {len(entries)} clips, {changed} added/updated")
total_written += changed
suffix = " (dry run)" if args.dry_run else ""
print(f"\nTotal: {total_written} entries written{suffix}")
if __name__ == "__main__":
main()