unrender 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. unrender/__init__.py +15 -0
  2. unrender/__main__.py +4 -0
  3. unrender/adapters/__init__.py +33 -0
  4. unrender/adapters/artifacts.py +32 -0
  5. unrender/adapters/paths.py +105 -0
  6. unrender/adapters/rows.py +96 -0
  7. unrender/adapters/stems.py +143 -0
  8. unrender/cli/__init__.py +5 -0
  9. unrender/cli/commands/__init__.py +39 -0
  10. unrender/cli/commands/audio.py +283 -0
  11. unrender/cli/commands/context.py +342 -0
  12. unrender/cli/commands/export.py +13 -0
  13. unrender/cli/commands/face.py +54 -0
  14. unrender/cli/commands/health.py +64 -0
  15. unrender/cli/commands/labels.py +33 -0
  16. unrender/cli/commands/timeline.py +57 -0
  17. unrender/cli/commands/voice.py +116 -0
  18. unrender/cli/helpers.py +53 -0
  19. unrender/cli/main.py +39 -0
  20. unrender/cli/parser.py +411 -0
  21. unrender/cloning/__init__.py +5 -0
  22. unrender/cloning/voice.py +476 -0
  23. unrender/dialogue/__init__.py +27 -0
  24. unrender/dialogue/clusters.py +344 -0
  25. unrender/dialogue/dub_script.py +326 -0
  26. unrender/dialogue/lines.py +684 -0
  27. unrender/dialogue/plan_writers.py +108 -0
  28. unrender/dialogue/transcription.py +266 -0
  29. unrender/exports/__init__.py +5 -0
  30. unrender/exports/artifacts.py +84 -0
  31. unrender/identity/__init__.py +27 -0
  32. unrender/identity/face.py +551 -0
  33. unrender/identity/resolution.py +203 -0
  34. unrender/identity/voice.py +410 -0
  35. unrender/io/__init__.py +1 -0
  36. unrender/io/csv.py +14 -0
  37. unrender/io/json.py +14 -0
  38. unrender/manifests/__init__.py +37 -0
  39. unrender/manifests/fingerprints.py +94 -0
  40. unrender/manifests/loaders.py +259 -0
  41. unrender/manifests/models.py +58 -0
  42. unrender/manifests/store.py +50 -0
  43. unrender/media/__init__.py +1 -0
  44. unrender/media/audio.py +143 -0
  45. unrender/media/ffmpeg.py +229 -0
  46. unrender/media/names.py +8 -0
  47. unrender/media/timecode.py +44 -0
  48. unrender/project/__init__.py +17 -0
  49. unrender/project/config.py +149 -0
  50. unrender/project/paths.py +177 -0
  51. unrender/py.typed +0 -0
  52. unrender/separation/__init__.py +20 -0
  53. unrender/separation/audioshake.py +367 -0
  54. unrender/separation/audioshake_client.py +306 -0
  55. unrender/separation/bandit.py +448 -0
  56. unrender/separation/models.py +25 -0
  57. unrender/shots/__init__.py +15 -0
  58. unrender/shots/dx.py +128 -0
  59. unrender/shots/stems.py +217 -0
  60. unrender/speakers/__init__.py +33 -0
  61. unrender/speakers/labeling.py +225 -0
  62. unrender/speakers/registry.py +219 -0
  63. unrender/timeline/__init__.py +20 -0
  64. unrender/timeline/builder.py +485 -0
  65. unrender/timeline/media.py +130 -0
  66. unrender/timeline/sources.py +276 -0
  67. unrender-0.2.1.dist-info/METADATA +478 -0
  68. unrender-0.2.1.dist-info/RECORD +73 -0
  69. unrender-0.2.1.dist-info/WHEEL +5 -0
  70. unrender-0.2.1.dist-info/entry_points.txt +2 -0
  71. unrender-0.2.1.dist-info/licenses/LICENSE +201 -0
  72. unrender-0.2.1.dist-info/licenses/NOTICE +18 -0
  73. unrender-0.2.1.dist-info/top_level.txt +1 -0
