davinci-cli 1.0.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.
@@ -0,0 +1,3 @@
1
+ """davinci-cli — DaVinci Resolve CLI & MCP server, agent-first design."""
2
+
3
+ __version__ = "1.0.1"
davinci_cli/cli.py ADDED
@@ -0,0 +1,109 @@
1
+ """CLI エントリポイント。
2
+
3
+ エントリポイント名は 'dr' に統一。'cli' は使わない。
4
+ グローバルエラーハンドリングは DavinciCLIGroup.invoke() オーバーライドで実装。
5
+ 各コマンドに手動でデコレータを付ける必要はない。
6
+
7
+ exit_code は core/exceptions.py の定義を参照する:
8
+ 1: ResolveNotRunningError
9
+ 2: ProjectNotOpenError / ProjectNotFoundError
10
+ 3: ValidationError
11
+ 4: DavinciEnvironmentError
12
+ 5: EditionError
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+
19
+ import click
20
+
21
+ from davinci_cli.core.exceptions import DavinciCLIError
22
+ from davinci_cli.core.logging import setup_logging
23
+
24
+
25
+ class DavinciCLIGroup(click.Group):
26
+ """カスタム Click Group — invoke オーバーライドでグローバルエラーハンドリング。"""
27
+
28
+ def invoke(self, ctx: click.Context) -> None:
29
+ try:
30
+ super().invoke(ctx)
31
+ except DavinciCLIError as exc:
32
+ error_response = {
33
+ "error": str(exc),
34
+ "error_type": type(exc).__name__,
35
+ "exit_code": exc.exit_code,
36
+ }
37
+ click.echo(json.dumps(error_response, ensure_ascii=False))
38
+ ctx.exit(exc.exit_code)
39
+ except click.exceptions.ClickException:
40
+ raise # Click の UsageError/BadParameter 等はそのまま伝播
41
+ except Exception as exc:
42
+ error_response = {
43
+ "error": str(exc),
44
+ "error_type": type(exc).__name__,
45
+ "exit_code": 1,
46
+ }
47
+ click.echo(json.dumps(error_response, ensure_ascii=False))
48
+ ctx.exit(1)
49
+
50
+
51
+ @click.group(cls=DavinciCLIGroup)
52
+ @click.version_option()
53
+ @click.option(
54
+ "--pretty",
55
+ is_flag=True,
56
+ default=False,
57
+ help="Human-readable output (TTY only)",
58
+ )
59
+ @click.option(
60
+ "--verbose",
61
+ "-v",
62
+ is_flag=True,
63
+ default=False,
64
+ help="Enable verbose logging (INFO level)",
65
+ )
66
+ @click.option(
67
+ "--debug",
68
+ is_flag=True,
69
+ default=False,
70
+ help="Enable debug logging (DEBUG level)",
71
+ )
72
+ @click.pass_context
73
+ def dr(ctx: click.Context, pretty: bool, verbose: bool, debug: bool) -> None:
74
+ """DaVinci Resolve CLI — agent-first interface."""
75
+ ctx.ensure_object(dict)
76
+ ctx.obj["pretty"] = pretty
77
+ ctx.obj["verbose"] = verbose
78
+ ctx.obj["debug"] = debug
79
+
80
+ setup_logging(verbose=verbose, debug=debug)
81
+
82
+
83
+ def _register_commands() -> None:
84
+ from davinci_cli.commands import (
85
+ clip,
86
+ color,
87
+ deliver,
88
+ gallery,
89
+ mcp_cmd,
90
+ media,
91
+ project,
92
+ schema,
93
+ system,
94
+ timeline,
95
+ )
96
+
97
+ dr.add_command(system.system)
98
+ dr.add_command(schema.schema)
99
+ dr.add_command(project.project)
100
+ dr.add_command(timeline.timeline)
101
+ dr.add_command(clip.clip)
102
+ dr.add_command(color.color)
103
+ dr.add_command(media.media)
104
+ dr.add_command(deliver.deliver)
105
+ dr.add_command(gallery.gallery)
106
+ dr.add_command(mcp_cmd.mcp)
107
+
108
+
109
+ _register_commands()
File without changes
@@ -0,0 +1,239 @@
1
+ """dr timeline marker beats — BPMベースのマーカー自動配置。
2
+
3
+ BPM と音価を指定して、指定クリップの範囲に等間隔マーカーを配置する。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ import click
11
+ from pydantic import BaseModel
12
+
13
+ from davinci_cli.core.connection import get_resolve
14
+ from davinci_cli.core.exceptions import ProjectNotOpenError, ValidationError
15
+ from davinci_cli.decorators import dry_run_option, json_input_option
16
+ from davinci_cli.output.formatter import output
17
+ from davinci_cli.schema_registry import register_schema
18
+
19
+ # 音価 → 1拍あたりの倍率マッピング
20
+ NOTE_VALUE_MAP: dict[str, float] = {
21
+ "1/1": 4.0, # 全音符 = 4拍
22
+ "1/2": 2.0, # 2分音符 = 2拍
23
+ "1/4": 1.0, # 4分音符 = 1拍
24
+ "1/8": 0.5, # 8分音符 = 0.5拍
25
+ "1/16": 0.25, # 16分音符 = 0.25拍
26
+ }
27
+
28
+
29
+ def _calculate_beat_frames(
30
+ bpm: float,
31
+ note_value: str,
32
+ fps: float,
33
+ start_frame: int,
34
+ end_frame: int,
35
+ ) -> list[int]:
36
+ """BPM・音価・FPSからマーカーを打つべきフレーム一覧を計算する。
37
+
38
+ 誤差蓄積防止: 各フレームは start_frame + round(i * interval) で計算。
39
+ 累積加算(frame += interval)は使わない。
40
+ """
41
+ beats_per_note = NOTE_VALUE_MAP[note_value]
42
+ # 1音価あたりの秒数
43
+ seconds_per_beat = (60.0 / bpm) * beats_per_note
44
+ # 1音価あたりのフレーム数(浮動小数点)
45
+ frames_per_beat = seconds_per_beat * fps
46
+
47
+ frames: list[int] = []
48
+ i = 0
49
+ while True:
50
+ frame = start_frame + round(i * frames_per_beat)
51
+ if frame > end_frame:
52
+ break
53
+ frames.append(frame)
54
+ i += 1
55
+ return frames
56
+
57
+
58
+ # --- Pydantic Models ---
59
+
60
+
61
+ class BeatMarkerInput(BaseModel):
62
+ bpm: float
63
+ clip_index: int
64
+ note_value: str = "1/4"
65
+ color: str = "Blue"
66
+ name: str = ""
67
+ duration: int = 1
68
+
69
+
70
+ class BeatMarkerOutput(BaseModel):
71
+ added_count: int | None = None
72
+ requested_count: int | None = None
73
+ failed_frames: list[int] | None = None
74
+ bpm: float | None = None
75
+ note_value: str | None = None
76
+ color: str | None = None
77
+ clip_name: str | None = None
78
+ frames: list[int] | None = None
79
+ dry_run: bool | None = None
80
+ action: str | None = None
81
+ count: int | None = None
82
+
83
+
84
+ # --- Helper ---
85
+
86
+
87
+ def _get_current_project() -> Any:
88
+ resolve = get_resolve()
89
+ pm = resolve.GetProjectManager()
90
+ project = pm.GetCurrentProject()
91
+ if project is None:
92
+ raise ProjectNotOpenError()
93
+ return project
94
+
95
+
96
+ def _collect_clips(tl: Any) -> list[tuple[dict[str, Any], Any]]:
97
+ """タイムラインから全クリップを収集する。"""
98
+ clips: list[tuple[dict[str, Any], Any]] = []
99
+ for track_type in ["video", "audio"]:
100
+ track_count = tl.GetTrackCount(track_type)
101
+ for track_idx in range(1, track_count + 1):
102
+ track_clips = tl.GetItemListInTrack(track_type, track_idx) or []
103
+ for clip_item in track_clips:
104
+ info = {
105
+ "index": len(clips),
106
+ "name": clip_item.GetName(),
107
+ "start": clip_item.GetStart(),
108
+ "end": clip_item.GetEnd(),
109
+ "type": track_type,
110
+ "track": track_idx,
111
+ }
112
+ clips.append((info, clip_item))
113
+ return clips
114
+
115
+
116
+ # --- _impl Function ---
117
+
118
+
119
+ def beat_marker_impl(
120
+ bpm: float,
121
+ clip_index: int,
122
+ note_value: str = "1/4",
123
+ color: str = "Blue",
124
+ name: str = "",
125
+ duration: int = 1,
126
+ dry_run: bool = False,
127
+ ) -> dict[str, Any]:
128
+ """BPM と音価を指定して、指定クリップの範囲にマーカーを配置する。"""
129
+ # 1. バリデーション
130
+ if note_value not in NOTE_VALUE_MAP:
131
+ valid = ", ".join(NOTE_VALUE_MAP)
132
+ raise ValidationError(
133
+ field="note_value",
134
+ reason=f"Invalid note_value: '{note_value}'. Must be one of: {valid}",
135
+ )
136
+ if not (20.0 <= bpm <= 300.0):
137
+ raise ValidationError(
138
+ field="bpm",
139
+ reason=f"BPM must be between 20.0 and 300.0, got {bpm}",
140
+ )
141
+
142
+ # 2. タイムライン情報取得
143
+ project = _get_current_project()
144
+ tl = project.GetCurrentTimeline()
145
+ if not tl:
146
+ raise ProjectNotOpenError()
147
+ fps = float(tl.GetSetting("timelineFrameRate") or "24")
148
+
149
+ # 3. クリップ取得
150
+ clips = _collect_clips(tl)
151
+ if clip_index < 0 or clip_index >= len(clips):
152
+ raise ValidationError(
153
+ field="clip_index",
154
+ reason=f"Clip index {clip_index} out of range (0..{len(clips) - 1})",
155
+ )
156
+ clip_info, _clip_item = clips[clip_index]
157
+ start_frame = clip_info["start"]
158
+ end_frame = clip_info["end"]
159
+ clip_name = clip_info["name"]
160
+
161
+ # 4. フレーム計算(クリップの start〜end 範囲)
162
+ frames = _calculate_beat_frames(bpm, note_value, fps, start_frame, end_frame)
163
+
164
+ # 5. dry-run
165
+ if dry_run:
166
+ return {
167
+ "dry_run": True,
168
+ "action": "marker_beats",
169
+ "bpm": bpm,
170
+ "note_value": note_value,
171
+ "color": color,
172
+ "clip_name": clip_name,
173
+ "count": len(frames),
174
+ "frames": frames,
175
+ }
176
+
177
+ # 6. マーカー追加(マーカーAPIは相対フレームを要求、空nameは拒否される)
178
+ from davinci_cli.commands.timeline import _get_start_frame_offset
179
+
180
+ offset = _get_start_frame_offset(tl)
181
+ marker_name = name or " "
182
+ added_count = 0
183
+ failed_frames = []
184
+ for frame_abs in frames:
185
+ rel_frame = frame_abs - offset
186
+ if tl.AddMarker(rel_frame, color, marker_name, "", duration):
187
+ added_count += 1
188
+ else:
189
+ failed_frames.append(frame_abs)
190
+
191
+ result = {
192
+ "added_count": added_count,
193
+ "requested_count": len(frames),
194
+ "bpm": bpm,
195
+ "note_value": note_value,
196
+ "color": color,
197
+ "clip_name": clip_name,
198
+ "frames": frames,
199
+ }
200
+ if failed_frames:
201
+ result["failed_frames"] = failed_frames
202
+ return result
203
+
204
+
205
+ # --- CLI Command ---
206
+
207
+
208
+ @click.command(name="beats")
209
+ @json_input_option
210
+ @dry_run_option
211
+ @click.pass_context
212
+ def beat_marker_cmd(
213
+ ctx: click.Context,
214
+ json_input: dict[str, Any] | None,
215
+ dry_run: bool,
216
+ ) -> None:
217
+ """BPMベースのマーカー自動配置。"""
218
+ if not json_input:
219
+ raise click.UsageError("--json is required")
220
+ data = BeatMarkerInput.model_validate(json_input)
221
+ result = beat_marker_impl(
222
+ bpm=data.bpm,
223
+ clip_index=data.clip_index,
224
+ note_value=data.note_value,
225
+ color=data.color,
226
+ name=data.name,
227
+ duration=data.duration,
228
+ dry_run=dry_run,
229
+ )
230
+ output(result, pretty=ctx.obj.get("pretty"))
231
+
232
+
233
+ # --- Schema Registration ---
234
+
235
+ register_schema(
236
+ "timeline.marker.beats",
237
+ output_model=BeatMarkerOutput,
238
+ input_model=BeatMarkerInput,
239
+ )