dvr 0.1.0__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.
- dvr/__init__.py +55 -0
- dvr/__main__.py +8 -0
- dvr/_version.py +24 -0
- dvr/_wrap.py +68 -0
- dvr/audio.py +139 -0
- dvr/cli/__init__.py +6 -0
- dvr/cli/commands/__init__.py +1 -0
- dvr/cli/commands/apply.py +77 -0
- dvr/cli/commands/mcp.py +26 -0
- dvr/cli/commands/media.py +148 -0
- dvr/cli/commands/project.py +126 -0
- dvr/cli/commands/render.py +159 -0
- dvr/cli/commands/serve.py +88 -0
- dvr/cli/commands/timeline.py +104 -0
- dvr/cli/main.py +168 -0
- dvr/cli/output.py +142 -0
- dvr/color.py +331 -0
- dvr/connection.py +378 -0
- dvr/daemon.py +367 -0
- dvr/errors.py +140 -0
- dvr/gallery.py +150 -0
- dvr/interchange.py +147 -0
- dvr/mcp/__init__.py +20 -0
- dvr/mcp/server.py +397 -0
- dvr/media.py +603 -0
- dvr/project.py +286 -0
- dvr/py.typed +0 -0
- dvr/render.py +331 -0
- dvr/resolve.py +180 -0
- dvr/spec.py +323 -0
- dvr/timeline.py +762 -0
- dvr-0.1.0.dist-info/METADATA +174 -0
- dvr-0.1.0.dist-info/RECORD +36 -0
- dvr-0.1.0.dist-info/WHEEL +4 -0
- dvr-0.1.0.dist-info/entry_points.txt +2 -0
- dvr-0.1.0.dist-info/licenses/LICENSE +21 -0
dvr/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""dvr — the missing CLI and Python library for DaVinci Resolve.
|
|
2
|
+
|
|
3
|
+
This package exposes a small, stable public API. Internal modules are
|
|
4
|
+
prefixed with ``_`` and may change between releases. The two things you
|
|
5
|
+
almost always want are:
|
|
6
|
+
|
|
7
|
+
from dvr import Resolve, errors
|
|
8
|
+
|
|
9
|
+
Open a connection with ``r = Resolve()`` and navigate from there.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from . import audio, errors, gallery, interchange, spec
|
|
15
|
+
from .color import ColorGroup, ColorOps, NodeGraph
|
|
16
|
+
from .media import Asset, Bin, MediaPool, MediaPoolItem, MediaStorage
|
|
17
|
+
from .project import Project, ProjectNamespace
|
|
18
|
+
from .render import RenderJob, RenderNamespace
|
|
19
|
+
from .resolve import App, Resolve
|
|
20
|
+
from .timeline import Clip, ClipFusion, ClipQuery, Takes, Timeline, TimelineNamespace, Track
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from ._version import __version__
|
|
24
|
+
except ImportError: # pragma: no cover - generated at build time
|
|
25
|
+
__version__ = "0.0.0+local"
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"App",
|
|
29
|
+
"Asset",
|
|
30
|
+
"Bin",
|
|
31
|
+
"Clip",
|
|
32
|
+
"ClipFusion",
|
|
33
|
+
"ClipQuery",
|
|
34
|
+
"ColorGroup",
|
|
35
|
+
"ColorOps",
|
|
36
|
+
"MediaPool",
|
|
37
|
+
"MediaPoolItem",
|
|
38
|
+
"MediaStorage",
|
|
39
|
+
"NodeGraph",
|
|
40
|
+
"Project",
|
|
41
|
+
"ProjectNamespace",
|
|
42
|
+
"RenderJob",
|
|
43
|
+
"RenderNamespace",
|
|
44
|
+
"Resolve",
|
|
45
|
+
"Takes",
|
|
46
|
+
"Timeline",
|
|
47
|
+
"TimelineNamespace",
|
|
48
|
+
"Track",
|
|
49
|
+
"__version__",
|
|
50
|
+
"audio",
|
|
51
|
+
"errors",
|
|
52
|
+
"gallery",
|
|
53
|
+
"interchange",
|
|
54
|
+
"spec",
|
|
55
|
+
]
|
dvr/__main__.py
ADDED
dvr/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
dvr/_wrap.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Internal helpers shared by every domain wrapper.
|
|
2
|
+
|
|
3
|
+
The Resolve API is famously inconsistent: methods can return ``None`` for
|
|
4
|
+
"not found", "wrong page", "no current project", or genuine errors — with
|
|
5
|
+
no way to distinguish. The helpers here let domain wrappers collapse those
|
|
6
|
+
cases into a structured ``DvrError`` with a useful diagnosis.
|
|
7
|
+
|
|
8
|
+
Nothing here is part of the public API.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from typing import Any, TypeVar
|
|
15
|
+
|
|
16
|
+
from . import errors
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def require(
|
|
22
|
+
value: T | None,
|
|
23
|
+
*,
|
|
24
|
+
error: type[errors.DvrError] = errors.DvrError,
|
|
25
|
+
message: str,
|
|
26
|
+
cause: str | None = None,
|
|
27
|
+
fix: str | None = None,
|
|
28
|
+
state: dict[str, Any] | None = None,
|
|
29
|
+
) -> T:
|
|
30
|
+
"""Assert ``value is not None`` or raise a structured error.
|
|
31
|
+
|
|
32
|
+
Use this around any raw API call that can return ``None`` to signal
|
|
33
|
+
failure. ``cause``/``fix``/``state`` flow into the resulting exception.
|
|
34
|
+
"""
|
|
35
|
+
if value is None:
|
|
36
|
+
raise error(message, cause=cause, fix=fix, state=state)
|
|
37
|
+
return value
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def safe_call(
|
|
41
|
+
fn: Callable[[], T],
|
|
42
|
+
*,
|
|
43
|
+
error: type[errors.DvrError] = errors.DvrError,
|
|
44
|
+
message: str,
|
|
45
|
+
cause: str | None = None,
|
|
46
|
+
fix: str | None = None,
|
|
47
|
+
state: dict[str, Any] | None = None,
|
|
48
|
+
) -> T:
|
|
49
|
+
"""Run a raw API call and translate exceptions into a ``DvrError``.
|
|
50
|
+
|
|
51
|
+
The Resolve API occasionally raises bare ``RuntimeError`` from C++.
|
|
52
|
+
Catch broadly and re-surface with diagnostic context.
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
result = fn()
|
|
56
|
+
except errors.DvrError:
|
|
57
|
+
raise
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
raise error(
|
|
60
|
+
message,
|
|
61
|
+
cause=cause or f"underlying API raised {type(exc).__name__}: {exc}",
|
|
62
|
+
fix=fix,
|
|
63
|
+
state=state,
|
|
64
|
+
) from exc
|
|
65
|
+
return require(result, error=error, message=message, cause=cause, fix=fix, state=state)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__all__ = ["require", "safe_call"]
|
dvr/audio.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Audio operations: channel mapping, voice isolation, Fairlight presets.
|
|
2
|
+
|
|
3
|
+
The Fairlight scripting surface is small. Resolve does not expose EQ /
|
|
4
|
+
compression / routing programmatically. What this module covers:
|
|
5
|
+
|
|
6
|
+
* Reading audio channel mapping (which embedded/linked tracks feed which
|
|
7
|
+
timeline audio channel) for clips and assets.
|
|
8
|
+
* Voice isolation (Fairlight feature) on timelines.
|
|
9
|
+
* Inserting audio at the playhead.
|
|
10
|
+
* Applying named Fairlight presets to the current timeline.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from typing import TYPE_CHECKING, Any, List # noqa: UP035
|
|
17
|
+
|
|
18
|
+
from . import errors
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from .project import Project
|
|
22
|
+
from .timeline import Clip, Timeline
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Per-clip / per-asset audio mapping
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_clip_audio_mapping(clip: Clip) -> dict[str, Any]:
|
|
31
|
+
"""Return the JSON-decoded audio mapping for a timeline clip."""
|
|
32
|
+
raw = clip.raw.GetSourceAudioChannelMapping() or "{}"
|
|
33
|
+
try:
|
|
34
|
+
return dict(json.loads(raw))
|
|
35
|
+
except (TypeError, ValueError):
|
|
36
|
+
return {}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_asset_audio_mapping(asset: Any) -> dict[str, Any]:
|
|
40
|
+
"""Return the JSON-decoded audio mapping for a media-pool asset."""
|
|
41
|
+
raw = asset.raw.GetAudioMapping() if hasattr(asset, "raw") else asset.GetAudioMapping()
|
|
42
|
+
try:
|
|
43
|
+
return dict(json.loads(raw or "{}"))
|
|
44
|
+
except (TypeError, ValueError):
|
|
45
|
+
return {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Voice isolation (timeline-level Fairlight feature)
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def voice_isolation_state(timeline: Timeline) -> dict[str, Any]:
|
|
54
|
+
"""Return ``{"enabled": bool, "amount": int}`` for the timeline."""
|
|
55
|
+
state = timeline.raw.GetVoiceIsolationState() or {}
|
|
56
|
+
return {
|
|
57
|
+
"enabled": bool(state.get("Enabled", False)),
|
|
58
|
+
"amount": int(state.get("Amount", 0)),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def set_voice_isolation(
|
|
63
|
+
timeline: Timeline,
|
|
64
|
+
*,
|
|
65
|
+
enabled: bool,
|
|
66
|
+
amount: int = 50,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Toggle voice isolation; ``amount`` is 0-100."""
|
|
69
|
+
if not 0 <= amount <= 100:
|
|
70
|
+
raise errors.DvrError(
|
|
71
|
+
f"Voice isolation amount must be 0-100, got {amount!r}.",
|
|
72
|
+
state={"amount": amount},
|
|
73
|
+
)
|
|
74
|
+
if not timeline.raw.SetVoiceIsolationState({"Enabled": enabled, "Amount": amount}):
|
|
75
|
+
raise errors.DvrError(
|
|
76
|
+
"Could not set voice isolation state.",
|
|
77
|
+
cause="SetVoiceIsolationState returned False.",
|
|
78
|
+
state={"timeline": timeline.name, "enabled": enabled, "amount": amount},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Project-level Fairlight presets
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def fairlight_presets(project: Project) -> List[str]: # noqa: UP006
|
|
88
|
+
return [str(n) for n in (project.raw.GetFairlightPresets() or [])]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def apply_fairlight_preset(project: Project, name: str) -> None:
|
|
92
|
+
"""Apply a named Fairlight preset to the current timeline."""
|
|
93
|
+
if not project.raw.ApplyFairlightPresetToCurrentTimeline(name):
|
|
94
|
+
raise errors.DvrError(
|
|
95
|
+
f"Could not apply Fairlight preset {name!r}.",
|
|
96
|
+
cause="ApplyFairlightPresetToCurrentTimeline returned False.",
|
|
97
|
+
fix=f"Available presets: {fairlight_presets(project)}",
|
|
98
|
+
state={"requested": name},
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------------------
|
|
103
|
+
# Insert audio at playhead (Fairlight page)
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def insert_audio_at_playhead(
|
|
108
|
+
project: Project,
|
|
109
|
+
*,
|
|
110
|
+
file_path: str,
|
|
111
|
+
offset_samples: int = 0,
|
|
112
|
+
duration_samples: int | None = None,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Insert audio at the current track's playhead on the Fairlight page."""
|
|
115
|
+
args = [file_path, offset_samples]
|
|
116
|
+
if duration_samples is not None:
|
|
117
|
+
args.append(duration_samples)
|
|
118
|
+
if not project.raw.InsertAudioToCurrentTrackAtPlayhead(*args):
|
|
119
|
+
raise errors.DvrError(
|
|
120
|
+
f"Could not insert audio {file_path!r}.",
|
|
121
|
+
cause="InsertAudioToCurrentTrackAtPlayhead returned False.",
|
|
122
|
+
fix="Switch to the Fairlight page and select an audio track first.",
|
|
123
|
+
state={
|
|
124
|
+
"file_path": file_path,
|
|
125
|
+
"offset_samples": offset_samples,
|
|
126
|
+
"duration_samples": duration_samples,
|
|
127
|
+
},
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
__all__ = [
|
|
132
|
+
"apply_fairlight_preset",
|
|
133
|
+
"fairlight_presets",
|
|
134
|
+
"get_asset_audio_mapping",
|
|
135
|
+
"get_clip_audio_mapping",
|
|
136
|
+
"insert_audio_at_playhead",
|
|
137
|
+
"set_voice_isolation",
|
|
138
|
+
"voice_isolation_state",
|
|
139
|
+
]
|
dvr/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI sub-apps, one per top-level domain."""
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""``dvr apply`` and ``dvr plan`` — declarative reconciliation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ... import spec as spec_mod
|
|
10
|
+
from ...resolve import Resolve
|
|
11
|
+
from .. import output
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _resolve(ctx: typer.Context) -> Resolve:
|
|
15
|
+
cfg = ctx.obj or {}
|
|
16
|
+
return Resolve(auto_launch=cfg.get("auto_launch", True), timeout=cfg.get("timeout", 30.0))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _action_rows(actions: list[spec_mod.Action]) -> list[dict[str, str]]:
|
|
20
|
+
return [{"op": a.op, "target": a.target, "detail": a.detail} for a in actions]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def register(app: typer.Typer) -> None:
|
|
24
|
+
"""Register ``apply`` and ``plan`` as top-level commands."""
|
|
25
|
+
|
|
26
|
+
@app.command("plan")
|
|
27
|
+
def plan_cmd(
|
|
28
|
+
ctx: typer.Context,
|
|
29
|
+
spec_file: Annotated[str, typer.Argument(help="Path to a YAML or JSON spec.")],
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Show the actions `dvr apply` would take, without executing."""
|
|
32
|
+
cfg = ctx.obj or {}
|
|
33
|
+
resolve = _resolve(ctx)
|
|
34
|
+
spec = spec_mod.load_spec(spec_file)
|
|
35
|
+
actions = spec_mod.plan(spec, resolve)
|
|
36
|
+
output.emit(
|
|
37
|
+
_action_rows(actions),
|
|
38
|
+
fmt=cfg.get("format"),
|
|
39
|
+
headline=f"plan: {spec.project}",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
@app.command("apply")
|
|
43
|
+
def apply_cmd(
|
|
44
|
+
ctx: typer.Context,
|
|
45
|
+
spec_file: Annotated[str, typer.Argument(help="Path to a YAML or JSON spec.")],
|
|
46
|
+
dry_run: Annotated[
|
|
47
|
+
bool,
|
|
48
|
+
typer.Option("--dry-run", "-n", help="Print the plan without applying."),
|
|
49
|
+
] = False,
|
|
50
|
+
yes: Annotated[
|
|
51
|
+
bool,
|
|
52
|
+
typer.Option("--yes", "-y", help="Skip the confirmation prompt."),
|
|
53
|
+
] = False,
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Reconcile a spec against the live DaVinci Resolve state."""
|
|
56
|
+
cfg = ctx.obj or {}
|
|
57
|
+
resolve = _resolve(ctx)
|
|
58
|
+
spec = spec_mod.load_spec(spec_file)
|
|
59
|
+
|
|
60
|
+
actions = spec_mod.plan(spec, resolve)
|
|
61
|
+
output.emit(
|
|
62
|
+
_action_rows(actions),
|
|
63
|
+
fmt=cfg.get("format"),
|
|
64
|
+
headline=f"plan: {spec.project}",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if dry_run:
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
if not yes:
|
|
71
|
+
typer.confirm(f"Apply {len(actions)} action(s) to {spec.project!r}?", abort=True)
|
|
72
|
+
|
|
73
|
+
applied = spec_mod.apply(spec, resolve, dry_run=False)
|
|
74
|
+
output.emit(
|
|
75
|
+
{"applied": len(applied), "project": spec.project},
|
|
76
|
+
fmt=cfg.get("format"),
|
|
77
|
+
)
|
dvr/cli/commands/mcp.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""``dvr mcp`` sub-commands — MCP server for LLM agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(name="mcp", help="MCP server: expose dvr to LLM agents.")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.command("serve")
|
|
11
|
+
def serve(ctx: typer.Context) -> None:
|
|
12
|
+
"""Run the MCP server on stdio (the default MCP transport)."""
|
|
13
|
+
cfg = ctx.obj or {}
|
|
14
|
+
try:
|
|
15
|
+
from ...mcp import run_stdio
|
|
16
|
+
except ImportError as exc:
|
|
17
|
+
typer.echo(
|
|
18
|
+
'MCP support requires the optional extra: pip install "dvr[mcp]"',
|
|
19
|
+
err=True,
|
|
20
|
+
)
|
|
21
|
+
raise typer.Exit(1) from exc
|
|
22
|
+
|
|
23
|
+
run_stdio(
|
|
24
|
+
auto_launch=cfg.get("auto_launch", True),
|
|
25
|
+
timeout=cfg.get("timeout", 30.0),
|
|
26
|
+
)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""``dvr media`` sub-commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ...resolve import Resolve
|
|
10
|
+
from .. import output
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(name="media", help="Media pool: bins, assets, import, relink, proxy, audio sync.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _resolve(ctx: typer.Context) -> Resolve:
|
|
16
|
+
cfg = ctx.obj or {}
|
|
17
|
+
return Resolve(auto_launch=cfg.get("auto_launch", True), timeout=cfg.get("timeout", 30.0))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command("inspect")
|
|
21
|
+
def inspect_pool(ctx: typer.Context) -> None:
|
|
22
|
+
"""Inspect the current project's media pool."""
|
|
23
|
+
r = _resolve(ctx)
|
|
24
|
+
project = r.project.current
|
|
25
|
+
if project is None:
|
|
26
|
+
typer.echo("No project is currently loaded.", err=True)
|
|
27
|
+
raise typer.Exit(1)
|
|
28
|
+
output.emit(project.media.inspect(), fmt=ctx.obj["format"], headline="media pool")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@app.command("bins")
|
|
32
|
+
def bins(ctx: typer.Context) -> None:
|
|
33
|
+
"""List bins in the current project's media pool."""
|
|
34
|
+
r = _resolve(ctx)
|
|
35
|
+
project = r.project.current
|
|
36
|
+
if project is None:
|
|
37
|
+
typer.echo("No project is currently loaded.", err=True)
|
|
38
|
+
raise typer.Exit(1)
|
|
39
|
+
rows = [b.inspect() for b in project.media.root().subbins()]
|
|
40
|
+
output.emit(rows, fmt=ctx.obj["format"], headline="bins")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@app.command("ls")
|
|
44
|
+
def ls_bin(
|
|
45
|
+
ctx: typer.Context,
|
|
46
|
+
bin: Annotated[
|
|
47
|
+
str | None,
|
|
48
|
+
typer.Argument(help="Bin name to list. Defaults to the root bin."),
|
|
49
|
+
] = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""List assets in a bin."""
|
|
52
|
+
r = _resolve(ctx)
|
|
53
|
+
project = r.project.current
|
|
54
|
+
if project is None:
|
|
55
|
+
typer.echo("No project is currently loaded.", err=True)
|
|
56
|
+
raise typer.Exit(1)
|
|
57
|
+
target = project.media._find_bin(bin) if bin else project.media.root()
|
|
58
|
+
rows = [a.inspect() for a in target.assets()]
|
|
59
|
+
output.emit(rows, fmt=ctx.obj["format"], headline=f"assets in {target.name}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command("mkbin")
|
|
63
|
+
def mkbin(
|
|
64
|
+
ctx: typer.Context,
|
|
65
|
+
name: Annotated[str, typer.Argument(help="Bin name.")],
|
|
66
|
+
parent: Annotated[
|
|
67
|
+
str | None,
|
|
68
|
+
typer.Option("--parent", help="Parent bin (default: root)."),
|
|
69
|
+
] = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Create (or get-or-create) a bin."""
|
|
72
|
+
r = _resolve(ctx)
|
|
73
|
+
project = r.project.current
|
|
74
|
+
if project is None:
|
|
75
|
+
typer.echo("No project is currently loaded.", err=True)
|
|
76
|
+
raise typer.Exit(1)
|
|
77
|
+
parent_bin = project.media._find_bin(parent) if parent else project.media.root()
|
|
78
|
+
bin_obj = project.media.ensure_bin(name, parent=parent_bin)
|
|
79
|
+
output.emit(bin_obj.inspect(), fmt=ctx.obj["format"], headline=f"bin: {bin_obj.name}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command("import")
|
|
83
|
+
def import_files(
|
|
84
|
+
ctx: typer.Context,
|
|
85
|
+
paths: Annotated[list[str], typer.Argument(help="One or more file paths to import.")],
|
|
86
|
+
bin: Annotated[
|
|
87
|
+
str | None,
|
|
88
|
+
typer.Option("--bin", "-b", help="Target bin (default: current)."),
|
|
89
|
+
] = None,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Import media files into the pool."""
|
|
92
|
+
r = _resolve(ctx)
|
|
93
|
+
project = r.project.current
|
|
94
|
+
if project is None:
|
|
95
|
+
typer.echo("No project is currently loaded.", err=True)
|
|
96
|
+
raise typer.Exit(1)
|
|
97
|
+
target_bin = project.media._find_bin(bin) if bin else None
|
|
98
|
+
assets = project.media.import_(paths, bin=target_bin)
|
|
99
|
+
rows = [a.inspect() for a in assets]
|
|
100
|
+
output.emit(rows, fmt=ctx.obj["format"], headline=f"imported {len(rows)}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.command("relink")
|
|
104
|
+
def relink(
|
|
105
|
+
ctx: typer.Context,
|
|
106
|
+
folder: Annotated[
|
|
107
|
+
str,
|
|
108
|
+
typer.Argument(help="Folder containing replacement media."),
|
|
109
|
+
],
|
|
110
|
+
bin: Annotated[
|
|
111
|
+
str | None,
|
|
112
|
+
typer.Option("--bin", "-b", help="Bin whose assets to relink (default: root)."),
|
|
113
|
+
] = None,
|
|
114
|
+
) -> None:
|
|
115
|
+
"""Relink assets in a bin to new files in a folder."""
|
|
116
|
+
r = _resolve(ctx)
|
|
117
|
+
project = r.project.current
|
|
118
|
+
if project is None:
|
|
119
|
+
typer.echo("No project is currently loaded.", err=True)
|
|
120
|
+
raise typer.Exit(1)
|
|
121
|
+
target = project.media._find_bin(bin) if bin else project.media.root()
|
|
122
|
+
project.media.relink(target.assets(), folder)
|
|
123
|
+
output.emit(
|
|
124
|
+
{"relinked": len(target.assets()), "folder": folder, "bin": target.name},
|
|
125
|
+
fmt=ctx.obj["format"],
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command("storage")
|
|
130
|
+
def storage(
|
|
131
|
+
ctx: typer.Context,
|
|
132
|
+
path: Annotated[
|
|
133
|
+
str | None,
|
|
134
|
+
typer.Argument(help="Path to list (default: mounted volumes)."),
|
|
135
|
+
] = None,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""List mounted volumes (no path) or files/folders under a path."""
|
|
138
|
+
r = _resolve(ctx)
|
|
139
|
+
if path is None:
|
|
140
|
+
output.emit(
|
|
141
|
+
[{"volume": v} for v in r.storage.volumes()],
|
|
142
|
+
fmt=ctx.obj["format"],
|
|
143
|
+
headline="volumes",
|
|
144
|
+
)
|
|
145
|
+
return
|
|
146
|
+
rows = [{"name": p, "kind": "folder"} for p in r.storage.subfolders(path)]
|
|
147
|
+
rows.extend({"name": f, "kind": "file"} for f in r.storage.files(path))
|
|
148
|
+
output.emit(rows, fmt=ctx.obj["format"], headline=path)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""``dvr project`` sub-commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ...resolve import Resolve
|
|
10
|
+
from .. import output
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
name="project", help="Project operations: list, ensure, load, create, delete, archive."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _resolve(ctx: typer.Context) -> Resolve:
|
|
18
|
+
cfg = ctx.obj or {}
|
|
19
|
+
return Resolve(auto_launch=cfg.get("auto_launch", True), timeout=cfg.get("timeout", 30.0))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@app.command("list")
|
|
23
|
+
def list_projects(ctx: typer.Context) -> None:
|
|
24
|
+
"""List projects in the current PM folder."""
|
|
25
|
+
r = _resolve(ctx)
|
|
26
|
+
rows = [{"name": n} for n in r.project.list()]
|
|
27
|
+
output.emit(rows, fmt=ctx.obj["format"], headline="projects")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command("current")
|
|
31
|
+
def current(ctx: typer.Context) -> None:
|
|
32
|
+
"""Inspect the currently loaded project."""
|
|
33
|
+
r = _resolve(ctx)
|
|
34
|
+
proj = r.project.current
|
|
35
|
+
if proj is None:
|
|
36
|
+
output.emit({"current": None}, fmt=ctx.obj["format"])
|
|
37
|
+
return
|
|
38
|
+
output.emit(proj.inspect(), fmt=ctx.obj["format"], headline=proj.name)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app.command("ensure")
|
|
42
|
+
def ensure(
|
|
43
|
+
ctx: typer.Context,
|
|
44
|
+
name: Annotated[str, typer.Argument(help="Project name to load-or-create.")],
|
|
45
|
+
) -> None:
|
|
46
|
+
"""Load the project if it exists, otherwise create it."""
|
|
47
|
+
r = _resolve(ctx)
|
|
48
|
+
proj = r.project.ensure(name)
|
|
49
|
+
output.emit(proj.inspect(), fmt=ctx.obj["format"], headline=f"ensured: {proj.name}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@app.command("create")
|
|
53
|
+
def create(
|
|
54
|
+
ctx: typer.Context,
|
|
55
|
+
name: Annotated[str, typer.Argument(help="Project name (must be unique).")],
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Create a new project."""
|
|
58
|
+
r = _resolve(ctx)
|
|
59
|
+
proj = r.project.create(name)
|
|
60
|
+
output.emit(proj.inspect(), fmt=ctx.obj["format"], headline=f"created: {proj.name}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.command("load")
|
|
64
|
+
def load(
|
|
65
|
+
ctx: typer.Context,
|
|
66
|
+
name: Annotated[str, typer.Argument(help="Project name to load.")],
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Load an existing project."""
|
|
69
|
+
r = _resolve(ctx)
|
|
70
|
+
proj = r.project.load(name)
|
|
71
|
+
output.emit(proj.inspect(), fmt=ctx.obj["format"], headline=f"loaded: {proj.name}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command("delete")
|
|
75
|
+
def delete(
|
|
76
|
+
ctx: typer.Context,
|
|
77
|
+
name: Annotated[str, typer.Argument(help="Project name to delete.")],
|
|
78
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Skip the confirmation prompt.")] = False,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Delete a project (must be closed first)."""
|
|
81
|
+
if not yes:
|
|
82
|
+
typer.confirm(f"Really delete project {name!r}?", abort=True)
|
|
83
|
+
r = _resolve(ctx)
|
|
84
|
+
r.project.delete(name)
|
|
85
|
+
output.emit({"deleted": name}, fmt=ctx.obj["format"])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command("save")
|
|
89
|
+
def save(ctx: typer.Context) -> None:
|
|
90
|
+
"""Save the currently loaded project."""
|
|
91
|
+
r = _resolve(ctx)
|
|
92
|
+
proj = r.project.current
|
|
93
|
+
if proj is None:
|
|
94
|
+
typer.echo("No project is currently loaded.", err=True)
|
|
95
|
+
raise typer.Exit(1)
|
|
96
|
+
proj.save()
|
|
97
|
+
output.emit({"saved": proj.name}, fmt=ctx.obj["format"])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@app.command("export")
|
|
101
|
+
def export(
|
|
102
|
+
ctx: typer.Context,
|
|
103
|
+
name: Annotated[str, typer.Argument(help="Project name to export.")],
|
|
104
|
+
file: Annotated[str, typer.Argument(help="Output .drp path.")],
|
|
105
|
+
no_assets: Annotated[
|
|
106
|
+
bool, typer.Option("--no-assets", help="Exclude stills and LUTs.")
|
|
107
|
+
] = False,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Export a project to a .drp file."""
|
|
110
|
+
r = _resolve(ctx)
|
|
111
|
+
r.project.export(name, file, with_stills_and_luts=not no_assets)
|
|
112
|
+
output.emit({"exported": name, "path": file}, fmt=ctx.obj["format"])
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@app.command("import")
|
|
116
|
+
def import_(
|
|
117
|
+
ctx: typer.Context,
|
|
118
|
+
file: Annotated[str, typer.Argument(help="Input .drp path.")],
|
|
119
|
+
name: Annotated[
|
|
120
|
+
str | None, typer.Option("--name", help="Override the imported project name.")
|
|
121
|
+
] = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
"""Import a project from a .drp file."""
|
|
124
|
+
r = _resolve(ctx)
|
|
125
|
+
proj = r.project.import_(file, name=name)
|
|
126
|
+
output.emit(proj.inspect(), fmt=ctx.obj["format"], headline=f"imported: {proj.name}")
|