unrender/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """Unrender timeline reconstruction pipeline."""
2
+
3
+ from unrender.manifests import DialogueLineRecord, ManifestStore, ShotRecord, VoiceInput
4
+ from unrender.project import RunPaths
5
+
6
+ __version__ = "0.2.1"
7
+
8
+ __all__ = [
9
+ "DialogueLineRecord",
10
+ "ManifestStore",
11
+ "RunPaths",
12
+ "ShotRecord",
13
+ "VoiceInput",
14
+ "__version__",
15
+ ]
unrender/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from unrender.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from unrender.adapters.artifacts import export_artifacts_to_paths
4
+ from unrender.adapters.paths import (
5
+ ArtifactExportPaths,
6
+ ExternalProject,
7
+ ExternalShotPaths,
8
+ project_from_mapping,
9
+ write_project_config,
10
+ )
11
+ from unrender.adapters.rows import DEFAULT_ROW_ALIASES, rows_to_shots, smpte_to_seconds
12
+ from unrender.adapters.stems import (
13
+ StemDestination,
14
+ StemDestinationPolicy,
15
+ media_dir_destination_policy,
16
+ plan_voice_promotions,
17
+ )
18
+
19
+ __all__ = [
20
+ "DEFAULT_ROW_ALIASES",
21
+ "ArtifactExportPaths",
22
+ "ExternalProject",
23
+ "ExternalShotPaths",
24
+ "StemDestination",
25
+ "StemDestinationPolicy",
26
+ "export_artifacts_to_paths",
27
+ "media_dir_destination_policy",
28
+ "plan_voice_promotions",
29
+ "project_from_mapping",
30
+ "rows_to_shots",
31
+ "smpte_to_seconds",
32
+ "write_project_config",
33
+ ]
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ from unrender.adapters.paths import ArtifactExportPaths
8
+ from unrender.exports import export_artifacts
9
+ from unrender.project import RunPaths
10
+
11
+
12
+ def export_artifacts_to_paths(
13
+ run: RunPaths,
14
+ paths: ArtifactExportPaths,
15
+ *,
16
+ project_name: str = "unrender",
17
+ ) -> ArtifactExportPaths:
18
+ del project_name
19
+ with tempfile.TemporaryDirectory(prefix="unrender-exports-") as temp_dir:
20
+ export_dir = export_artifacts(run, Path(temp_dir))
21
+ copies = {
22
+ "face_speaker_detection.json": paths.face_speaker_detection,
23
+ "voice_speaker_detection.json": paths.voice_speaker_detection,
24
+ "speaker_resolution_plan.json": paths.speaker_resolution_plan,
25
+ "shot_stem_plan.json": paths.shot_stem_plan,
26
+ }
27
+ for name, destination in copies.items():
28
+ source = export_dir / name
29
+ destination.parent.mkdir(parents=True, exist_ok=True)
30
+ if source.exists():
31
+ shutil.copy2(source, destination)
32
+ return paths
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from unrender.manifests import write_json
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ExternalProject:
13
+ name: str
14
+ root_dir: Path
15
+ data_dir: Path
16
+ run_dir: Path
17
+ fps: float
18
+ speakers_config: Mapping[str, Any] | Sequence[str] | None = None
19
+ stem_map: Mapping[str, str] | None = None
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ExternalShotPaths:
24
+ shot_id: str
25
+ shot_dir: Path
26
+ media_dir: Path
27
+ video_path: Path | None = None
28
+ target_face_box: tuple[int, int, int, int] | None = None
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ArtifactExportPaths:
33
+ face_speaker_detection: Path
34
+ voice_speaker_detection: Path
35
+ speaker_resolution_plan: Path
36
+ shot_stem_plan: Path
37
+
38
+
39
+ def project_from_mapping(
40
+ config: Mapping[str, Any],
41
+ *,
42
+ name: str | None = None,
43
+ root_key: str = "root_dir",
44
+ root_dir: Path | None = None,
45
+ data_dir: Path | None = None,
46
+ run_dir: Path | None = None,
47
+ fps: float | None = None,
48
+ speakers_config: Mapping[str, Any] | Sequence[str] | None = None,
49
+ stem_map: Mapping[str, str] | None = None,
50
+ ) -> ExternalProject:
51
+ root = root_dir or _path_value(config, root_key) or Path.cwd()
52
+ resolved_name = name or str(config.get("name") or config.get("project") or root.name)
53
+ resolved_data = data_dir or _path_value(config, "data_dir") or root / "_data"
54
+ resolved_run = run_dir or _path_value(config, "run_dir") or resolved_data / "unrender"
55
+ resolved_fps = fps if fps is not None else float(config.get("fps") or 24.0)
56
+ return ExternalProject(
57
+ name=resolved_name,
58
+ root_dir=root.expanduser(),
59
+ data_dir=resolved_data.expanduser(),
60
+ run_dir=resolved_run.expanduser(),
61
+ fps=resolved_fps,
62
+ speakers_config=speakers_config if speakers_config is not None else config.get("speakers"),
63
+ stem_map=stem_map if stem_map is not None else _string_mapping(config.get("stem_map")),
64
+ )
65
+
66
+
67
+ def write_project_config(
68
+ project: ExternalProject,
69
+ *,
70
+ shots_path: Path,
71
+ config_path: Path | None = None,
72
+ extra_paths: Mapping[str, str] | None = None,
73
+ ) -> Path:
74
+ out_path = config_path or project.data_dir / f"{project.name}.json"
75
+ payload: dict[str, Any] = {
76
+ "speakers": project.speakers_config or {},
77
+ "paths": {
78
+ "run_dir": str(project.run_dir),
79
+ "shots": str(shots_path),
80
+ **dict(extra_paths or {}),
81
+ },
82
+ }
83
+ if project.stem_map:
84
+ payload["stem_map"] = dict(project.stem_map)
85
+ write_json(out_path, payload)
86
+ return out_path
87
+
88
+
89
+ def media_dir_destination_policy(*args: Any, **kwargs: Any):
90
+ from unrender.adapters.stems import media_dir_destination_policy as _policy
91
+
92
+ return _policy(*args, **kwargs)
93
+
94
+
95
+ def _path_value(config: Mapping[str, Any], key: str) -> Path | None:
96
+ value = config.get(key)
97
+ if value in (None, ""):
98
+ return None
99
+ return Path(str(value))
100
+
101
+
102
+ def _string_mapping(value: Any) -> dict[str, str] | None:
103
+ if not isinstance(value, Mapping):
104
+ return None
105
+ return {str(key): str(path) for key, path in value.items() if path not in (None, "")}
@@ -0,0 +1,96 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Mapping, Sequence
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from unrender.adapters.paths import ExternalShotPaths
8
+ from unrender.manifests import ShotRecord
9
+ from unrender.media.timecode import smpte_to_seconds
10
+
11
+ __all__ = ["DEFAULT_ROW_ALIASES", "rows_to_shots", "smpte_to_seconds"]
12
+
13
+ DEFAULT_ROW_ALIASES: dict[str, tuple[str, ...]] = {
14
+ "shot_id": ("shot_id", "id", "number", "Shot ID"),
15
+ "start_sec": ("start_sec", "start_seconds"),
16
+ "end_sec": ("end_sec", "end_seconds"),
17
+ "start_tc": ("start_tc", "start", "start_timecode"),
18
+ "end_tc": ("end_tc", "end", "end_timecode"),
19
+ "speaker": ("speaker", "on_screen_speaker", "character"),
20
+ "video_path": ("video_path", "path", "file_path"),
21
+ }
22
+
23
+
24
+ def rows_to_shots(
25
+ rows: Sequence[Mapping[str, Any]],
26
+ *,
27
+ fps: float,
28
+ resolve_shot_paths: Callable[[Mapping[str, Any]], ExternalShotPaths | None],
29
+ aliases: Mapping[str, Sequence[str]] | None = None,
30
+ parse_timecode: Callable[[str, float], float | None] | None = None,
31
+ ) -> list[ShotRecord]:
32
+ merged_aliases = _merged_aliases(aliases)
33
+ parser = parse_timecode or smpte_to_seconds
34
+ shots: list[ShotRecord] = []
35
+ for row in rows:
36
+ shot_id = str(_value(row, merged_aliases["shot_id"]) or "").strip()
37
+ if not shot_id:
38
+ continue
39
+ paths = resolve_shot_paths(row)
40
+ if paths is None:
41
+ continue
42
+ start_sec = _seconds(row, merged_aliases, "start", fps=fps, parse_timecode=parser)
43
+ end_sec = _seconds(row, merged_aliases, "end", fps=fps, parse_timecode=parser)
44
+ video_path = paths.video_path or _path_from_row(row, merged_aliases["video_path"])
45
+ shots.append(
46
+ ShotRecord(
47
+ shot_id=shot_id,
48
+ video_path=video_path or paths.shot_dir,
49
+ existing_speaker=str(_value(row, merged_aliases["speaker"]) or "").strip(),
50
+ start_sec=start_sec,
51
+ end_sec=end_sec,
52
+ target_face_box=paths.target_face_box,
53
+ )
54
+ )
55
+ return shots
56
+
57
+
58
+ def _seconds(
59
+ row: Mapping[str, Any],
60
+ aliases: Mapping[str, Sequence[str]],
61
+ prefix: str,
62
+ *,
63
+ fps: float,
64
+ parse_timecode: Callable[[str, float], float | None],
65
+ ) -> float | None:
66
+ seconds_value = _value(row, aliases[f"{prefix}_sec"])
67
+ if seconds_value not in (None, ""):
68
+ return float(seconds_value)
69
+ tc_value = _value(row, aliases[f"{prefix}_tc"])
70
+ if tc_value in (None, ""):
71
+ return None
72
+ return parse_timecode(str(tc_value), fps)
73
+
74
+
75
+ def _path_from_row(row: Mapping[str, Any], aliases: Sequence[str]) -> Path | None:
76
+ value = _value(row, aliases)
77
+ if value in (None, ""):
78
+ return None
79
+ return Path(str(value)).expanduser()
80
+
81
+
82
+ def _value(row: Mapping[str, Any], aliases: Sequence[str]) -> Any:
83
+ for key in aliases:
84
+ if key in row and row[key] not in (None, ""):
85
+ return row[key]
86
+ return None
87
+
88
+
89
+ def _merged_aliases(
90
+ aliases: Mapping[str, Sequence[str]] | None,
91
+ ) -> dict[str, tuple[str, ...]]:
92
+ out = dict(DEFAULT_ROW_ALIASES)
93
+ for key, values in (aliases or {}).items():
94
+ defaults = out.get(key, ())
95
+ out[key] = tuple(dict.fromkeys((*values, *defaults)))
96
+ return out
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Mapping, Sequence
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from unrender.adapters.paths import ExternalShotPaths
9
+ from unrender.manifests import ShotRecord, VoiceInput
10
+ from unrender.media.names import safe_name
11
+ from unrender.speakers import canonical_speaker_name, speaker_key
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class StemDestination:
16
+ path: Path
17
+ external_value: str | None = None
18
+
19
+
20
+ StemDestinationPolicy = Callable[[ShotRecord, str, Mapping[str, Any]], StemDestination]
21
+
22
+
23
+ def media_dir_destination_policy(
24
+ shot_paths_by_id: Mapping[str, ExternalShotPaths],
25
+ *,
26
+ filename_template: str = "{shot_dir_name}_{speaker_key}_stem.wav",
27
+ ) -> StemDestinationPolicy:
28
+ def destination(shot: ShotRecord, speaker: str, mapping: Mapping[str, Any]) -> StemDestination:
29
+ paths = shot_paths_by_id[shot.shot_id]
30
+ speaker_id = speaker_key(speaker) or safe_name(speaker, fallback="speaker")
31
+ filename = filename_template.format(
32
+ shot_id=shot.shot_id,
33
+ shot_dir_name=paths.shot_dir.name,
34
+ speaker=canonical_speaker_name(speaker),
35
+ speaker_key=speaker_id,
36
+ source_group=str(mapping.get("source_group") or ""),
37
+ line_id=str(mapping.get("line_id") or ""),
38
+ )
39
+ path = paths.media_dir / filename
40
+ return StemDestination(path=path, external_value=str(path))
41
+
42
+ return destination
43
+
44
+
45
+ def plan_voice_promotions(
46
+ *,
47
+ rows: Sequence[Mapping[str, Any]],
48
+ clips: Sequence[VoiceInput],
49
+ shot_paths_by_id: Mapping[str, ExternalShotPaths],
50
+ speaker_db_path: Path,
51
+ sim_threshold: float = 0.65,
52
+ rms_threshold_db: float = -55.0,
53
+ destination_policy: StemDestinationPolicy,
54
+ aliases: Mapping[str, Sequence[str]] | None = None,
55
+ ) -> list[dict[str, Any]]:
56
+ del speaker_db_path, sim_threshold, rms_threshold_db
57
+ shot_aliases: tuple[str, ...] = ("shot_id", "id", "number", "Shot ID")
58
+ speaker_aliases: tuple[str, ...] = ("speaker", "on_screen_speaker", "character")
59
+ if aliases:
60
+ shot_aliases = tuple(aliases.get("shot_id", shot_aliases))
61
+ speaker_aliases = tuple(aliases.get("speaker", speaker_aliases))
62
+ clips_by_shot: dict[str, list[VoiceInput]] = {}
63
+ for clip in clips:
64
+ clips_by_shot.setdefault(clip.shot_id, []).append(clip)
65
+
66
+ plan: list[dict[str, Any]] = []
67
+ for row in rows:
68
+ shot_id = str(_first(row, shot_aliases) or "").strip()
69
+ speaker = canonical_speaker_name(str(_first(row, speaker_aliases) or ""))
70
+ if not shot_id or not speaker:
71
+ continue
72
+ shot_paths = shot_paths_by_id.get(shot_id)
73
+ candidates = clips_by_shot.get(shot_id, [])
74
+ if shot_paths is None:
75
+ plan.append(_promotion_row(shot_id, speaker, "", "", 0.0, "missing_shot_paths"))
76
+ continue
77
+ if len(candidates) != 1:
78
+ plan.append(
79
+ _promotion_row(
80
+ shot_id,
81
+ speaker,
82
+ "",
83
+ "",
84
+ 0.0,
85
+ "needs_review" if candidates else "no_candidate",
86
+ candidate_count=len(candidates),
87
+ )
88
+ )
89
+ continue
90
+ clip = candidates[0]
91
+ shot = ShotRecord(
92
+ shot_id=shot_id,
93
+ video_path=shot_paths.video_path or shot_paths.shot_dir,
94
+ existing_speaker=speaker,
95
+ )
96
+ destination = destination_policy(
97
+ shot,
98
+ speaker,
99
+ {"source_group": clip.source_group, "clip_id": clip.clip_id},
100
+ )
101
+ plan.append(
102
+ _promotion_row(
103
+ shot_id,
104
+ speaker,
105
+ str(clip.path),
106
+ str(destination.path),
107
+ 1.0,
108
+ "accepted",
109
+ reason="single speaker with one voiced candidate",
110
+ candidate_count=1,
111
+ )
112
+ )
113
+ return plan
114
+
115
+
116
+ def _first(row: Mapping[str, Any], aliases: Sequence[str]) -> Any:
117
+ for key in aliases:
118
+ if row.get(key) not in (None, ""):
119
+ return row[key]
120
+ return None
121
+
122
+
123
+ def _promotion_row(
124
+ shot_id: str,
125
+ speaker: str,
126
+ source_path: str,
127
+ target_path: str,
128
+ score: float,
129
+ status: str,
130
+ *,
131
+ reason: str = "",
132
+ candidate_count: int = 0,
133
+ ) -> dict[str, Any]:
134
+ return {
135
+ "shot_id": shot_id,
136
+ "speaker": speaker,
137
+ "source_path": source_path,
138
+ "target_path": target_path,
139
+ "score": score,
140
+ "status": status,
141
+ "reason": reason,
142
+ "candidate_count": candidate_count,
143
+ }
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+ from unrender.cli.main import main
4
+
5
+ __all__ = ["main"]
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from unrender.cli.commands.audio import (
4
+ _audio_build_clips,
5
+ _audio_map_dialogue,
6
+ _audio_resolve_clips,
7
+ _audio_separate,
8
+ _audio_shot_dx,
9
+ _audio_transcribe_lines,
10
+ )
11
+ from unrender.cli.commands.context import _projects_from_args
12
+ from unrender.cli.commands.export import _export_artifacts
13
+ from unrender.cli.commands.face import _face_build, _shots_match
14
+ from unrender.cli.commands.health import _doctor, _status
15
+ from unrender.cli.commands.labels import _labels_apply, _labels_interactive, _labels_template
16
+ from unrender.cli.commands.timeline import _timeline_build
17
+ from unrender.cli.commands.voice import _voice_build, _voice_clone, _voice_match
18
+
19
+ __all__ = [
20
+ "_audio_build_clips",
21
+ "_audio_map_dialogue",
22
+ "_audio_resolve_clips",
23
+ "_audio_separate",
24
+ "_audio_shot_dx",
25
+ "_audio_transcribe_lines",
26
+ "_doctor",
27
+ "_export_artifacts",
28
+ "_face_build",
29
+ "_labels_apply",
30
+ "_labels_interactive",
31
+ "_labels_template",
32
+ "_projects_from_args",
33
+ "_shots_match",
34
+ "_status",
35
+ "_timeline_build",
36
+ "_voice_build",
37
+ "_voice_clone",
38
+ "_voice_match",
39
+ ]