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.
- inline_core/__init__.py +7 -0
- inline_core/components/__init__.py +1 -0
- inline_core/components/conditioning.py +20 -0
- inline_core/components/interfaces.py +91 -0
- inline_core/config.py +33 -0
- inline_core/device/__init__.py +1 -0
- inline_core/device/auto.py +35 -0
- inline_core/device/detect.py +41 -0
- inline_core/device/memory.py +168 -0
- inline_core/device/policy.py +82 -0
- inline_core/device/types.py +31 -0
- inline_core/errors.py +42 -0
- inline_core/graph/__init__.py +1 -0
- inline_core/graph/cache.py +83 -0
- inline_core/graph/descriptor.py +81 -0
- inline_core/graph/executor.py +102 -0
- inline_core/graph/primitives.py +137 -0
- inline_core/graph/registry.py +61 -0
- inline_core/graph/runners.py +72 -0
- inline_core/graph/schema.py +136 -0
- inline_core/graph/topo.py +49 -0
- inline_core/graph/validate.py +56 -0
- inline_core/importer/__init__.py +1 -0
- inline_core/importer/comfy.py +162 -0
- inline_core/media.py +11 -0
- inline_core/models/__init__.py +8 -0
- inline_core/models/catalog.py +98 -0
- inline_core/models/zimage/__init__.py +11 -0
- inline_core/models/zimage/requirements.py +180 -0
- inline_core/models/zimage/runner.py +386 -0
- inline_core/parallel/__init__.py +13 -0
- inline_core/parallel/config.py +50 -0
- inline_core/parallel/group.py +136 -0
- inline_core/parallel/launch.py +44 -0
- inline_core/parallel/protocol.py +53 -0
- inline_core/parallel/registry.py +31 -0
- inline_core/parallel/worker.py +75 -0
- inline_core/runtime/__init__.py +1 -0
- inline_core/runtime/context.py +31 -0
- inline_core/runtime/file_store.py +51 -0
- inline_core/runtime/progress.py +83 -0
- inline_core/runtime/run.py +113 -0
- inline_core/runtime/store.py +18 -0
- inline_core/sampling/__init__.py +1 -0
- inline_core/sampling/batch.py +116 -0
- inline_core/server/__init__.py +1 -0
- inline_core/server/__main__.py +61 -0
- inline_core/server/app.py +327 -0
- inline_core/server/assets.py +47 -0
- inline_core/server/bootstrap.py +21 -0
- inline_core/server/frontend.py +37 -0
- inline_core/server/manager.py +195 -0
- inline_core/server/rpc.py +60 -0
- inline_core/server/run_store.py +155 -0
- inline_core/server/serialize.py +144 -0
- inline_core/studio/__init__.py +7 -0
- inline_core/studio/assets.py +194 -0
- inline_core/studio/config.py +29 -0
- inline_core/studio/fal.py +237 -0
- inline_core/studio/frames.py +552 -0
- inline_core/studio/generation.py +136 -0
- inline_core/studio/graph_build.py +109 -0
- inline_core/studio/handlers.py +236 -0
- inline_core/studio/models.py +173 -0
- inline_core/studio/moodboard.py +400 -0
- inline_core/studio/schema.py +278 -0
- inline_core/studio/store.py +291 -0
- inline_core/studio/timeline/__init__.py +6 -0
- inline_core/studio/timeline/compose.py +130 -0
- inline_core/studio/timeline/ffmpeg.py +76 -0
- inline_core/studio/timeline/render.py +120 -0
- inline_core/studio/timeline/resolve.py +189 -0
- inline_core/takes.py +31 -0
- inline_core-1.1.1.dist-info/METADATA +35 -0
- inline_core-1.1.1.dist-info/RECORD +77 -0
- inline_core-1.1.1.dist-info/WHEEL +4 -0
- 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}
|