inline-core 1.1.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 (77) hide show
  1. inline_core/__init__.py +7 -0
  2. inline_core/components/__init__.py +1 -0
  3. inline_core/components/conditioning.py +20 -0
  4. inline_core/components/interfaces.py +91 -0
  5. inline_core/config.py +33 -0
  6. inline_core/device/__init__.py +1 -0
  7. inline_core/device/auto.py +35 -0
  8. inline_core/device/detect.py +41 -0
  9. inline_core/device/memory.py +168 -0
  10. inline_core/device/policy.py +82 -0
  11. inline_core/device/types.py +31 -0
  12. inline_core/errors.py +42 -0
  13. inline_core/graph/__init__.py +1 -0
  14. inline_core/graph/cache.py +83 -0
  15. inline_core/graph/descriptor.py +81 -0
  16. inline_core/graph/executor.py +102 -0
  17. inline_core/graph/primitives.py +137 -0
  18. inline_core/graph/registry.py +61 -0
  19. inline_core/graph/runners.py +72 -0
  20. inline_core/graph/schema.py +136 -0
  21. inline_core/graph/topo.py +49 -0
  22. inline_core/graph/validate.py +56 -0
  23. inline_core/importer/__init__.py +1 -0
  24. inline_core/importer/comfy.py +162 -0
  25. inline_core/media.py +11 -0
  26. inline_core/models/__init__.py +8 -0
  27. inline_core/models/catalog.py +98 -0
  28. inline_core/models/zimage/__init__.py +11 -0
  29. inline_core/models/zimage/requirements.py +180 -0
  30. inline_core/models/zimage/runner.py +386 -0
  31. inline_core/parallel/__init__.py +13 -0
  32. inline_core/parallel/config.py +50 -0
  33. inline_core/parallel/group.py +136 -0
  34. inline_core/parallel/launch.py +44 -0
  35. inline_core/parallel/protocol.py +53 -0
  36. inline_core/parallel/registry.py +31 -0
  37. inline_core/parallel/worker.py +75 -0
  38. inline_core/runtime/__init__.py +1 -0
  39. inline_core/runtime/context.py +31 -0
  40. inline_core/runtime/file_store.py +51 -0
  41. inline_core/runtime/progress.py +83 -0
  42. inline_core/runtime/run.py +113 -0
  43. inline_core/runtime/store.py +18 -0
  44. inline_core/sampling/__init__.py +1 -0
  45. inline_core/sampling/batch.py +116 -0
  46. inline_core/server/__init__.py +1 -0
  47. inline_core/server/__main__.py +61 -0
  48. inline_core/server/app.py +327 -0
  49. inline_core/server/assets.py +47 -0
  50. inline_core/server/bootstrap.py +21 -0
  51. inline_core/server/frontend.py +37 -0
  52. inline_core/server/manager.py +195 -0
  53. inline_core/server/rpc.py +60 -0
  54. inline_core/server/run_store.py +155 -0
  55. inline_core/server/serialize.py +144 -0
  56. inline_core/studio/__init__.py +7 -0
  57. inline_core/studio/assets.py +194 -0
  58. inline_core/studio/config.py +29 -0
  59. inline_core/studio/fal.py +237 -0
  60. inline_core/studio/frames.py +552 -0
  61. inline_core/studio/generation.py +136 -0
  62. inline_core/studio/graph_build.py +109 -0
  63. inline_core/studio/handlers.py +236 -0
  64. inline_core/studio/models.py +173 -0
  65. inline_core/studio/moodboard.py +400 -0
  66. inline_core/studio/schema.py +278 -0
  67. inline_core/studio/store.py +291 -0
  68. inline_core/studio/timeline/__init__.py +6 -0
  69. inline_core/studio/timeline/compose.py +130 -0
  70. inline_core/studio/timeline/ffmpeg.py +76 -0
  71. inline_core/studio/timeline/render.py +120 -0
  72. inline_core/studio/timeline/resolve.py +189 -0
  73. inline_core/takes.py +31 -0
  74. inline_core-1.1.1.dist-info/METADATA +35 -0
  75. inline_core-1.1.1.dist-info/RECORD +77 -0
  76. inline_core-1.1.1.dist-info/WHEEL +4 -0
  77. inline_core-1.1.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,291 @@
