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,155 @@
1
+ """Durable run storage (SQLite, stdlib). A runId and its final state survive a process restart, so
2
+ GET /v1/runs/{id} keeps working after Core is bounced. Runs left mid-flight by a crash are marked
3
+ interrupted on the next start. Live progress ticks are not persisted (they are lost on a crash
4
+ anyway); the record captures structure, terminal status, and takes.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sqlite3
11
+ import threading
12
+ from abc import ABC, abstractmethod
13
+ from pathlib import Path
14
+
15
+ from ..media import MediaKind
16
+ from ..runtime.run import NodeRuntimeState, NodeState, RunError, RunState, RunStatus
17
+ from ..takes import Take
18
+
19
+
20
+ class RunStore(ABC):
21
+ @abstractmethod
22
+ def interrupt_stale(self) -> None: ...
23
+
24
+ @abstractmethod
25
+ def create(self, state: RunState, client_run_id: str | None) -> None: ...
26
+
27
+ @abstractmethod
28
+ def update(self, state: RunState) -> None: ...
29
+
30
+ @abstractmethod
31
+ def load(self, run_id: str) -> RunState | None: ...
32
+
33
+ @abstractmethod
34
+ def find_take(self, take_id: str) -> Take | None: ...
35
+
36
+
37
+ _SCHEMA = """
38
+ CREATE TABLE IF NOT EXISTS runs (
39
+ run_id TEXT PRIMARY KEY, target TEXT, status TEXT, fraction REAL,
40
+ error_message TEXT, error_node TEXT, client_run_id TEXT, created_at INTEGER
41
+ );
42
+ CREATE TABLE IF NOT EXISTS run_nodes (
43
+ run_id TEXT, node_id TEXT, state TEXT, phase TEXT, fraction REAL,
44
+ step INTEGER, step_count INTEGER, status TEXT, PRIMARY KEY (run_id, node_id)
45
+ );
46
+ CREATE TABLE IF NOT EXISTS run_takes (
47
+ take_id TEXT PRIMARY KEY, run_id TEXT, node_id TEXT, kind TEXT, uri TEXT,
48
+ hash TEXT, params TEXT, created_at INTEGER
49
+ );
50
+ """
51
+
52
+
53
+ class SqliteRunStore(RunStore):
54
+ def __init__(self, path: Path) -> None:
55
+ path.parent.mkdir(parents=True, exist_ok=True)
56
+ self._conn = sqlite3.connect(str(path), check_same_thread=False)
57
+ self._lock = threading.Lock()
58
+ with self._lock, self._conn:
59
+ self._conn.executescript(_SCHEMA)
60
+
61
+ def interrupt_stale(self) -> None:
62
+ with self._lock, self._conn:
63
+ self._conn.execute(
64
+ "UPDATE runs SET status=?, error_message=? WHERE status IN ('queued','running')",
65
+ (RunStatus.ERROR.value, "interrupted by a restart"),
66
+ )
67
+
68
+ def create(self, state: RunState, client_run_id: str | None) -> None:
69
+ with self._lock, self._conn:
70
+ self._conn.execute(
71
+ "INSERT OR REPLACE INTO runs VALUES (?,?,?,?,?,?,?,?)",
72
+ (state.run_id, state.target, state.status.value, state.fraction, None, None,
73
+ client_run_id, 0),
74
+ )
75
+ self._write_nodes(state)
76
+
77
+ def update(self, state: RunState) -> None:
78
+ error_message = state.error.message if state.error is not None else None
79
+ error_node = state.error.node_id if state.error is not None else None
80
+ with self._lock, self._conn:
81
+ self._conn.execute(
82
+ "UPDATE runs SET status=?, fraction=?, error_message=?, error_node=? "
83
+ "WHERE run_id=?",
84
+ (state.status.value, state.fraction, error_message, error_node, state.run_id),
85
+ )
86
+ self._write_nodes(state)
87
+ self._conn.execute("DELETE FROM run_takes WHERE run_id=?", (state.run_id,))
88
+ self._conn.executemany(
89
+ "INSERT OR REPLACE INTO run_takes VALUES (?,?,?,?,?,?,?,?)",
90
+ [
91
+ (t.id, t.run_id, t.node_id, t.kind.value, t.uri, t.hash,
92
+ json.dumps(t.params), t.created_at)
93
+ for t in state.takes
94
+ ],
95
+ )
96
+
97
+ def _write_nodes(self, state: RunState) -> None:
98
+ self._conn.execute("DELETE FROM run_nodes WHERE run_id=?", (state.run_id,))
99
+ self._conn.executemany(
100
+ "INSERT OR REPLACE INTO run_nodes VALUES (?,?,?,?,?,?,?,?)",
101
+ [
102
+ (state.run_id, node_id, n.state.value, n.phase, n.fraction, n.step, n.step_count,
103
+ n.status)
104
+ for node_id, n in state.nodes.items()
105
+ ],
106
+ )
107
+
108
+ def load(self, run_id: str) -> RunState | None:
109
+ with self._lock:
110
+ row = self._conn.execute(
111
+ "SELECT target, status, fraction, error_message, error_node FROM runs "
112
+ "WHERE run_id=?",
113
+ (run_id,),
114
+ ).fetchone()
115
+ if row is None:
116
+ return None
117
+ node_rows = self._conn.execute(
118
+ "SELECT node_id, state, phase, fraction, step, step_count, status FROM run_nodes "
119
+ "WHERE run_id=?",
120
+ (run_id,),
121
+ ).fetchall()
122
+ take_rows = self._conn.execute(
123
+ "SELECT take_id, node_id, kind, uri, hash, params, created_at FROM run_takes "
124
+ "WHERE run_id=?",
125
+ (run_id,),
126
+ ).fetchall()
127
+
128
+ target, status, fraction, error_message, error_node = row
129
+ state = RunState(run_id=run_id, target=target, status=RunStatus(status), fraction=fraction)
130
+ for node_id, node_state, phase, node_fraction, step, step_count, node_status in node_rows:
131
+ state.nodes[node_id] = NodeRuntimeState(
132
+ state=NodeState(node_state), phase=phase, fraction=node_fraction,
133
+ step=step, step_count=step_count, status=node_status,
134
+ )
135
+ for take_id, node_id, kind, uri, take_hash, params, created_at in take_rows:
136
+ state.takes.append(
137
+ Take(id=take_id, run_id=run_id, node_id=node_id, kind=MediaKind(kind),
138
+ uri=uri, hash=take_hash, params=json.loads(params), created_at=created_at)
139
+ )
140
+ if error_message is not None:
141
+ state.error = RunError(message=error_message, node_id=error_node)
142
+ return state
143
+
144
+ def find_take(self, take_id: str) -> Take | None:
145
+ with self._lock:
146
+ row = self._conn.execute(
147
+ "SELECT run_id, node_id, kind, uri, hash, params, created_at FROM run_takes "
148
+ "WHERE take_id=?",
149
+ (take_id,),
150
+ ).fetchone()
151
+ if row is None:
152
+ return None
153
+ run_id, node_id, kind, uri, take_hash, params, created_at = row
154
+ return Take(id=take_id, run_id=run_id, node_id=node_id, kind=MediaKind(kind),
155
+ uri=uri, hash=take_hash, params=json.loads(params), created_at=created_at)
@@ -0,0 +1,144 @@
1
+ """Domain objects -> contract JSON (camelCase). The API boundary; keeps the domain types clean."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..graph.descriptor import NodeDescriptor, ParamField, Port
8
+ from ..models.catalog import ModelCatalog
9
+ from ..runtime.progress import (
10
+ CancelledEvent,
11
+ NodeDoneEvent,
12
+ ProgressEvent,
13
+ RunDoneEvent,
14
+ RunEvent,
15
+ )
16
+ from ..runtime.run import NodeRuntimeState, RunState
17
+ from ..takes import Take
18
+
19
+
20
+ def port_json(port: Port) -> dict[str, Any]:
21
+ return {"id": port.id, "label": port.label, "kind": port.kind.value, "required": port.required}
22
+
23
+
24
+ def param_json(field: ParamField, catalog: ModelCatalog | None = None) -> dict[str, Any]:
25
+ out: dict[str, Any] = {
26
+ "key": field.key,
27
+ "label": field.label,
28
+ "widget": field.widget.value,
29
+ "default": field.default,
30
+ }
31
+ if field.min is not None:
32
+ out["min"] = field.min
33
+ if field.max is not None:
34
+ out["max"] = field.max
35
+ if field.step is not None:
36
+ out["step"] = field.step
37
+ options = [{"value": o.value, "label": o.label} for o in field.options]
38
+ if field.options_from is not None:
39
+ out["optionsFrom"] = field.options_from
40
+ if catalog is not None:
41
+ options += [{"value": f, "label": f} for f in catalog.list(field.options_from)]
42
+ if options:
43
+ out["options"] = options
44
+ if field.advanced:
45
+ out["advanced"] = True
46
+ return out
47
+
48
+
49
+ def descriptor_json(
50
+ descriptor: NodeDescriptor, catalog: ModelCatalog | None = None
51
+ ) -> dict[str, Any]:
52
+ out: dict[str, Any] = {
53
+ "type": descriptor.type,
54
+ "title": descriptor.title,
55
+ "category": descriptor.category,
56
+ "icon": descriptor.icon,
57
+ "source": descriptor.source,
58
+ "outputKind": descriptor.output_kind.value if descriptor.output_kind else None,
59
+ "inputs": [port_json(p) for p in descriptor.inputs],
60
+ "outputs": [port_json(p) for p in descriptor.outputs],
61
+ "params": [param_json(p, catalog) for p in descriptor.params],
62
+ }
63
+ if descriptor.hidden:
64
+ out["hidden"] = True
65
+ return out
66
+
67
+
68
+ def take_json(take: Take) -> dict[str, Any]:
69
+ return {
70
+ "id": take.id,
71
+ "runId": take.run_id,
72
+ "nodeId": take.node_id,
73
+ "kind": take.kind.value,
74
+ "uri": take.uri,
75
+ "hash": take.hash,
76
+ "params": take.params,
77
+ "createdAt": take.created_at,
78
+ }
79
+
80
+
81
+ def node_json(node: NodeRuntimeState) -> dict[str, Any]:
82
+ out: dict[str, Any] = {"state": node.state.value, "fraction": node.fraction}
83
+ if node.phase is not None:
84
+ out["phase"] = node.phase
85
+ if node.step is not None:
86
+ out["step"] = node.step
87
+ if node.step_count is not None:
88
+ out["stepCount"] = node.step_count
89
+ if node.status:
90
+ out["status"] = node.status
91
+ return out
92
+
93
+
94
+ def run_json(state: RunState) -> dict[str, Any]:
95
+ return {
96
+ "runId": state.run_id,
97
+ "status": state.status.value,
98
+ "target": state.target,
99
+ "fraction": state.fraction,
100
+ "nodes": {node_id: node_json(n) for node_id, n in state.nodes.items()},
101
+ "takes": [take_json(t) for t in state.takes],
102
+ "error": (
103
+ {"nodeId": state.error.node_id, "message": state.error.message}
104
+ if state.error is not None
105
+ else None
106
+ ),
107
+ }
108
+
109
+
110
+ def event_json(event: RunEvent) -> dict[str, Any]:
111
+ if isinstance(event, ProgressEvent):
112
+ out: dict[str, Any] = {
113
+ "type": "progress",
114
+ "runId": event.run_id,
115
+ "nodeId": event.node_id,
116
+ "phase": event.phase.value,
117
+ "fraction": event.fraction,
118
+ "status": event.status,
119
+ }
120
+ if event.step is not None:
121
+ out["step"] = event.step
122
+ if event.step_count is not None:
123
+ out["stepCount"] = event.step_count
124
+ if event.eta_ms is not None:
125
+ out["etaMs"] = event.eta_ms
126
+ return out
127
+ if isinstance(event, NodeDoneEvent):
128
+ return {
129
+ "type": "node_done",
130
+ "runId": event.run_id,
131
+ "nodeId": event.node_id,
132
+ "cached": event.cached,
133
+ "takes": [take_json(t) for t in event.takes],
134
+ }
135
+ if isinstance(event, RunDoneEvent):
136
+ return {"type": "run_done", "runId": event.run_id}
137
+ if isinstance(event, CancelledEvent):
138
+ return {"type": "cancelled", "runId": event.run_id}
139
+ return {
140
+ "type": "error",
141
+ "runId": event.run_id,
142
+ "message": event.message,
143
+ "nodeId": event.node_id,
144
+ }
@@ -0,0 +1,7 @@
1
+ """The Studio app-backend, ported to Python (Part B of the migration).
2
+
3
+ This is the former Electron/TypeScript ``electron/main`` backend — projects, the project SQLite DB,
4
+ frames/takes, the moodboard, settings — reimplemented so Inline Core is the single backend the web
5
+ SPA talks to over ``/rpc`` (see ``inline_core.server.rpc``). Ported domain by domain; until the
6
+ storage layer lands, the ``/rpc`` bridge still proxies un-ported channels to the Node backend.
7
+ """
@@ -0,0 +1,194 @@
1
+ """Asset library + folders, ported from the Studio ``electron/main/assets/{store,folders}.ts``.
2
+
3
+ Physical files live flat under the project's ``assets/`` dir; folders are a logical tree in the DB.
4
+ Operates on an open ``sqlite3.Connection`` plus the project folder (for copying imported files).
5
+
6
+ Poster/thumbnail/transcode generation (ffmpeg) is deferred and best-effort — the UI renders the
7
+ original meanwhile — so import here just copies the file and inserts the row.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import shutil
13
+ import sqlite3
14
+ import time
15
+ import uuid
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ # Extension -> media kind (mirrors the Studio KIND_BY_EXT).
20
+ _KIND_BY_EXT = {
21
+ ".png": "image", ".jpg": "image", ".jpeg": "image", ".webp": "image", ".gif": "image",
22
+ ".bmp": "image", ".tiff": "image", ".avif": "image",
23
+ ".mp4": "video", ".mov": "video", ".webm": "video", ".mkv": "video", ".avi": "video",
24
+ ".mp3": "audio", ".wav": "audio", ".m4a": "audio", ".flac": "audio", ".ogg": "audio",
25
+ ".aac": "audio",
26
+ }
27
+
28
+
29
+ def _now() -> int:
30
+ return int(time.time() * 1000)
31
+
32
+
33
+ def _uuid() -> str:
34
+ return str(uuid.uuid4())
35
+
36
+
37
+ def _project_id(conn: sqlite3.Connection) -> str:
38
+ row = conn.execute("SELECT id FROM project LIMIT 1").fetchone()
39
+ if row is None:
40
+ raise RuntimeError("No project is open.")
41
+ return row["id"]
42
+
43
+
44
+ def kind_for_file(path: str) -> str | None:
45
+ return _KIND_BY_EXT.get(Path(path).suffix.lower())
46
+
47
+
48
+ # --- folders ------------------------------------------------------------------------------------
49
+
50
+
51
+ def _row_to_folder(row: sqlite3.Row) -> dict[str, Any]:
52
+ return {
53
+ "id": row["id"],
54
+ "projectId": row["project_id"],
55
+ "name": row["name"],
56
+ "parentId": row["parent_id"],
57
+ "createdAt": row["created_at"],
58
+ }
59
+
60
+
61
+ def get_folder(conn: sqlite3.Connection, folder_id: str) -> dict[str, Any]:
62
+ row = conn.execute("SELECT * FROM asset_folders WHERE id = ?", (folder_id,)).fetchone()
63
+ if row is None:
64
+ raise ValueError("Folder not found.")
65
+ return _row_to_folder(row)
66
+
67
+
68
+ def list_folders(conn: sqlite3.Connection) -> list[dict[str, Any]]:
69
+ rows = conn.execute("SELECT * FROM asset_folders ORDER BY name COLLATE NOCASE").fetchall()
70
+ return [_row_to_folder(r) for r in rows]
71
+
72
+
73
+ def create_folder(conn: sqlite3.Connection, name: str, parent_id: str | None) -> dict[str, Any]:
74
+ trimmed = name.strip()
75
+ if not trimmed:
76
+ raise ValueError("Folder name is required.")
77
+ if parent_id:
78
+ get_folder(conn, parent_id) # validate parent
79
+ folder = {
80
+ "id": _uuid(),
81
+ "projectId": _project_id(conn),
82
+ "name": trimmed,
83
+ "parentId": parent_id,
84
+ "createdAt": _now(),
85
+ }
86
+ conn.execute(
87
+ "INSERT INTO asset_folders (id, project_id, name, parent_id, created_at) "
88
+ "VALUES (?, ?, ?, ?, ?)",
89
+ (folder["id"], folder["projectId"], trimmed, parent_id, folder["createdAt"]),
90
+ )
91
+ return folder
92
+
93
+
94
+ def rename_folder(conn: sqlite3.Connection, folder_id: str, name: str) -> dict[str, Any]:
95
+ trimmed = name.strip()
96
+ if not trimmed:
97
+ raise ValueError("Folder name is required.")
98
+ get_folder(conn, folder_id)
99
+ conn.execute("UPDATE asset_folders SET name = ? WHERE id = ?", (trimmed, folder_id))
100
+ return get_folder(conn, folder_id)
101
+
102
+
103
+ def delete_folder(conn: sqlite3.Connection, folder_id: str) -> None:
104
+ """Delete a folder; its assets and subfolders reparent to its parent."""
105
+ folder = get_folder(conn, folder_id)
106
+ parent = folder["parentId"]
107
+ conn.execute("UPDATE assets SET folder_id = ? WHERE folder_id = ?", (parent, folder_id))
108
+ conn.execute("UPDATE asset_folders SET parent_id = ? WHERE parent_id = ?", (parent, folder_id))
109
+ conn.execute("DELETE FROM asset_folders WHERE id = ?", (folder_id,))
110
+
111
+
112
+ # --- assets -------------------------------------------------------------------------------------
113
+
114
+
115
+ def _row_to_asset(row: sqlite3.Row) -> dict[str, Any]:
116
+ return {
117
+ "id": row["id"],
118
+ "projectId": row["project_id"],
119
+ "folderId": row["folder_id"],
120
+ "name": row["name"],
121
+ "filePath": row["file_path"],
122
+ "kind": row["kind"],
123
+ "thumbPath": row["thumb_path"],
124
+ "previewPath": row["preview_path"],
125
+ "createdAt": row["created_at"],
126
+ }
127
+
128
+
129
+ def list_assets(conn: sqlite3.Connection) -> list[dict[str, Any]]:
130
+ rows = conn.execute("SELECT * FROM assets ORDER BY created_at DESC, rowid DESC").fetchall()
131
+ return [_row_to_asset(r) for r in rows]
132
+
133
+
134
+ def asset_file(conn: sqlite3.Connection, asset_id: str) -> dict[str, Any] | None:
135
+ row = conn.execute(
136
+ "SELECT file_path, kind, name FROM assets WHERE id = ?", (asset_id,)
137
+ ).fetchone()
138
+ if row is None:
139
+ return None
140
+ return {"filePath": row["file_path"], "kind": row["kind"], "name": row["name"]}
141
+
142
+
143
+ def import_file(
144
+ conn: sqlite3.Connection, folder: Path, abs_path: str, folder_id: str | None
145
+ ) -> dict[str, Any] | None:
146
+ """Copy a file into the project's assets/ dir + insert its row. None for unknown kinds."""
147
+ kind = kind_for_file(abs_path)
148
+ if kind is None:
149
+ return None
150
+ asset_id = _uuid()
151
+ ext = Path(abs_path).suffix.lower()
152
+ relative = f"assets/{asset_id}{ext}"
153
+ (folder / "assets").mkdir(parents=True, exist_ok=True)
154
+ shutil.copyfile(abs_path, folder / relative)
155
+ asset = {
156
+ "id": asset_id,
157
+ "projectId": _project_id(conn),
158
+ "folderId": folder_id,
159
+ "name": Path(abs_path).name,
160
+ "filePath": relative,
161
+ "kind": kind,
162
+ "thumbPath": None,
163
+ "previewPath": None,
164
+ "createdAt": _now(),
165
+ }
166
+ conn.execute(
167
+ "INSERT INTO assets (id, project_id, folder_id, name, file_path, kind, thumb_path, "
168
+ "preview_path, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
169
+ (
170
+ asset["id"], asset["projectId"], folder_id, asset["name"], relative, kind, None, None,
171
+ asset["createdAt"],
172
+ ),
173
+ )
174
+ return asset
175
+
176
+
177
+ def delete_asset(conn: sqlite3.Connection, asset_id: str) -> list[str]:
178
+ """Delete a library asset (blocked if used as a frame input). Returns file paths to unlink."""
179
+ used = conn.execute(
180
+ "SELECT COUNT(*) AS n FROM frame_inputs WHERE asset_id = ?", (asset_id,)
181
+ ).fetchone()["n"]
182
+ if used > 0:
183
+ plural = "" if used == 1 else "s"
184
+ raise ValueError(
185
+ f"This asset is used by {used} frame{plural} — remove it from those frames first."
186
+ )
187
+ row = conn.execute(
188
+ "SELECT file_path, thumb_path, preview_path FROM assets WHERE id = ?", (asset_id,)
189
+ ).fetchone()
190
+ conn.execute("DELETE FROM moodboard_items WHERE asset_id = ?", (asset_id,))
191
+ conn.execute("DELETE FROM assets WHERE id = ?", (asset_id,))
192
+ if row is None:
193
+ return []
194
+ return [p for p in (row["file_path"], row["thumb_path"], row["preview_path"]) if p]
@@ -0,0 +1,29 @@
1
+ """App-global config for the Studio backend: where recents/settings live and where new projects are
2
+ created (the browser has no folder picker). Env-driven, mirroring the legacy Node web server.
3
+
4
+ ``INLINE_STUDIO_DATA_DIR`` — app data (recents, settings). Default ``~/.inline-studio-server``
5
+ ``INLINE_STUDIO_WORKSPACE_DIR`` — where new projects are created. Default ``~/InlineStudioProjects``
6
+
7
+ The old ``STORYLINE_*`` names still work as deprecated aliases (see CLAUDE.md).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from pathlib import Path
14
+
15
+ DEFAULT_COMFY_URL = os.environ.get("COMFYUI_URL") or "http://127.0.0.1:8188"
16
+ DEFAULT_CORE_URL = os.environ.get("INLINE_CORE_URL") or "http://127.0.0.1:8848"
17
+
18
+
19
+ def _env(name: str) -> str | None:
20
+ """Read INLINE_STUDIO_<name>, falling back to the deprecated STORYLINE_<name> alias."""
21
+ return os.environ.get(f"INLINE_STUDIO_{name}") or os.environ.get(f"STORYLINE_{name}")
22
+
23
+
24
+ def data_dir() -> Path:
25
+ return Path(_env("DATA_DIR") or (Path.home() / ".inline-studio-server"))
26
+
27
+
28
+ def workspace_dir() -> Path:
29
+ return Path(_env("WORKSPACE_DIR") or (Path.home() / "InlineStudioProjects"))