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,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"))
|