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,400 @@
1
+ """Moodboard persistence — items (assets, text, frames, core nodes, layers, previews, director,
2
+ trim, prompt) and connectors — ported from the Studio ``electron/main/moodboard/store.ts``.
3
+
4
+ Operates on an open ``sqlite3.Connection``. The frame-creating adders compose with the frames
5
+ domain. Adders that need a fal node def (``add_gen_node``) take the model's kind/params/title from
6
+ the caller (the fal defs live studio-side).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sqlite3
13
+ import time
14
+ import uuid
15
+ from typing import Any
16
+
17
+ from . import frames as fr
18
+
19
+ _ITEM_COLUMNS = (
20
+ "id, project_id, type, asset_id, frame_id, parent_id, data, x, y, width, height, rotation, "
21
+ "z_index, created_at, updated_at"
22
+ )
23
+
24
+ _DEFAULT_SIZE = {"image": (320, 180), "video": (360, 203), "audio": (320, 80)}
25
+
26
+ _DEFAULT_TEXT = {
27
+ "text": "Text",
28
+ "fontSize": 18,
29
+ "bold": False,
30
+ "italic": False,
31
+ "underline": False,
32
+ "color": "#e4e4e7",
33
+ "align": "left",
34
+ }
35
+
36
+
37
+ def _now() -> int:
38
+ return int(time.time() * 1000)
39
+
40
+
41
+ def _uuid() -> str:
42
+ return str(uuid.uuid4())
43
+
44
+
45
+ def _project_id(conn: sqlite3.Connection) -> str:
46
+ row = conn.execute("SELECT id FROM project LIMIT 1").fetchone()
47
+ if row is None:
48
+ raise RuntimeError("No project is open.")
49
+ return row["id"]
50
+
51
+
52
+ def _parse(raw: str | None) -> dict[str, Any]:
53
+ if not raw:
54
+ return {}
55
+ try:
56
+ parsed = json.loads(raw)
57
+ except (json.JSONDecodeError, TypeError):
58
+ return {}
59
+ return parsed if isinstance(parsed, dict) else {}
60
+
61
+
62
+ def _row_to_item(row: sqlite3.Row) -> dict[str, Any]:
63
+ return {
64
+ "id": row["id"],
65
+ "projectId": row["project_id"],
66
+ "type": row["type"],
67
+ "assetId": row["asset_id"],
68
+ "frameId": row["frame_id"],
69
+ "parentId": row["parent_id"],
70
+ "data": _parse(row["data"]),
71
+ "x": row["x"],
72
+ "y": row["y"],
73
+ "width": row["width"],
74
+ "height": row["height"],
75
+ "rotation": row["rotation"],
76
+ "zIndex": row["z_index"],
77
+ "createdAt": row["created_at"],
78
+ "updatedAt": row["updated_at"],
79
+ }
80
+
81
+
82
+ def _row_to_connector(row: sqlite3.Row) -> dict[str, Any]:
83
+ return {
84
+ "id": row["id"],
85
+ "projectId": row["project_id"],
86
+ "fromItemId": row["from_item_id"],
87
+ "toItemId": row["to_item_id"],
88
+ "label": row["label"],
89
+ "data": _parse(row["data"]),
90
+ "createdAt": row["created_at"],
91
+ }
92
+
93
+
94
+ def get_item(conn: sqlite3.Connection, item_id: str) -> dict[str, Any]:
95
+ row = conn.execute("SELECT * FROM moodboard_items WHERE id = ?", (item_id,)).fetchone()
96
+ if row is None:
97
+ raise ValueError("Moodboard item not found.")
98
+ return _row_to_item(row)
99
+
100
+
101
+ def list_items(conn: sqlite3.Connection) -> list[dict[str, Any]]:
102
+ rows = conn.execute("SELECT * FROM moodboard_items").fetchall()
103
+ return [_row_to_item(r) for r in rows]
104
+
105
+
106
+ def list_board(conn: sqlite3.Connection) -> dict[str, Any]:
107
+ items = [_row_to_item(r) for r in conn.execute("SELECT * FROM moodboard_items").fetchall()]
108
+ connectors = [
109
+ _row_to_connector(r) for r in conn.execute("SELECT * FROM moodboard_connectors").fetchall()
110
+ ]
111
+ return {"items": items, "connectors": connectors}
112
+
113
+
114
+ def _next_z(conn: sqlite3.Connection) -> int:
115
+ row = conn.execute("SELECT MAX(z_index) AS z FROM moodboard_items").fetchone()
116
+ return (row["z"] or 0) + 1
117
+
118
+
119
+ def _insert_item(
120
+ conn: sqlite3.Connection,
121
+ *,
122
+ item_type: str,
123
+ x: float,
124
+ y: float,
125
+ width: float,
126
+ height: float,
127
+ data: dict[str, Any] | None = None,
128
+ asset_id: str | None = None,
129
+ frame_id: str | None = None,
130
+ z_index: int | None = None,
131
+ ) -> dict[str, Any]:
132
+ now = _now()
133
+ item = {
134
+ "id": _uuid(),
135
+ "project_id": _project_id(conn),
136
+ "type": item_type,
137
+ "asset_id": asset_id,
138
+ "frame_id": frame_id,
139
+ "parent_id": None,
140
+ "data": json.dumps(data or {}),
141
+ "x": x,
142
+ "y": y,
143
+ "width": width,
144
+ "height": height,
145
+ "rotation": 0,
146
+ "z_index": z_index if z_index is not None else _next_z(conn),
147
+ "created_at": now,
148
+ "updated_at": now,
149
+ }
150
+ conn.execute(
151
+ f"INSERT INTO moodboard_items ({_ITEM_COLUMNS}) VALUES "
152
+ "(:id, :project_id, :type, :asset_id, :frame_id, :parent_id, :data, :x, :y, :width, "
153
+ ":height, :rotation, :z_index, :created_at, :updated_at)",
154
+ item,
155
+ )
156
+ return get_item(conn, item["id"])
157
+
158
+
159
+ def add_asset(conn: sqlite3.Connection, asset_id: str, x: float, y: float) -> dict[str, Any]:
160
+ asset = conn.execute("SELECT id, kind FROM assets WHERE id = ?", (asset_id,)).fetchone()
161
+ if asset is None:
162
+ raise ValueError("Asset not found.")
163
+ w, h = _DEFAULT_SIZE.get(asset["kind"], (320, 180))
164
+ return _insert_item(conn, item_type="asset", x=x, y=y, width=w, height=h, asset_id=asset_id)
165
+
166
+
167
+ def add_text(conn: sqlite3.Connection, x: float, y: float) -> dict[str, Any]:
168
+ return _insert_item(
169
+ conn, item_type="text", x=x, y=y, width=200, height=60, data={"text": dict(_DEFAULT_TEXT)}
170
+ )
171
+
172
+
173
+ def add_core_node(conn: sqlite3.Connection, core_type: str, x: float, y: float) -> dict[str, Any]:
174
+ return _insert_item(
175
+ conn, item_type="core", x=x, y=y, width=200, height=120,
176
+ data={"core": {"type": core_type, "params": {}}},
177
+ )
178
+
179
+
180
+ def add_frame_item(conn: sqlite3.Connection, frame_id: str, x: float, y: float) -> dict[str, Any]:
181
+ return _insert_item(conn, item_type="frame", x=x, y=y, width=220, height=200, frame_id=frame_id)
182
+
183
+
184
+ def add_frame_from_asset(
185
+ conn: sqlite3.Connection, asset_id: str, x: float, y: float
186
+ ) -> dict[str, Any]:
187
+ frame = fr.add_from_asset(conn, asset_id)
188
+ return add_frame_item(conn, frame["id"], x, y)
189
+
190
+
191
+ def add_empty_frame(conn: sqlite3.Connection, x: float, y: float) -> dict[str, Any]:
192
+ frame = fr.create_empty_frame(conn)
193
+ return add_frame_item(conn, frame["id"], x, y)
194
+
195
+
196
+ def add_gen_node(
197
+ conn: sqlite3.Connection,
198
+ model_id: str,
199
+ x: float,
200
+ y: float,
201
+ *,
202
+ kind: str,
203
+ params: dict[str, Any],
204
+ title: str,
205
+ ) -> dict[str, Any]:
206
+ frame = fr.create_fal_frame(conn, model_id, kind, params, title)
207
+ return _insert_item(
208
+ conn, item_type="frame", x=x, y=y, width=240, height=380, frame_id=frame["id"]
209
+ )
210
+
211
+
212
+ def add_layer(conn: sqlite3.Connection, x: float, y: float) -> dict[str, Any]:
213
+ return _insert_item(
214
+ conn, item_type="layer", x=x, y=y, width=420, height=300, data={"name": "Layer"}, z_index=0
215
+ )
216
+
217
+
218
+ def add_preview(conn: sqlite3.Connection, x: float, y: float) -> dict[str, Any]:
219
+ return _insert_item(conn, item_type="preview", x=x, y=y, width=280, height=220)
220
+
221
+
222
+ def add_director(conn: sqlite3.Connection, x: float, y: float) -> dict[str, Any]:
223
+ return _insert_item(
224
+ conn, item_type="director", x=x, y=y, width=440, height=400,
225
+ data={"name": "Director", "director": {"width": 1920, "height": 1080, "fps": 30}},
226
+ )
227
+
228
+
229
+ def add_trim(conn: sqlite3.Connection, x: float, y: float) -> dict[str, Any]:
230
+ return _insert_item(
231
+ conn, item_type="trim", x=x, y=y, width=360, height=170,
232
+ data={"trim": {"inPoint": 0, "outPoint": 0}},
233
+ )
234
+
235
+
236
+ def add_prompt(conn: sqlite3.Connection, x: float, y: float) -> dict[str, Any]:
237
+ return _insert_item(
238
+ conn, item_type="prompt", x=x, y=y, width=240, height=120, data={"promptText": ""}
239
+ )
240
+
241
+
242
+ def update_item(conn: sqlite3.Connection, item_id: str, patch: dict[str, Any]) -> dict[str, Any]:
243
+ get_item(conn, item_id) # ensure exists
244
+ sets: list[str] = []
245
+ params: dict[str, Any] = {"id": item_id, "updated_at": _now()}
246
+ for key, column in (
247
+ ("x", "x"), ("y", "y"), ("width", "width"), ("height", "height"),
248
+ ("rotation", "rotation"), ("zIndex", "z_index"),
249
+ ):
250
+ value = patch.get(key)
251
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
252
+ sets.append(f"{column} = :{column}")
253
+ params[column] = value
254
+ if "data" in patch:
255
+ sets.append("data = :data")
256
+ params["data"] = json.dumps(patch["data"])
257
+ if "parentId" in patch:
258
+ sets.append("parent_id = :parent_id")
259
+ params["parent_id"] = patch["parentId"]
260
+ sets.append("updated_at = :updated_at")
261
+ conn.execute(f"UPDATE moodboard_items SET {', '.join(sets)} WHERE id = :id", params)
262
+ return get_item(conn, item_id)
263
+
264
+
265
+ def set_core_node_output(conn: sqlite3.Connection, item_id: str, output: dict[str, Any]) -> None:
266
+ """Store the latest render a Core media node produced, for display on the node."""
267
+ try:
268
+ item = get_item(conn, item_id)
269
+ except ValueError:
270
+ return
271
+ core = (item.get("data") or {}).get("core")
272
+ if item["type"] != "core" or not core:
273
+ return
274
+ update_item(conn, item_id, {"data": {**item["data"], "core": {**core, "output": output}}})
275
+
276
+
277
+ def delete_item(conn: sqlite3.Connection, item_id: str) -> None:
278
+ conn.execute(
279
+ "DELETE FROM moodboard_connectors WHERE from_item_id = ? OR to_item_id = ?",
280
+ (item_id, item_id),
281
+ )
282
+ conn.execute("DELETE FROM moodboard_items WHERE id = ?", (item_id,))
283
+
284
+
285
+ def replace_board(
286
+ conn: sqlite3.Connection, items: list[dict[str, Any]], connectors: list[dict[str, Any]]
287
+ ) -> None:
288
+ """Replace the whole board (undo/redo restore), preserving ids, in one transaction."""
289
+ pid = _project_id(conn)
290
+ conn.execute("DELETE FROM moodboard_connectors WHERE project_id = ?", (pid,))
291
+ conn.execute("DELETE FROM moodboard_items WHERE project_id = ?", (pid,))
292
+ for it in items:
293
+ conn.execute(
294
+ f"INSERT INTO moodboard_items ({_ITEM_COLUMNS}) VALUES "
295
+ "(:id, :project_id, :type, :asset_id, :frame_id, :parent_id, :data, :x, :y, :width, "
296
+ ":height, :rotation, :z_index, :created_at, :updated_at)",
297
+ {
298
+ "id": it["id"],
299
+ "project_id": pid,
300
+ "type": it["type"],
301
+ "asset_id": it.get("assetId"),
302
+ "frame_id": it.get("frameId"),
303
+ "parent_id": it.get("parentId"),
304
+ "data": json.dumps(it.get("data") or {}),
305
+ "x": it["x"],
306
+ "y": it["y"],
307
+ "width": it["width"],
308
+ "height": it["height"],
309
+ "rotation": it.get("rotation", 0),
310
+ "z_index": it.get("zIndex", 0),
311
+ "created_at": it.get("createdAt", _now()),
312
+ "updated_at": it.get("updatedAt", _now()),
313
+ },
314
+ )
315
+ for c in connectors:
316
+ conn.execute(
317
+ "INSERT INTO moodboard_connectors "
318
+ "(id, project_id, from_item_id, to_item_id, label, data, created_at) "
319
+ "VALUES (:id, :project_id, :from_item_id, :to_item_id, :label, :data, :created_at)",
320
+ {
321
+ "id": c["id"],
322
+ "project_id": pid,
323
+ "from_item_id": c["fromItemId"],
324
+ "to_item_id": c["toItemId"],
325
+ "label": c.get("label"),
326
+ "data": json.dumps(c.get("data") or {}),
327
+ "created_at": c.get("createdAt", _now()),
328
+ },
329
+ )
330
+
331
+
332
+ def create_connector(
333
+ conn: sqlite3.Connection,
334
+ from_item_id: str,
335
+ to_item_id: str,
336
+ source_handle: str | None = None,
337
+ target_handle: str | None = None,
338
+ ) -> dict[str, Any]:
339
+ get_item(conn, from_item_id)
340
+ get_item(conn, to_item_id)
341
+ connector = {
342
+ "id": _uuid(),
343
+ "projectId": _project_id(conn),
344
+ "fromItemId": from_item_id,
345
+ "toItemId": to_item_id,
346
+ "label": None,
347
+ "data": {"sourceHandle": source_handle, "targetHandle": target_handle},
348
+ "createdAt": _now(),
349
+ }
350
+ conn.execute(
351
+ "INSERT INTO moodboard_connectors "
352
+ "(id, project_id, from_item_id, to_item_id, label, data, created_at) "
353
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
354
+ (
355
+ connector["id"], connector["projectId"], from_item_id, to_item_id, None,
356
+ json.dumps(connector["data"]), connector["createdAt"],
357
+ ),
358
+ )
359
+ return connector
360
+
361
+
362
+ def delete_connector(conn: sqlite3.Connection, connector_id: str) -> None:
363
+ conn.execute("DELETE FROM moodboard_connectors WHERE id = ?", (connector_id,))
364
+
365
+
366
+ def set_connector_volume(conn: sqlite3.Connection, connector_id: str, volume: float) -> None:
367
+ row = conn.execute(
368
+ "SELECT data FROM moodboard_connectors WHERE id = ?", (connector_id,)
369
+ ).fetchone()
370
+ data = _parse(row["data"]) if row else {}
371
+ data["volume"] = min(1.0, max(0.0, volume))
372
+ conn.execute(
373
+ "UPDATE moodboard_connectors SET data = ? WHERE id = ?",
374
+ (json.dumps(data), connector_id),
375
+ )
376
+
377
+
378
+ def prompt_text_for_frame(conn: sqlite3.Connection, frame_id: str) -> str | None:
379
+ """The text of a Prompt node wired into this frame's canvas node on the 'prompt' handle."""
380
+ nodes = conn.execute(
381
+ "SELECT id FROM moodboard_items WHERE frame_id = ? AND type = 'frame'", (frame_id,)
382
+ ).fetchall()
383
+ for node in nodes:
384
+ conns = conn.execute(
385
+ "SELECT from_item_id, data FROM moodboard_connectors WHERE to_item_id = ?",
386
+ (node["id"],),
387
+ ).fetchall()
388
+ for c in conns:
389
+ if _parse(c["data"]).get("targetHandle") != "prompt":
390
+ continue
391
+ src = conn.execute(
392
+ "SELECT data FROM moodboard_items WHERE id = ? AND type = 'prompt'",
393
+ (c["from_item_id"],),
394
+ ).fetchone()
395
+ if src is None:
396
+ continue
397
+ text = _parse(src["data"]).get("promptText")
398
+ if isinstance(text, str) and text.strip():
399
+ return text
400
+ return None
@@ -0,0 +1,278 @@
1
+ """SQLite schema for a project's ``project.db`` — a faithful port of the Studio TypeScript
2
+ ``electron/main/db/schema.ts`` (SCHEMA_VERSION 14). The DB is the source of truth for a project;
3
+ "save" is implicit. Bumping ``SCHEMA_VERSION`` + adding a migration is how the schema evolves.
4
+
5
+ Kept byte-compatible with the Node schema so Core can open existing ``.inlinestudio`` projects: same
6
+ tables, same column names, same ``user_version`` stamping, and the same additive/rename migrations.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sqlite3
12
+
13
+ SCHEMA_VERSION = 14
14
+
15
+ SCHEMA_SQL = """
16
+ CREATE TABLE IF NOT EXISTS project (
17
+ id TEXT PRIMARY KEY,
18
+ name TEXT NOT NULL,
19
+ created_at INTEGER NOT NULL,
20
+ updated_at INTEGER NOT NULL
21
+ );
22
+
23
+ CREATE TABLE IF NOT EXISTS sequences (
24
+ id TEXT PRIMARY KEY,
25
+ project_id TEXT NOT NULL,
26
+ name TEXT NOT NULL,
27
+ position INTEGER NOT NULL
28
+ );
29
+
30
+ CREATE TABLE IF NOT EXISTS frames (
31
+ id TEXT PRIMARY KEY,
32
+ sequence_id TEXT NOT NULL,
33
+ name TEXT NOT NULL,
34
+ kind TEXT NOT NULL,
35
+ position INTEGER NOT NULL,
36
+ input_asset_id TEXT,
37
+ hero_take_id TEXT,
38
+ provider TEXT NOT NULL DEFAULT 'comfy',
39
+ model_id TEXT,
40
+ params TEXT NOT NULL DEFAULT '{}',
41
+ workflow_template_id TEXT,
42
+ comfy_workflow_name TEXT,
43
+ comfy_workflow_ready INTEGER NOT NULL DEFAULT 0,
44
+ created_at INTEGER NOT NULL,
45
+ updated_at INTEGER NOT NULL
46
+ );
47
+
48
+ CREATE TABLE IF NOT EXISTS takes (
49
+ id TEXT PRIMARY KEY,
50
+ frame_id TEXT NOT NULL,
51
+ file_path TEXT NOT NULL,
52
+ kind TEXT NOT NULL,
53
+ params TEXT NOT NULL,
54
+ comfy_prompt_id TEXT,
55
+ created_at INTEGER NOT NULL
56
+ );
57
+
58
+ CREATE TABLE IF NOT EXISTS frame_inputs (
59
+ id TEXT PRIMARY KEY,
60
+ frame_id TEXT NOT NULL,
61
+ asset_id TEXT,
62
+ source_frame_id TEXT,
63
+ position INTEGER NOT NULL
64
+ );
65
+
66
+ CREATE TABLE IF NOT EXISTS asset_folders (
67
+ id TEXT PRIMARY KEY,
68
+ project_id TEXT NOT NULL,
69
+ name TEXT NOT NULL,
70
+ parent_id TEXT,
71
+ created_at INTEGER NOT NULL
72
+ );
73
+
74
+ CREATE TABLE IF NOT EXISTS assets (
75
+ id TEXT PRIMARY KEY,
76
+ project_id TEXT NOT NULL,
77
+ folder_id TEXT,
78
+ name TEXT NOT NULL,
79
+ file_path TEXT NOT NULL,
80
+ kind TEXT NOT NULL,
81
+ thumb_path TEXT,
82
+ preview_path TEXT,
83
+ created_at INTEGER NOT NULL
84
+ );
85
+
86
+ CREATE TABLE IF NOT EXISTS moodboard_items (
87
+ id TEXT PRIMARY KEY,
88
+ project_id TEXT NOT NULL,
89
+ type TEXT NOT NULL DEFAULT 'asset',
90
+ asset_id TEXT,
91
+ frame_id TEXT,
92
+ parent_id TEXT,
93
+ data TEXT,
94
+ x REAL NOT NULL,
95
+ y REAL NOT NULL,
96
+ width REAL NOT NULL,
97
+ height REAL NOT NULL,
98
+ rotation REAL NOT NULL DEFAULT 0,
99
+ z_index INTEGER NOT NULL DEFAULT 0,
100
+ created_at INTEGER NOT NULL DEFAULT 0,
101
+ updated_at INTEGER NOT NULL DEFAULT 0
102
+ );
103
+
104
+ CREATE TABLE IF NOT EXISTS moodboard_connectors (
105
+ id TEXT PRIMARY KEY,
106
+ project_id TEXT NOT NULL,
107
+ from_item_id TEXT NOT NULL,
108
+ to_item_id TEXT NOT NULL,
109
+ label TEXT,
110
+ data TEXT,
111
+ created_at INTEGER NOT NULL DEFAULT 0
112
+ );
113
+
114
+ CREATE TABLE IF NOT EXISTS workflow_templates (
115
+ id TEXT PRIMARY KEY,
116
+ project_id TEXT NOT NULL,
117
+ name TEXT NOT NULL,
118
+ graph TEXT NOT NULL,
119
+ params TEXT NOT NULL
120
+ );
121
+
122
+ CREATE TABLE IF NOT EXISTS pending_generation (
123
+ id TEXT PRIMARY KEY,
124
+ frame_id TEXT NOT NULL,
125
+ model_id TEXT NOT NULL,
126
+ endpoint TEXT NOT NULL,
127
+ request_id TEXT NOT NULL,
128
+ status_url TEXT NOT NULL,
129
+ response_url TEXT NOT NULL,
130
+ params TEXT NOT NULL,
131
+ created_at INTEGER NOT NULL
132
+ );
133
+
134
+ CREATE INDEX IF NOT EXISTS idx_frames_sequence ON frames(sequence_id);
135
+ CREATE INDEX IF NOT EXISTS idx_takes_frame ON takes(frame_id);
136
+ CREATE INDEX IF NOT EXISTS idx_frame_inputs_frame ON frame_inputs(frame_id);
137
+ CREATE INDEX IF NOT EXISTS idx_assets_project ON assets(project_id);
138
+ CREATE INDEX IF NOT EXISTS idx_assets_folder ON assets(folder_id);
139
+ CREATE INDEX IF NOT EXISTS idx_asset_folders_parent ON asset_folders(parent_id);
140
+ CREATE INDEX IF NOT EXISTS idx_moodboard_items_project ON moodboard_items(project_id);
141
+ CREATE INDEX IF NOT EXISTS idx_moodboard_connectors_project ON moodboard_connectors(project_id);
142
+ """
143
+
144
+
145
+ def apply_schema(conn: sqlite3.Connection) -> None:
146
+ """Create tables (idempotent) and stamp the schema version. Mirrors ``applySchema``."""
147
+ conn.execute("PRAGMA journal_mode = WAL")
148
+ conn.execute("PRAGMA foreign_keys = ON")
149
+ from_version = int(conn.execute("PRAGMA user_version").fetchone()[0])
150
+ # Additive column migrations run before SCHEMA_SQL (its indexes reference new columns).
151
+ _migrate_columns(conn)
152
+ conn.executescript(SCHEMA_SQL)
153
+ _run_data_migrations(conn, from_version)
154
+ _stamp_version(conn)
155
+ conn.commit()
156
+
157
+
158
+ def _run_data_migrations(conn: sqlite3.Connection, from_version: int) -> None:
159
+ # v5 -> v6: move each frame's single input_asset_id into the new frame_inputs table. Idempotent.
160
+ if from_version < 6:
161
+ conn.execute(
162
+ """
163
+ INSERT INTO frame_inputs (id, frame_id, asset_id, position)
164
+ SELECT lower(hex(randomblob(16))), id, input_asset_id, 0
165
+ FROM frames
166
+ WHERE input_asset_id IS NOT NULL
167
+ AND NOT EXISTS (SELECT 1 FROM frame_inputs si WHERE si.frame_id = frames.id);
168
+ """
169
+ )
170
+
171
+
172
+ def _migrate_columns(conn: sqlite3.Connection) -> None:
173
+ """Additive column + rename migrations for pre-existing projects (no-ops on fresh DBs)."""
174
+ _migrate_renames(conn) # v7 -> v8: shot -> frame
175
+
176
+ _add_column_if_missing(conn, "assets", "folder_id", "TEXT")
177
+
178
+ _add_column_if_missing(conn, "moodboard_items", "type", "TEXT NOT NULL DEFAULT 'asset'")
179
+ _add_column_if_missing(conn, "moodboard_items", "data", "TEXT")
180
+ _add_column_if_missing(conn, "moodboard_items", "rotation", "REAL NOT NULL DEFAULT 0")
181
+ _add_column_if_missing(conn, "moodboard_items", "z_index", "INTEGER NOT NULL DEFAULT 0")
182
+ _add_column_if_missing(conn, "moodboard_items", "created_at", "INTEGER NOT NULL DEFAULT 0")
183
+ _add_column_if_missing(conn, "moodboard_items", "updated_at", "INTEGER NOT NULL DEFAULT 0")
184
+
185
+ _add_column_if_missing(conn, "frames", "input_asset_id", "TEXT")
186
+ _add_column_if_missing(conn, "frames", "comfy_workflow_name", "TEXT")
187
+
188
+ _add_column_if_missing(conn, "moodboard_items", "frame_id", "TEXT")
189
+ _add_column_if_missing(conn, "moodboard_items", "parent_id", "TEXT")
190
+ _add_column_if_missing(conn, "frame_inputs", "source_frame_id", "TEXT")
191
+
192
+ _relax_frame_inputs_asset_id(conn) # v8 -> v9: asset_id must be nullable
193
+
194
+ _add_column_if_missing(conn, "assets", "preview_path", "TEXT")
195
+ _add_column_if_missing(conn, "frames", "comfy_workflow_ready", "INTEGER NOT NULL DEFAULT 0")
196
+
197
+ # v12 -> v13: fal provider + model id + params.
198
+ _add_column_if_missing(conn, "frames", "provider", "TEXT NOT NULL DEFAULT 'comfy'")
199
+ _add_column_if_missing(conn, "frames", "model_id", "TEXT")
200
+ _add_column_if_missing(conn, "frames", "params", "TEXT NOT NULL DEFAULT '{}'")
201
+
202
+
203
+ def _relax_frame_inputs_asset_id(conn: sqlite3.Connection) -> None:
204
+ """Rebuild frame_inputs to drop a legacy NOT NULL on asset_id. Idempotent."""
205
+ if not _table_exists(conn, "frame_inputs"):
206
+ return
207
+ cols = conn.execute("PRAGMA table_info(frame_inputs)").fetchall()
208
+ asset = next((c for c in cols if c[1] == "asset_id"), None)
209
+ if asset is None or asset[3] == 0: # c[3] = notnull flag
210
+ return
211
+ conn.executescript(
212
+ """
213
+ CREATE TABLE frame_inputs_new (
214
+ id TEXT PRIMARY KEY,
215
+ frame_id TEXT NOT NULL,
216
+ asset_id TEXT,
217
+ source_frame_id TEXT,
218
+ position INTEGER NOT NULL
219
+ );
220
+ INSERT INTO frame_inputs_new (id, frame_id, asset_id, source_frame_id, position)
221
+ SELECT id, frame_id, asset_id, source_frame_id, position FROM frame_inputs;
222
+ DROP TABLE frame_inputs;
223
+ ALTER TABLE frame_inputs_new RENAME TO frame_inputs;
224
+ """
225
+ )
226
+
227
+
228
+ def _migrate_renames(conn: sqlite3.Connection) -> None:
229
+ """v7 -> v8: the "shot" domain was renamed to "frame". Guarded, so a no-op on fresh DBs."""
230
+ if _table_exists(conn, "shots") and not _table_exists(conn, "frames"):
231
+ conn.execute("ALTER TABLE shots RENAME TO frames")
232
+ if _table_exists(conn, "shot_inputs") and not _table_exists(conn, "frame_inputs"):
233
+ conn.execute("ALTER TABLE shot_inputs RENAME TO frame_inputs")
234
+ _rename_column_if_exists(conn, "frame_inputs", "shot_id", "frame_id")
235
+ _rename_column_if_exists(conn, "frame_inputs", "source_shot_id", "source_frame_id")
236
+ _rename_column_if_exists(conn, "takes", "shot_id", "frame_id")
237
+ _rename_column_if_exists(conn, "moodboard_items", "shot_id", "frame_id")
238
+ if _table_exists(conn, "moodboard_items"):
239
+ conn.execute("UPDATE moodboard_items SET type='frame' WHERE type='shot'")
240
+ conn.executescript(
241
+ "DROP INDEX IF EXISTS idx_shots_sequence;"
242
+ "DROP INDEX IF EXISTS idx_takes_shot;"
243
+ "DROP INDEX IF EXISTS idx_shot_inputs_shot;"
244
+ )
245
+
246
+
247
+ def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
248
+ row = conn.execute(
249
+ "SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)
250
+ ).fetchone()
251
+ return row is not None
252
+
253
+
254
+ def _column_names(conn: sqlite3.Connection, table: str) -> set[str]:
255
+ return {c[1] for c in conn.execute(f"PRAGMA table_info({table})").fetchall()}
256
+
257
+
258
+ def _rename_column_if_exists(conn: sqlite3.Connection, table: str, old: str, new: str) -> None:
259
+ if not _table_exists(conn, table):
260
+ return
261
+ cols = _column_names(conn, table)
262
+ if old in cols and new not in cols:
263
+ conn.execute(f"ALTER TABLE {table} RENAME COLUMN {old} TO {new}")
264
+
265
+
266
+ def _add_column_if_missing(
267
+ conn: sqlite3.Connection, table: str, column: str, definition: str
268
+ ) -> None:
269
+ if not _table_exists(conn, table):
270
+ return
271
+ if column not in _column_names(conn, table):
272
+ conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {definition}")
273
+
274
+
275
+ def _stamp_version(conn: sqlite3.Connection) -> None:
276
+ current = int(conn.execute("PRAGMA user_version").fetchone()[0])
277
+ if current < SCHEMA_VERSION:
278
+ conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}")