1
+ """Project lifecycle + app-global settings/recents, ported from the Studio TS backend
2
+ (``electron/main/project/*`` + ``settings/store.ts``). Core owns ``project.db`` once this layer is
3
+ wired into ``/rpc``.
4
+
5
+ A project on disk is a portable folder::
6
+
7
+ MyFilm.inlinestudio/
8
+ project.db assets/ takes/ thumbs/
9
+
10
+ The web SPA has no native folder picker, so new projects are created under a workspace dir (like the
11
+ legacy Node web server did with STORYLINE_WORKSPACE_DIR). Recents + settings are app-global JSON in
12
+ the app data dir.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import re
20
+ import sqlite3
21
+ import time
22
+ import uuid
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from .schema import apply_schema
27
+
28
+ PROJECT_EXT = ".inlinestudio"
29
+ LEGACY_EXTS = (".storyline",)
30
+ PROJECT_EXTS = (PROJECT_EXT, *LEGACY_EXTS)
31
+ SUBDIRS = ("assets", "takes", "thumbs")
32
+ MAX_RECENTS = 12
33
+
34
+
35
+ def _now_ms() -> int:
36
+ return int(time.time() * 1000)
37
+
38
+
39
+ def _sanitize_folder_name(name: str) -> str:
40
+ base = re.sub(r"^-+|-+$", "", re.sub(r"[^\w.-]+", "-", name.strip()))
41
+ return base or "untitled"
42
+
43
+
44
+ def _is_project_ext(folder: str) -> bool:
45
+ return any(folder.endswith(ext) for ext in PROJECT_EXTS)
46
+
47
+
48
+ class StudioStore:
49
+ """Owns the single open project (folder + SQLite connection) and app-global settings/recents."""
50
+
51
+ def __init__(
52
+ self,
53
+ app_data_dir: str | Path,
54
+ workspace_dir: str | Path,
55
+ *,
56
+ default_comfy_url: str = "http://127.0.0.1:8188",
57
+ default_core_url: str = "http://127.0.0.1:8848",
58
+ ) -> None:
59
+ self._app_data = Path(app_data_dir)
60
+ self._workspace = Path(workspace_dir)
61
+ self._app_data.mkdir(parents=True, exist_ok=True)
62
+ self._workspace.mkdir(parents=True, exist_ok=True)
63
+ self._default_comfy_url = default_comfy_url
64
+ self._default_core_url = default_core_url
65
+ self._conn: sqlite3.Connection | None = None
66
+ self._folder: Path | None = None
67
+ self._current: dict[str, Any] | None = None
68
+
69
+ # --- project db connection ------------------------------------------------------------------
70
+
71
+ def _open_db(self, folder: Path) -> sqlite3.Connection:
72
+ """Open (creating if needed) the project.db inside a project folder, applying the schema."""
73
+ self.close()
74
+ db_path = folder / "project.db"
75
+ # A stale -shm in a copied project is rebuildable; drop it so SQLite regenerates it on open.
76
+ shm = Path(f"{db_path}-shm")
77
+ if shm.exists():
78
+ try:
79
+ shm.unlink()
80
+ except OSError:
81
+ pass
82
+ # Autocommit (isolation_level=None) so each write persists immediately, matching the Node
83
+ # backend's better-sqlite3 default — the domain modules don't manage transactions.
84
+ conn = sqlite3.connect(str(db_path), isolation_level=None)
85
+ conn.row_factory = sqlite3.Row
86
+ apply_schema(conn)
87
+ self._conn = conn
88
+ self._folder = folder
89
+ return conn
90
+
91
+ def conn(self) -> sqlite3.Connection:
92
+ if self._conn is None:
93
+ raise RuntimeError("No project is open.")
94
+ return self._conn
95
+
96
+ # Back-compat internal alias.
97
+ _db = conn
98
+
99
+ def folder(self) -> Path:
100
+ if self._folder is None:
101
+ raise RuntimeError("No project is open.")
102
+ return self._folder
103
+
104
+ def close(self) -> None:
105
+ if self._conn is not None:
106
+ self._conn.close()
107
+ self._conn = None
108
+ self._folder = None
109
+
110
+ # --- projects -------------------------------------------------------------------------------
111
+
112
+ def create_project(self, name: str, parent_dir: str | None = None) -> dict[str, Any]:
113
+ if not name or not name.strip():
114
+ raise ValueError("Project name is required.")
115
+ parent = Path(parent_dir) if parent_dir else self._workspace
116
+ folder = parent / f"{_sanitize_folder_name(name)}{PROJECT_EXT}"
117
+ if folder.exists():
118
+ raise ValueError(f"A project already exists at {folder}")
119
+ folder.mkdir(parents=True)
120
+ for sub in SUBDIRS:
121
+ (folder / sub).mkdir(parents=True, exist_ok=True)
122
+
123
+ conn = self._open_db(folder)
124
+ now = _now_ms()
125
+ pid = str(uuid.uuid4())
126
+ conn.execute(
127
+ "INSERT INTO project (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)",
128
+ (pid, name, now, now),
129
+ )
130
+ conn.commit()
131
+ # TODO(B2/B3): seed the starter graph (empty frame + Prompt -> Z-Image) once the frames and
132
+ # moodboard stores are ported. New projects open with an empty board until then.
133
+ project = {"id": pid, "name": name, "path": str(folder), "createdAt": now, "updatedAt": now}
134
+ self._current = project
135
+ self.record_recent(name, str(folder))
136
+ return project
137
+
138
+ def open_project(self, selected: str) -> dict[str, Any]:
139
+ folder = self.resolve_project_folder(selected)
140
+ if folder is None:
141
+ raise ValueError("That folder is not an Inline Studio project (no project.db found).")
142
+ self._open_db(folder)
143
+ for sub in SUBDIRS:
144
+ (folder / sub).mkdir(parents=True, exist_ok=True)
145
+ project = self._load_project_row(folder)
146
+ self._current = project
147
+ self.record_recent(project["name"], str(folder))
148
+ return project
149
+
150
+ def _load_project_row(self, folder: Path) -> dict[str, Any]:
151
+ row = self._db().execute(
152
+ "SELECT id, name, created_at, updated_at FROM project LIMIT 1"
153
+ ).fetchone()
154
+ if row is None:
155
+ raise ValueError("project.db is missing its project record.")
156
+ return {
157
+ "id": row["id"],
158
+ "name": row["name"],
159
+ "path": str(folder),
160
+ "createdAt": row["created_at"],
161
+ "updatedAt": row["updated_at"],
162
+ }
163
+
164
+ def current_project(self) -> dict[str, Any] | None:
165
+ return self._current
166
+
167
+ def media_dirs(self) -> dict[str, str]:
168
+ if self._folder is None:
169
+ raise RuntimeError("No project is open.")
170
+ return {
171
+ "inputDir": str(self._folder / "assets"),
172
+ "outputDir": str(self._folder / "takes"),
173
+ }
174
+
175
+ def resolve_project_folder(self, folder: str) -> Path | None:
176
+ """The real project folder for a picked one: itself if it holds project.db, else a child
177
+ that does (an unzip may wrap the project one level down). Prefers a *.inlinestudio child."""
178
+ root = Path(folder)
179
+ try:
180
+ if (root / "project.db").exists():
181
+ return root
182
+ children = [c for c in root.iterdir() if c.is_dir()]
183
+ preferred = next(
184
+ (c for c in children if _is_project_ext(c.name) and (c / "project.db").exists()),
185
+ None,
186
+ )
187
+ if preferred is not None:
188
+ return preferred
189
+ any_child = next((c for c in children if (c / "project.db").exists()), None)
190
+ return any_child
191
+ except OSError:
192
+ return None
193
+
194
+ def is_project_folder(self, folder: str) -> bool:
195
+ return self.resolve_project_folder(folder) is not None
196
+
197
+ # --- recents (app-global JSON) --------------------------------------------------------------
198
+
199
+ def _recents_file(self) -> Path:
200
+ return self._app_data / "recent-projects.json"
201
+
202
+ def list_recent(self) -> list[dict[str, Any]]:
203
+ file = self._recents_file()
204
+ if not file.exists():
205
+ return []
206
+ try:
207
+ parsed = json.loads(file.read_text(encoding="utf-8"))
208
+ except (json.JSONDecodeError, OSError):
209
+ return []
210
+ return parsed if isinstance(parsed, list) else []
211
+
212
+ def record_recent(self, name: str, path: str) -> None:
213
+ now = _now_ms()
214
+ existing = [r for r in self.list_recent() if r.get("path") != path]
215
+ nxt = [{"name": name, "path": path, "lastOpenedAt": now}, *existing][:MAX_RECENTS]
216
+ self._recents_file().write_text(json.dumps(nxt, indent=2), encoding="utf-8")
217
+
218
+ # --- settings (app-global JSON) -------------------------------------------------------------
219
+
220
+ def _settings_file(self) -> Path:
221
+ return self._app_data / "settings.json"
222
+
223
+ def _read_settings(self) -> dict[str, Any]:
224
+ file = self._settings_file()
225
+ if not file.exists():
226
+ return {}
227
+ try:
228
+ parsed = json.loads(file.read_text(encoding="utf-8"))
229
+ except (json.JSONDecodeError, OSError):
230
+ return {}
231
+ return parsed if isinstance(parsed, dict) else {}
232
+
233
+ @staticmethod
234
+ def _non_empty(value: Any) -> str | None:
235
+ return value if isinstance(value, str) and value else None
236
+
237
+ def get_settings(self) -> dict[str, str]:
238
+ saved = self._read_settings()
239
+ return {
240
+ "comfyUrl": self._non_empty(saved.get("comfyUrl")) or self._default_comfy_url,
241
+ "coreUrl": self._non_empty(saved.get("coreUrl")) or self._default_core_url,
242
+ }
243
+
244
+ def _save_settings(self, settings: dict[str, str]) -> dict[str, str]:
245
+ self._settings_file().write_text(json.dumps(settings, indent=2), encoding="utf-8")
246
+ return settings
247
+
248
+ def set_comfy_url(self, url: str) -> dict[str, str]:
249
+ settings = self.get_settings()
250
+ settings["comfyUrl"] = url.strip() or self._default_comfy_url
251
+ return self._save_settings(settings)
252
+
253
+ def set_core_url(self, url: str) -> dict[str, str]:
254
+ settings = self.get_settings()
255
+ settings["coreUrl"] = url.strip() or self._default_core_url
256
+ return self._save_settings(settings)
257
+
258
+ # --- fal API key (app-global, server-side only) ---------------------------------------------
259
+
260
+ def _fal_key_file(self) -> Path:
261
+ return self._app_data / "fal_key"
262
+
263
+ def fal_key(self) -> str | None:
264
+ file = self._fal_key_file()
265
+ if not file.exists():
266
+ env = os.environ.get("FAL_KEY", "").strip()
267
+ return env or None
268
+ try:
269
+ return file.read_text(encoding="utf-8").strip() or None
270
+ except OSError:
271
+ return None
272
+
273
+ def fal_status(self) -> dict[str, bool]:
274
+ # `encrypted` is False: the key lives in a 0600 file, not OS-encrypted (single-user local).
275
+ return {"hasKey": self.fal_key() is not None, "encrypted": False}
276
+
277
+ def set_fal_key(self, key: str) -> dict[str, bool]:
278
+ file = self._fal_key_file()
279
+ file.write_text(key.strip(), encoding="utf-8")
280
+ try:
281
+ file.chmod(0o600) # owner-only: the key never leaves the server side
282
+ except OSError:
283
+ pass
284
+ return self.fal_status()
285
+
286
+ def clear_fal_key(self) -> dict[str, bool]:
287
+ try:
288
+ self._fal_key_file().unlink(missing_ok=True)
289
+ except OSError:
290
+ pass
291
+ return {"hasKey": False, "encrypted": False}
@@ -0,0 +1,6 @@
1
+ """The director-node timeline: resolve a wired timeline from the canvas, then render it with ffmpeg.
2
+
3
+ Ported from the Studio ``electron/main/{timeline,export,media}`` modules. ``compose`` builds the
4
+ ffmpeg arg vector (pure), ``ffmpeg`` runs it, ``resolve`` derives the timeline from the moodboard
5
+ connectors, and ``render`` orchestrates preview/export.
6
+ """
@@ -0,0 +1,130 @@
1
+ """Build the ffmpeg args to render a director timeline (EDL) into one muxed MP4 — a faithful
2
+ port of the Studio ``electron/main/export/compose.ts``. Pure + deterministic, so unit-tested.
3
+
4
+ Model: clips reference absolute files at ``start_time``, trimmed to in/out. Track 0 = video
5
+ (images loop for a synthetic duration), track 1 = audio. Heterogeneous sources are normalised to a
6
+ common W×H/fps and 44.1k stereo, then overlaid / mixed onto a base black-video + silent-audio bed.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+
13
+
14
+ @dataclass
15
+ class ResolvedClip:
16
+ kind: str # 'video' | 'image' | 'audio'
17
+ abs_path: str
18
+ track: int # 0 = video, 1 = audio
19
+ start_time: float
20
+ in_point: float
21
+ out_point: float
22
+ has_audio: bool = False
23
+ muted: bool = False
24
+ volume: float = 1.0
25
+
26
+
27
+ @dataclass
28
+ class ComposeSettings:
29
+ width: int
30
+ height: int
31
+ fps: int
32
+ preset: str
33
+ crf: int
34
+ out_path: str
35
+
36
+
37
+ def _clip_duration(c: ResolvedClip) -> float:
38
+ return max(0.04, c.out_point - c.in_point)
39
+
40
+
41
+ def _clip_end(c: ResolvedClip) -> float:
42
+ return c.start_time + _clip_duration(c)
43
+
44
+
45
+ def timeline_duration(clips: list[ResolvedClip]) -> float:
46
+ """Total timeline length = the furthest clip end (min 0.04s)."""
47
+ return max((_clip_end(c) for c in clips), default=0.04)
48
+
49
+
50
+ def _contributes_audio(c: ResolvedClip) -> bool:
51
+ if c.volume <= 0:
52
+ return False
53
+ if c.track == 1:
54
+ return True
55
+ return c.kind == "video" and not c.muted and c.has_audio
56
+
57
+
58
+ def build_compose_args(clips: list[ResolvedClip], s: ComposeSettings) -> list[str]:
59
+ """The full ffmpeg arg vector (excluding the binary). Raises if there are no clips."""
60
+ if not clips:
61
+ raise ValueError("Cannot compose an empty timeline.")
62
+ total_str = f"{timeline_duration(clips):.3f}"
63
+ args: list[str] = ["-y"]
64
+
65
+ # Base beds: a black video and silent audio spanning the whole timeline.
66
+ args += ["-f", "lavfi", "-t", total_str, "-i",
67
+ f"color=c=black:s={s.width}x{s.height}:r={s.fps}"]
68
+ args += ["-f", "lavfi", "-t", total_str, "-i",
69
+ "anullsrc=channel_layout=stereo:sample_rate=44100"]
70
+
71
+ # One input per clip (index starts at 2). Images loop for their duration.
72
+ for c in clips:
73
+ dur = f"{_clip_duration(c):.3f}"
74
+ if c.kind == "image":
75
+ args += ["-loop", "1", "-t", dur, "-i", c.abs_path]
76
+ else:
77
+ args += ["-ss", f"{c.in_point:.3f}", "-t", dur, "-i", c.abs_path]
78
+
79
+ filters: list[str] = []
80
+ video_label = "0:v"
81
+ for i, c in enumerate(clips):
82
+ inp = i + 2
83
+ if c.track == 0 and c.kind in ("video", "image"):
84
+ v = f"v{i}"
85
+ filters.append(
86
+ f"[{inp}:v]scale={s.width}:{s.height}:force_original_aspect_ratio=decrease,"
87
+ f"pad={s.width}:{s.height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={s.fps},"
88
+ f"setpts=PTS-STARTPTS+{c.start_time:.3f}/TB[{v}]"
89
+ )
90
+ out = f"ov{i}"
91
+ filters.append(
92
+ f"[{video_label}][{v}]overlay="
93
+ f"enable='between(t,{c.start_time:.3f},{_clip_end(c):.3f})'[{out}]"
94
+ )
95
+ video_label = out
96
+
97
+ audio_labels: list[str] = ["1:a"]
98
+ for i, c in enumerate(clips):
99
+ inp = i + 2
100
+ if _contributes_audio(c):
101
+ ms = round(c.start_time * 1000)
102
+ dur = _clip_duration(c)
103
+ fade = min(0.02, dur / 4)
104
+ fade_out = f"{max(0.0, dur - fade):.3f}"
105
+ a = f"a{len(audio_labels) - 1}"
106
+ filters.append(
107
+ f"[{inp}:a]atrim=0:{dur:.3f},asetpts=PTS-STARTPTS,"
108
+ f"aformat=sample_rates=44100:channel_layouts=stereo,"
109
+ f"volume={c.volume:.2f},"
110
+ f"afade=t=in:st=0:d={fade:.3f},afade=t=out:st={fade_out}:d={fade:.3f},"
111
+ f"adelay={ms}|{ms}[{a}]"
112
+ )
113
+ audio_labels.append(a)
114
+
115
+ audio_out = "1:a"
116
+ if len(audio_labels) > 1:
117
+ audio_out = "aout"
118
+ joined = "".join(f"[{lbl}]" for lbl in audio_labels)
119
+ filters.append(
120
+ f"{joined}amix=inputs={len(audio_labels)}:normalize=0:dropout_transition=0[{audio_out}]"
121
+ )
122
+
123
+ if filters:
124
+ args += ["-filter_complex", ";".join(filters)]
125
+ args += ["-map", "0:v" if video_label == "0:v" else f"[{video_label}]"]
126
+ args += ["-map", "1:a" if audio_out == "1:a" else f"[{audio_out}]"]
127
+ args += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-preset", s.preset, "-crf", str(s.crf),
128
+ "-c:a", "aac", "-movflags", "+faststart", "-r", str(s.fps), "-t", total_str,
129
+ s.out_path]
130
+ return args
@@ -0,0 +1,76 @@
1
+ """ffmpeg/ffprobe for the timeline: locate the binary, probe media, run a render with progress.
2
+
3
+ Prefers a bundled ``imageio-ffmpeg`` binary, else a system ``ffmpeg`` on PATH. ffprobe comes from
4
+ PATH only (imageio bundles ffmpeg alone); probing degrades gracefully when it's absent.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import shutil
12
+ import subprocess
13
+ from collections.abc import Callable
14
+ from functools import lru_cache
15
+
16
+
17
+ @lru_cache(maxsize=1)
18
+ def ffmpeg_exe() -> str | None:
19
+ try:
20
+ import imageio_ffmpeg
21
+
22
+ return imageio_ffmpeg.get_ffmpeg_exe()
23
+ except Exception: # noqa: BLE001
24
+ return shutil.which("ffmpeg")
25
+
26
+
27
+ @lru_cache(maxsize=1)
28
+ def ffprobe_exe() -> str | None:
29
+ return shutil.which("ffprobe")
30
+
31
+
32
+ def ffmpeg_available() -> bool:
33
+ return ffmpeg_exe() is not None
34
+
35
+
36
+ def probe_media(abs_path: str) -> dict[str, object]:
37
+ """``{"durationSec", "hasAudio"}`` via ffprobe; conservative defaults if unavailable."""
38
+ probe = ffprobe_exe()
39
+ if probe:
40
+ try:
41
+ out = subprocess.run(
42
+ [probe, "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams",
43
+ abs_path],
44
+ capture_output=True, text=True, timeout=30,
45
+ )
46
+ data = json.loads(out.stdout or "{}")
47
+ duration = float(data.get("format", {}).get("duration") or 0)
48
+ has_audio = any(s.get("codec_type") == "audio" for s in data.get("streams", []))
49
+ return {"durationSec": duration, "hasAudio": has_audio}
50
+ except (OSError, ValueError, json.JSONDecodeError, subprocess.SubprocessError):
51
+ pass
52
+ return {"durationSec": 0.0, "hasAudio": False}
53
+
54
+
55
+ async def compose_render(
56
+ args: list[str], total: float, on_progress: Callable[[float], None]
57
+ ) -> bool:
58
+ """Run ffmpeg with the arg vector, parsing -progress for a 0..1 fraction. True on success."""
59
+ exe = ffmpeg_exe()
60
+ if exe is None:
61
+ raise RuntimeError("ffmpeg is not available.")
62
+ proc = await asyncio.create_subprocess_exec(
63
+ exe, "-progress", "pipe:1", "-nostats", *args,
64
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
65
+ )
66
+ assert proc.stdout is not None
67
+ async for raw in proc.stdout:
68
+ line = raw.decode(errors="ignore").strip()
69
+ if line.startswith("out_time_ms="):
70
+ try:
71
+ ms = int(line.split("=", 1)[1])
72
+ on_progress(min(1.0, (ms / 1_000_000) / max(0.04, total)))
73
+ except ValueError:
74
+ pass
75
+ await proc.wait()
76
+ return proc.returncode == 0
@@ -0,0 +1,120 @@
1
+ """Orchestrate rendering a director timeline: preview (low-res proxy) + export (full-res), plus
2
+ the hero-take folder export. Ports ``electron/main/timeline/compose.ts`` + ``export/folder.ts``.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ import shutil
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .. import moodboard as mb
14
+ from .compose import ComposeSettings, build_compose_args, timeline_duration
15
+ from .ffmpeg import compose_render, ffmpeg_available
16
+ from .resolve import resolve_timeline, resolve_trim
17
+
18
+ _DEFAULT = {"width": 1920, "height": 1080, "fps": 30}
19
+
20
+
21
+ def _even(n: float) -> int:
22
+ return max(2, round(n / 2) * 2)
23
+
24
+
25
+ def _clamp01(v: Any) -> float:
26
+ return min(1.0, max(0.0, float(v))) if isinstance(v, (int, float)) else 1.0
27
+
28
+
29
+ class Timeline:
30
+ def __init__(self, store: Any, events: Any) -> None:
31
+ self._store = store
32
+ self._events = events
33
+
34
+ def resolve(self, owner_item_id: str) -> dict[str, Any]:
35
+ display, _ = resolve_timeline(self._store.conn(), self._store.folder(), owner_item_id)
36
+ return display
37
+
38
+ def resolve_trim(self, item_id: str) -> dict[str, Any] | None:
39
+ return resolve_trim(self._store.conn(), self._store.folder(), item_id)
40
+
41
+ def set_volumes(self, owner_item_id: str, l1: Any, l2: Any) -> None:
42
+ conn = self._store.conn()
43
+ item = mb.get_item(conn, owner_item_id)
44
+ mb.update_item(
45
+ conn, owner_item_id,
46
+ {"data": {**item["data"], "l1Volume": _clamp01(l1), "l2Volume": _clamp01(l2)}},
47
+ )
48
+
49
+ async def build_preview(self, owner_item_id: str) -> str | None:
50
+ return await self._render(owner_item_id, preview=True)
51
+
52
+ async def export(self, owner_item_id: str) -> str | None:
53
+ return await self._render(owner_item_id, preview=False)
54
+
55
+ async def _render(self, owner_item_id: str, *, preview: bool) -> str | None:
56
+ if not ffmpeg_available():
57
+ raise RuntimeError("ffmpeg is not available.")
58
+ conn, folder = self._store.conn(), self._store.folder()
59
+ _, clips = resolve_timeline(conn, folder, owner_item_id)
60
+ if not clips:
61
+ return None
62
+ settings = (mb.get_item(conn, owner_item_id).get("data") or {}).get("director") or _DEFAULT
63
+ w, h, fps = settings["width"], settings["height"], settings["fps"]
64
+ stamp = int(time.time() * 1000)
65
+ if preview:
66
+ rel = f"thumbs/director-{owner_item_id}-{stamp}.preview.mp4"
67
+ cs = ComposeSettings(640, _even(640 * h / w), min(fps, 30), "ultrafast", 30,
68
+ str(folder / rel))
69
+ else:
70
+ rel = f"thumbs/director-{owner_item_id}-export-{stamp}.mp4"
71
+ cs = ComposeSettings(_even(w), _even(h), fps, "veryfast", 20, str(folder / rel))
72
+ (folder / "thumbs").mkdir(parents=True, exist_ok=True)
73
+
74
+ total = timeline_duration(clips)
75
+ ok = await compose_render(
76
+ build_compose_args(clips, cs), total,
77
+ lambda f: self._events.broadcast(
78
+ "events:timelineProgress", {"ownerItemId": owner_item_id, "fraction": f}
79
+ ),
80
+ )
81
+ self._events.broadcast(
82
+ "events:timelineProgress", {"ownerItemId": owner_item_id, "fraction": 1}
83
+ )
84
+ if not ok:
85
+ if not preview:
86
+ raise RuntimeError("Export render failed.")
87
+ return None
88
+ item = mb.get_item(conn, owner_item_id)
89
+ field = "directorPreview" if preview else "directorExport"
90
+ prev = item["data"].get(field)
91
+ mb.update_item(conn, owner_item_id, {"data": {**item["data"], field: rel}})
92
+ if prev and prev != rel:
93
+ try:
94
+ (folder / prev).unlink(missing_ok=True)
95
+ except OSError:
96
+ pass
97
+ return rel
98
+
99
+
100
+ def export_frames(conn: Any, folder: Path) -> dict[str, Any]:
101
+ """Copy each frame's hero-take Output, in order, into a numbered export dir (no browser
102
+ folder picker, so it goes under the project's ``exports/<timestamp>/``)."""
103
+ out_dir = folder / "exports" / str(int(time.time() * 1000))
104
+ out_dir.mkdir(parents=True, exist_ok=True)
105
+ rows = conn.execute(
106
+ "SELECT s.name AS name, t.file_path AS file_path FROM frames s "
107
+ "LEFT JOIN takes t ON s.hero_take_id = t.id ORDER BY s.position"
108
+ ).fetchall()
109
+ exported = 0
110
+ skipped: list[str] = []
111
+ for row in rows:
112
+ if not row["file_path"]:
113
+ skipped.append(row["name"])
114
+ continue
115
+ exported += 1
116
+ ext = Path(row["file_path"]).suffix or ".png"
117
+ safe = re.sub(r"[^\w.-]+", "_", row["name"])
118
+ dest = out_dir / f"{str(exported).zfill(3)}_{safe}{ext}"
119
+ shutil.copyfile(folder / row["file_path"], dest)
120
+ return {"dir": str(out_dir), "exported": exported, "skipped": skipped}