local-coze 0.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.
Files changed (58) hide show
  1. local_coze/__init__.py +110 -0
  2. local_coze/cli/__init__.py +3 -0
  3. local_coze/cli/chat.py +126 -0
  4. local_coze/cli/cli.py +34 -0
  5. local_coze/cli/constants.py +7 -0
  6. local_coze/cli/db.py +81 -0
  7. local_coze/cli/embedding.py +193 -0
  8. local_coze/cli/image.py +162 -0
  9. local_coze/cli/knowledge.py +195 -0
  10. local_coze/cli/search.py +198 -0
  11. local_coze/cli/utils.py +41 -0
  12. local_coze/cli/video.py +191 -0
  13. local_coze/cli/video_edit.py +888 -0
  14. local_coze/cli/voice.py +351 -0
  15. local_coze/core/__init__.py +25 -0
  16. local_coze/core/client.py +253 -0
  17. local_coze/core/config.py +58 -0
  18. local_coze/core/exceptions.py +67 -0
  19. local_coze/database/__init__.py +29 -0
  20. local_coze/database/client.py +170 -0
  21. local_coze/database/migration.py +342 -0
  22. local_coze/embedding/__init__.py +31 -0
  23. local_coze/embedding/client.py +350 -0
  24. local_coze/embedding/models.py +130 -0
  25. local_coze/image/__init__.py +19 -0
  26. local_coze/image/client.py +110 -0
  27. local_coze/image/models.py +163 -0
  28. local_coze/knowledge/__init__.py +19 -0
  29. local_coze/knowledge/client.py +148 -0
  30. local_coze/knowledge/models.py +45 -0
  31. local_coze/llm/__init__.py +25 -0
  32. local_coze/llm/client.py +317 -0
  33. local_coze/llm/models.py +48 -0
  34. local_coze/memory/__init__.py +14 -0
  35. local_coze/memory/client.py +176 -0
  36. local_coze/s3/__init__.py +12 -0
  37. local_coze/s3/client.py +580 -0
  38. local_coze/s3/models.py +18 -0
  39. local_coze/search/__init__.py +19 -0
  40. local_coze/search/client.py +183 -0
  41. local_coze/search/models.py +57 -0
  42. local_coze/video/__init__.py +17 -0
  43. local_coze/video/client.py +347 -0
  44. local_coze/video/models.py +39 -0
  45. local_coze/video_edit/__init__.py +23 -0
  46. local_coze/video_edit/examples.py +340 -0
  47. local_coze/video_edit/frame_extractor.py +176 -0
  48. local_coze/video_edit/models.py +362 -0
  49. local_coze/video_edit/video_edit.py +631 -0
  50. local_coze/voice/__init__.py +17 -0
  51. local_coze/voice/asr.py +82 -0
  52. local_coze/voice/models.py +86 -0
  53. local_coze/voice/tts.py +94 -0
  54. local_coze-0.0.1.dist-info/METADATA +636 -0
  55. local_coze-0.0.1.dist-info/RECORD +58 -0
  56. local_coze-0.0.1.dist-info/WHEEL +4 -0
  57. local_coze-0.0.1.dist-info/entry_points.txt +3 -0
  58. local_coze-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,888 @@
1
+ import json
2
+ import os
3
+ from typing import Optional
4
+
5
+ import click
6
+ from coze_coding_utils.runtime_ctx.context import new_context
7
+ from rich.console import Console
8
+ from rich.progress import Progress, SpinnerColumn, TextColumn
9
+ from rich.table import Table
10
+ from sqlalchemy import True_
11
+
12
+ from ..core.config import Config
13
+ from ..core.exceptions import APIError
14
+ from ..video_edit import (
15
+ FrameExtractorClient,
16
+ VideoEditClient,
17
+ SubtitleConfig,
18
+ FontPosConfig,
19
+ TextItem,
20
+ )
21
+ from .constants import RUN_MODE_HEADER, RUN_MODE_TEST
22
+
23
+ console = Console()
24
+
25
+
26
+ @click.group()
27
+ def video_edit():
28
+ """视频编辑工具集"""
29
+ pass
30
+
31
+
32
+ @video_edit.command()
33
+ @click.option("--url", "-u", required=True, help="视频 URL")
34
+ @click.option("--output", "-o", type=click.Path(), help="输出文件路径(JSON)")
35
+ @click.option("--mock", is_flag=True, help="使用 mock 模式(测试运行)")
36
+ @click.option(
37
+ "--header",
38
+ "-H",
39
+ multiple=True,
40
+ help="自定义 HTTP 请求头 (格式: 'Key: Value' 或 'Key=Value',可多次使用)",
41
+ )
42
+ @click.option("--verbose", "-v", is_flag=True, help="显示详细的 HTTP 请求日志")
43
+ def extract_keyframe(
44
+ url: str,
45
+ output: Optional[str],
46
+ mock: bool,
47
+ header: tuple,
48
+ verbose: bool,
49
+ ):
50
+ """按关键帧提取视频帧"""
51
+ try:
52
+ from .utils import parse_headers
53
+
54
+ config = Config()
55
+
56
+ ctx = None
57
+ custom_headers = parse_headers(header) or {}
58
+
59
+ if mock:
60
+ ctx = new_context(method="video_edit.extract_keyframe", headers=custom_headers)
61
+ custom_headers[RUN_MODE_HEADER] = RUN_MODE_TEST
62
+ console.print("[yellow]🧪 Mock 模式已启用(测试运行)[/yellow]")
63
+
64
+ client = FrameExtractorClient(
65
+ config, ctx=ctx, custom_headers=custom_headers, verbose=verbose
66
+ )
67
+
68
+ with Progress(
69
+ SpinnerColumn(),
70
+ TextColumn("[progress.description]{task.description}"),
71
+ console=console,
72
+ ) as progress:
73
+ task = progress.add_task("[cyan]提取关键帧中...", total=None)
74
+
75
+ try:
76
+ response = client.extract_by_key_frame(url=url)
77
+ progress.update(task, description="[green]✓ 关键帧提取完成!")
78
+
79
+ except APIError as e:
80
+ progress.update(task, description="[red]✗ 关键帧提取失败")
81
+ console.print(f"[red]错误: {str(e)}[/red]")
82
+ raise click.Abort()
83
+
84
+ table = Table(title="关键帧提取结果")
85
+ table.add_column("索引", style="cyan", no_wrap=True)
86
+ table.add_column("时间 (秒)", style="yellow")
87
+ table.add_column("URL", style="green", overflow="fold")
88
+
89
+ for frame in response.data.chunks[:10]:
90
+ table.add_row(
91
+ str(frame.index),
92
+ f"{frame.timestamp_ms:.2f}",
93
+ frame.screenshot
94
+ )
95
+
96
+ if len(response.data.chunks) > 10:
97
+ table.add_row("...", "...", f"(还有 {len(response.data.chunks) - 10} 帧)")
98
+
99
+ console.print(table)
100
+ console.print(f"\n[cyan]总共提取了 {len(response.data.chunks)} 帧[/cyan]")
101
+
102
+ if output:
103
+ result = {
104
+ "code": response.code,
105
+ "message": response.message,
106
+ "log_id": response.log_id,
107
+ "frames": [
108
+ {
109
+ "index": frame.index,
110
+ "time": frame.timestamp_ms,
111
+ "url": frame.screenshot
112
+ }
113
+ for frame in response.data.chunks
114
+ ]
115
+ }
116
+ with open(output, "w") as f:
117
+ json.dump(result, f, indent=2, ensure_ascii=False)
118
+ console.print(f"\n[green]✓[/green] 结果已保存到: {output}")
119
+
120
+ except Exception as e:
121
+ console.print(f"[red]✗ 错误: {str(e)}[/red]")
122
+ raise click.Abort()
123
+
124
+
125
+ @video_edit.command()
126
+ @click.option("--url", "-u", required=True, help="视频 URL")
127
+ @click.option("--interval", "-i", required=True, type=int, help="抽帧间隔(毫秒)")
128
+ @click.option("--output", "-o", type=click.Path(), help="输出文件路径(JSON)")
129
+ @click.option("--mock", is_flag=True, help="使用 mock 模式(测试运行)")
130
+ @click.option(
131
+ "--header",
132
+ "-H",
133
+ multiple=True,
134
+ help="自定义 HTTP 请求头 (格式: 'Key: Value' 或 'Key=Value',可多次使用)",
135
+ )
136
+ @click.option("--verbose", "-v", is_flag=True, help="显示详细的 HTTP 请求日志")
137
+ def extract_interval(
138
+ url: str,
139
+ interval: int,
140
+ output: Optional[str],
141
+ mock: bool,
142
+ header: tuple,
143
+ verbose: bool,
144
+ ):
145
+ """按固定时间间隔提取视频帧"""
146
+ try:
147
+ from .utils import parse_headers
148
+
149
+ config = Config()
150
+
151
+ ctx = None
152
+ custom_headers = parse_headers(header) or {}
153
+
154
+ if mock:
155
+ ctx = new_context(method="video_edit.extract_interval", headers=custom_headers)
156
+ custom_headers[RUN_MODE_HEADER] = RUN_MODE_TEST
157
+ console.print("[yellow]🧪 Mock 模式已启用(测试运行)[/yellow]")
158
+
159
+ client = FrameExtractorClient(
160
+ config, ctx=ctx, custom_headers=custom_headers, verbose=verbose
161
+ )
162
+
163
+ with Progress(
164
+ SpinnerColumn(),
165
+ TextColumn("[progress.description]{task.description}"),
166
+ console=console,
167
+ ) as progress:
168
+ task = progress.add_task(f"[cyan]按 {interval} 秒间隔提取帧中...", total=None)
169
+
170
+ try:
171
+ response = client.extract_by_interval(url=url, interval_ms=interval)
172
+ progress.update(task, description="[green]✓ 间隔抽帧完成!")
173
+
174
+ except APIError as e:
175
+ progress.update(task, description="[red]✗ 间隔抽帧失败")
176
+ console.print(f"[red]错误: {str(e)}[/red]")
177
+ raise click.Abort()
178
+
179
+ table = Table(title=f"间隔抽帧结果 (每 {interval} 秒)")
180
+ table.add_column("索引", style="cyan", no_wrap=True)
181
+ table.add_column("时间 (秒)", style="yellow")
182
+ table.add_column("URL", style="green", overflow="fold")
183
+
184
+ for frame in response.data.chunks[:10]:
185
+ table.add_row(
186
+ str(frame.index),
187
+ f"{frame.timestamp_ms:.2f}",
188
+ frame.screenshot
189
+ )
190
+
191
+ if len(response.data.chunks) > 10:
192
+ table.add_row("...", "...", f"(还有 {len(response.data.chunks) - 10} 帧)")
193
+
194
+ console.print(table)
195
+ console.print(f"\n[cyan]总共提取了 {len(response.data.chunks)} 帧[/cyan]")
196
+
197
+ if output:
198
+ result = {
199
+ "code": response.code,
200
+ "message": response.message,
201
+ "log_id": response.log_id,
202
+ "interval": interval,
203
+ "frames": [
204
+ {
205
+ "index": frame.index,
206
+ "time": frame.timestamp_ms,
207
+ "url": frame.screenshot
208
+ }
209
+ for frame in response.data.chunks
210
+ ]
211
+ }
212
+ with open(output, "w") as f:
213
+ json.dump(result, f, indent=2, ensure_ascii=False)
214
+ console.print(f"\n[green]✓[/green] 结果已保存到: {output}")
215
+
216
+ except Exception as e:
217
+ console.print(f"[red]✗ 错误: {str(e)}[/red]")
218
+ raise click.Abort()
219
+
220
+
221
+ @video_edit.command()
222
+ @click.option("--url", "-u", required=True, help="视频 URL")
223
+ @click.option("--count", "-c", required=True, type=int, help="提取帧数")
224
+ @click.option("--output", "-o", type=click.Path(), help="输出文件路径(JSON)")
225
+ @click.option("--mock", is_flag=True, help="使用 mock 模式(测试运行)")
226
+ @click.option(
227
+ "--header",
228
+ "-H",
229
+ multiple=True,
230
+ help="自定义 HTTP 请求头 (格式: 'Key: Value' 或 'Key=Value',可多次使用)",
231
+ )
232
+ @click.option("--verbose", "-v", is_flag=True, help="显示详细的 HTTP 请求日志")
233
+ def extract_count(
234
+ url: str,
235
+ count: int,
236
+ output: Optional[str],
237
+ mock: bool,
238
+ header: tuple,
239
+ verbose: bool,
240
+ ):
241
+ """按固定数量提取视频帧"""
242
+ try:
243
+ from .utils import parse_headers
244
+
245
+ config = Config()
246
+
247
+ ctx = None
248
+ custom_headers = parse_headers(header) or {}
249
+
250
+ if mock:
251
+ ctx = new_context(method="video_edit.extract_count", headers=custom_headers)
252
+ custom_headers[RUN_MODE_HEADER] = RUN_MODE_TEST
253
+ console.print("[yellow]🧪 Mock 模式已启用(测试运行)[/yellow]")
254
+
255
+ client = FrameExtractorClient(
256
+ config, ctx=ctx, custom_headers=custom_headers, verbose=verbose
257
+ )
258
+
259
+ with Progress(
260
+ SpinnerColumn(),
261
+ TextColumn("[progress.description]{task.description}"),
262
+ console=console,
263
+ ) as progress:
264
+ task = progress.add_task(f"[cyan]提取 {count} 帧中...", total=None)
265
+
266
+ try:
267
+ response = client.extract_by_count(url=url, count=count)
268
+ progress.update(task, description="[green]✓ 定量抽帧完成!")
269
+
270
+ except APIError as e:
271
+ progress.update(task, description="[red]✗ 定量抽帧失败")
272
+ console.print(f"[red]错误: {str(e)}[/red]")
273
+ raise click.Abort()
274
+
275
+ table = Table(title=f"定量抽帧结果 (共 {count} 帧)")
276
+ table.add_column("索引", style="cyan", no_wrap=True)
277
+ table.add_column("时间 (秒)", style="yellow")
278
+ table.add_column("URL", style="green", overflow="fold")
279
+
280
+ for frame in response.data.chunks:
281
+ table.add_row(
282
+ str(frame.index),
283
+ f"{frame.timestamp_ms:.2f}",
284
+ frame.screenshot
285
+ )
286
+
287
+ console.print(table)
288
+
289
+ if output:
290
+ result = {
291
+ "code": response.code,
292
+ "message": response.message,
293
+ "log_id": response.log_id,
294
+ "count": count,
295
+ "frames": [
296
+ {
297
+ "index": frame.index,
298
+ "time": frame.timestamp_ms,
299
+ "url": frame.screenshot
300
+ }
301
+ for frame in response.data.chunks
302
+ ]
303
+ }
304
+ with open(output, "w") as f:
305
+ json.dump(result, f, indent=2, ensure_ascii=False)
306
+ console.print(f"\n[green]✓[/green] 结果已保存到: {output}")
307
+
308
+ except Exception as e:
309
+ console.print(f"[red]✗ 错误: {str(e)}[/red]")
310
+ raise click.Abort()
311
+
312
+
313
+ @video_edit.command()
314
+ @click.option("--video", "-v", required=True, help="视频 URL")
315
+ @click.option("--start", "-s", required=True, type=float, help="开始时间(秒)")
316
+ @click.option("--end", "-e", required=True, type=float, help="结束时间(秒)")
317
+ @click.option("--output", "-o", type=click.Path(), help="输出文件路径(JSON)")
318
+ @click.option("--mock", is_flag=True, help="使用 mock 模式(测试运行)")
319
+ @click.option(
320
+ "--header",
321
+ "-H",
322
+ multiple=True,
323
+ help="自定义 HTTP 请求头 (格式: 'Key: Value' 或 'Key=Value',可多次使用)",
324
+ )
325
+ @click.option("--verbose", is_flag=True, help="显示详细的 HTTP 请求日志")
326
+ def trim(
327
+ video: str,
328
+ start: float,
329
+ end: float,
330
+ output: Optional[str],
331
+ mock: bool,
332
+ header: tuple,
333
+ verbose: bool,
334
+ ):
335
+ """裁剪视频"""
336
+ try:
337
+ from .utils import parse_headers
338
+
339
+ config = Config()
340
+
341
+ ctx = None
342
+ custom_headers = parse_headers(header) or {}
343
+
344
+ if mock:
345
+ ctx = new_context(method="video_edit.trim", headers=custom_headers)
346
+ custom_headers[RUN_MODE_HEADER] = RUN_MODE_TEST
347
+ console.print("[yellow]🧪 Mock 模式已启用(测试运行)[/yellow]")
348
+
349
+ client = VideoEditClient(
350
+ config, ctx=ctx, custom_headers=custom_headers, verbose=verbose
351
+ )
352
+
353
+ console.print(f"[cyan]裁剪视频: {start}s - {end}s (时长: {end - start}s)[/cyan]")
354
+
355
+ with Progress(
356
+ SpinnerColumn(),
357
+ TextColumn("[progress.description]{task.description}"),
358
+ console=console,
359
+ ) as progress:
360
+ task = progress.add_task("[cyan]裁剪视频中...", total=None)
361
+
362
+ try:
363
+ response = client.video_trim(
364
+ video=video,
365
+ start_time=start,
366
+ end_time=end
367
+ )
368
+ progress.update(task, description="[green]✓ 视频裁剪完成!")
369
+
370
+ except APIError as e:
371
+ progress.update(task, description="[red]✗ 视频裁剪失败")
372
+ console.print(f"[red]错误: {str(e)}[/red]")
373
+ raise click.Abort()
374
+
375
+ table = Table(title="视频裁剪结果")
376
+ table.add_column("字段", style="cyan", no_wrap=True)
377
+ table.add_column("值", style="green", overflow="fold")
378
+
379
+ table.add_row("请求 ID", response.req_id)
380
+ table.add_row("视频 URL", response.url)
381
+ if response.message:
382
+ table.add_row("消息", response.message)
383
+ if response.video_meta:
384
+ table.add_row("时长", f"{response.video_meta.duration:.2f}s")
385
+ table.add_row("分辨率", response.video_meta.resolution)
386
+
387
+ console.print(table)
388
+ console.print(f"\n[cyan]完整视频 URL:[/cyan]")
389
+ console.print(f"[green]{response.url}[/green]")
390
+
391
+ if output:
392
+ result = {
393
+ "req_id": response.req_id,
394
+ "url": response.url,
395
+ "message": response.message,
396
+ "video_meta": {
397
+ "duration": response.video_meta.duration,
398
+ "resolution": response.video_meta.resolution,
399
+ "type": response.video_meta.type
400
+ } if response.video_meta else None
401
+ }
402
+ with open(output, "w") as f:
403
+ json.dump(result, f, indent=2, ensure_ascii=False)
404
+ console.print(f"\n[green]✓[/green] 结果已保存到: {output}")
405
+
406
+ except Exception as e:
407
+ console.print(f"[red]✗ 错误: {str(e)}[/red]")
408
+ raise click.Abort()
409
+
410
+
411
+ @video_edit.command()
412
+ @click.option("--videos", "-v", required=True, help="视频 URL 列表(逗号分隔)")
413
+ @click.option("--output", "-o", type=click.Path(), help="输出文件路径(JSON)")
414
+ @click.option("--mock", is_flag=True, help="使用 mock 模式(测试运行)")
415
+ @click.option(
416
+ "--header",
417
+ "-H",
418
+ multiple=True,
419
+ help="自定义 HTTP 请求头 (格式: 'Key: Value' 或 'Key=Value',可多次使用)",
420
+ )
421
+ @click.option("--verbose", is_flag=True, help="显示详细的 HTTP 请求日志")
422
+ def concat(
423
+ videos: str,
424
+ output: Optional[str],
425
+ mock: bool,
426
+ header: tuple,
427
+ verbose: bool,
428
+ ):
429
+ """拼接多个视频"""
430
+ try:
431
+ from .utils import parse_headers
432
+
433
+ config = Config()
434
+
435
+ ctx = None
436
+ custom_headers = parse_headers(header) or {}
437
+
438
+ if mock:
439
+ ctx = new_context(method="video_edit.concat", headers=custom_headers)
440
+ custom_headers[RUN_MODE_HEADER] = RUN_MODE_TEST
441
+ console.print("[yellow]🧪 Mock 模式已启用(测试运行)[/yellow]")
442
+
443
+ client = VideoEditClient(
444
+ config, ctx=ctx, custom_headers=custom_headers, verbose=verbose
445
+ )
446
+
447
+ video_list = [v.strip() for v in videos.split(",")]
448
+ console.print(f"[cyan]拼接 {len(video_list)} 个视频[/cyan]")
449
+
450
+ with Progress(
451
+ SpinnerColumn(),
452
+ TextColumn("[progress.description]{task.description}"),
453
+ console=console,
454
+ ) as progress:
455
+ task = progress.add_task("[cyan]拼接视频中...", total=None)
456
+
457
+ try:
458
+ response = client.concat_videos(videos=video_list)
459
+ progress.update(task, description="[green]✓ 视频拼接完成!")
460
+
461
+ except APIError as e:
462
+ progress.update(task, description="[red]✗ 视频拼接失败")
463
+ console.print(f"[red]错误: {str(e)}[/red]")
464
+ raise click.Abort()
465
+
466
+ table = Table(title="视频拼接结果")
467
+ table.add_column("字段", style="cyan", no_wrap=True)
468
+ table.add_column("值", style="green", overflow="fold")
469
+
470
+ table.add_row("请求 ID", response.req_id)
471
+ table.add_row("视频 URL", response.url)
472
+ if response.message:
473
+ table.add_row("消息", response.message)
474
+ if response.video_meta:
475
+ table.add_row("时长", f"{response.video_meta.duration:.2f}s")
476
+ table.add_row("分辨率", response.video_meta.resolution)
477
+
478
+ console.print(table)
479
+ console.print(f"\n[cyan]完整视频 URL:[/cyan]")
480
+ console.print(f"[green]{response.url}[/green]")
481
+
482
+ if output:
483
+ result = {
484
+ "req_id": response.req_id,
485
+ "url": response.url,
486
+ "message": response.message,
487
+ "video_meta": {
488
+ "duration": response.video_meta.duration,
489
+ "resolution": response.video_meta.resolution,
490
+ "type": response.video_meta.type
491
+ } if response.video_meta else None
492
+ }
493
+ with open(output, "w") as f:
494
+ json.dump(result, f, indent=2, ensure_ascii=False)
495
+ console.print(f"\n[green]✓[/green] 结果已保存到: {output}")
496
+
497
+ except Exception as e:
498
+ console.print(f"[red]✗ 错误: {str(e)}[/red]")
499
+ raise click.Abort()
500
+
501
+
502
+ @video_edit.command()
503
+ @click.option("--video", "-v", required=True, help="视频 URL")
504
+ @click.option("--subtitle", "-s", required=True, help="字幕文件 URL(SRT/VTT)")
505
+ @click.option("--text", "-t", help="文本内容(格式: start,end,text;多个用 | 分隔)")
506
+ @click.option("--font-size", type=int, default=40, help="字体大小")
507
+ @click.option("--font-color", default="#FFFFFFFF", help="字体颜色(十六进制)")
508
+ @click.option("--pos-x", default="0", help="字幕 X 坐标")
509
+ @click.option("--pos-y", default="90%", help="字幕 Y 坐标")
510
+ @click.option("--width", default="100%", help="字幕宽度")
511
+ @click.option("--height", default="10%", help="字幕高度")
512
+ @click.option("--output", "-o", type=click.Path(), help="输出文件路径(JSON)")
513
+ @click.option("--mock", is_flag=True, help="使用 mock 模式(测试运行)")
514
+ @click.option(
515
+ "--header",
516
+ "-H",
517
+ multiple=True,
518
+ help="自定义 HTTP 请求头 (格式: 'Key: Value' 或 'Key=Value',可多次使用)",
519
+ )
520
+ @click.option("--verbose", is_flag=True, help="显示详细的 HTTP 请求日志")
521
+ def add_subtitle(
522
+ video: str,
523
+ subtitle: Optional[str],
524
+ text: Optional[str],
525
+ font_size: Optional[int],
526
+ font_color: Optional[str],
527
+ pos_x: Optional[str],
528
+ pos_y: Optional[str],
529
+ width: Optional[str],
530
+ height: Optional[str],
531
+ output: Optional[str],
532
+ mock: bool,
533
+ header: tuple,
534
+ verbose: bool,
535
+ ):
536
+ """为视频添加字幕"""
537
+ try:
538
+ from .utils import parse_headers
539
+
540
+ if not subtitle and not text:
541
+ console.print("[red]错误: 必须提供 --subtitle 或 --text 参数[/red]")
542
+ raise click.Abort()
543
+
544
+ config = Config()
545
+
546
+ ctx = None
547
+ custom_headers = parse_headers(header) or {}
548
+
549
+ if mock:
550
+ ctx = new_context(method="video_edit.add_subtitle", headers=custom_headers)
551
+ custom_headers[RUN_MODE_HEADER] = RUN_MODE_TEST
552
+ console.print("[yellow]🧪 Mock 模式已启用(测试运行)[/yellow]")
553
+
554
+ client = VideoEditClient(
555
+ config, ctx=ctx, custom_headers=custom_headers, verbose=verbose
556
+ )
557
+
558
+ subtitle_config = SubtitleConfig(
559
+ font_pos_config=FontPosConfig(
560
+ pos_x=pos_x,
561
+ pos_y=pos_y,
562
+ width=width,
563
+ height=height
564
+ ),
565
+ font_size=font_size,
566
+ font_color=font_color
567
+ )
568
+
569
+ text_list = None
570
+ subtitle_url = None
571
+
572
+ if text:
573
+ text_list = []
574
+ for item in text.split("|"):
575
+ parts = item.strip().split(",", 2)
576
+ if len(parts) == 3:
577
+ start_time, end_time, text_content = parts
578
+ text_list.append(TextItem(
579
+ start_time=float(start_time),
580
+ end_time=float(end_time),
581
+ text=text_content
582
+ ))
583
+ console.print(f"[cyan]添加 {len(text_list)} 条文本字幕[/cyan]")
584
+ elif subtitle:
585
+ console.print(f"[cyan]使用字幕文件: {subtitle}[/cyan]")
586
+ subtitle_url = subtitle
587
+
588
+ with Progress(
589
+ SpinnerColumn(),
590
+ TextColumn("[progress.description]{task.description}"),
591
+ console=console,
592
+ ) as progress:
593
+ task = progress.add_task("[cyan]添加字幕中...", total=None)
594
+
595
+ try:
596
+ response = client.add_subtitles(
597
+ video=video,
598
+ subtitle_config=subtitle_config,
599
+ subtitle_url=subtitle_url,
600
+ text_list=text_list
601
+ )
602
+ progress.update(task, description="[green]✓ 字幕添加完成!")
603
+
604
+ except APIError as e:
605
+ progress.update(task, description="[red]✗ 字幕添加失败")
606
+ console.print(f"[red]错误: {str(e)}[/red]")
607
+ raise click.Abort()
608
+
609
+ table = Table(title="字幕添加结果")
610
+ table.add_column("字段", style="cyan", no_wrap=True)
611
+ table.add_column("值", style="green", overflow="fold")
612
+
613
+ table.add_row("请求 ID", response.req_id)
614
+ table.add_row("视频 URL", response.url)
615
+ if response.message:
616
+ table.add_row("消息", response.message)
617
+
618
+ console.print(table)
619
+ console.print(f"\n[cyan]完整视频 URL:[/cyan]")
620
+ console.print(f"[green]{response.url}[/green]")
621
+
622
+ if output:
623
+ result = {
624
+ "req_id": response.req_id,
625
+ "url": response.url,
626
+ "message": response.message
627
+ }
628
+ with open(output, "w") as f:
629
+ json.dump(result, f, indent=2, ensure_ascii=False)
630
+ console.print(f"\n[green]✓[/green] 结果已保存到: {output}")
631
+
632
+ except Exception as e:
633
+ console.print(f"[red]✗ 错误: {str(e)}[/red]")
634
+ raise click.Abort()
635
+
636
+
637
+ @video_edit.command()
638
+ @click.option("--video", "-v", required=True, help="视频 URL")
639
+ @click.option("--output", "-o", type=click.Path(), help="输出文件路径(JSON)")
640
+ @click.option("--mock", is_flag=True, help="使用 mock 模式(测试运行)")
641
+ @click.option(
642
+ "--header",
643
+ "-H",
644
+ multiple=True,
645
+ help="自定义 HTTP 请求头 (格式: 'Key: Value' 或 'Key=Value',可多次使用)",
646
+ )
647
+ @click.option("--verbose", is_flag=True, help="显示详细的 HTTP 请求日志")
648
+ def extract_audio(
649
+ video: str,
650
+ output: Optional[str],
651
+ mock: bool,
652
+ header: tuple,
653
+ verbose: bool,
654
+ ):
655
+ """从视频中提取音频"""
656
+ try:
657
+ from .utils import parse_headers
658
+
659
+ config = Config()
660
+
661
+ ctx = None
662
+ custom_headers = parse_headers(header) or {}
663
+
664
+ if mock:
665
+ ctx = new_context(method="video_edit.extract_audio", headers=custom_headers)
666
+ custom_headers[RUN_MODE_HEADER] = RUN_MODE_TEST
667
+ console.print("[yellow]🧪 Mock 模式已启用(测试运行)[/yellow]")
668
+
669
+ client = VideoEditClient(
670
+ config, ctx=ctx, custom_headers=custom_headers, verbose=verbose
671
+ )
672
+
673
+ with Progress(
674
+ SpinnerColumn(),
675
+ TextColumn("[progress.description]{task.description}"),
676
+ console=console,
677
+ ) as progress:
678
+ task = progress.add_task("[cyan]提取音频中...", total=None)
679
+
680
+ try:
681
+ response = client.extract_audio(video=video)
682
+ progress.update(task, description="[green]✓ 音频提取完成!")
683
+
684
+ except APIError as e:
685
+ progress.update(task, description="[red]✗ 音频提取失败")
686
+ console.print(f"[red]错误: {str(e)}[/red]")
687
+ raise click.Abort()
688
+
689
+ table = Table(title="音频提取结果")
690
+ table.add_column("字段", style="cyan", no_wrap=True)
691
+ table.add_column("值", style="green", overflow="fold")
692
+
693
+ table.add_row("请求 ID", response.req_id)
694
+ table.add_row("音频 URL", response.url)
695
+ if response.message:
696
+ table.add_row("消息", response.message)
697
+
698
+ console.print(table)
699
+ console.print(f"\n[cyan]完整音频 URL:[/cyan]")
700
+ console.print(f"[green]{response.url}[/green]")
701
+
702
+ if output:
703
+ result = {
704
+ "req_id": response.req_id,
705
+ "url": response.url,
706
+ "message": response.message
707
+ }
708
+ with open(output, "w") as f:
709
+ json.dump(result, f, indent=2, ensure_ascii=False)
710
+ console.print(f"\n[green]✓[/green] 结果已保存到: {output}")
711
+
712
+ except Exception as e:
713
+ console.print(f"[red]✗ 错误: {str(e)}[/red]")
714
+ raise click.Abort()
715
+
716
+
717
+ @video_edit.command()
718
+ @click.option("--audio", "-a", required=True, help="音频 URL")
719
+ @click.option("--output", "-o", type=click.Path(), help="输出文件路径(JSON)")
720
+ @click.option("--mock", is_flag=True, help="使用 mock 模式(测试运行)")
721
+ @click.option(
722
+ "--header",
723
+ "-H",
724
+ multiple=True,
725
+ help="自定义 HTTP 请求头 (格式: 'Key: Value' 或 'Key=Value',可多次使用)",
726
+ )
727
+ @click.option("--verbose", is_flag=True, help="显示详细的 HTTP 请求日志")
728
+ def audio_to_subtitle(
729
+ audio: str,
730
+ output: Optional[str],
731
+ mock: bool,
732
+ header: tuple,
733
+ verbose: bool,
734
+ ):
735
+ """将音频转换为字幕"""
736
+ try:
737
+ from .utils import parse_headers
738
+
739
+ config = Config()
740
+
741
+ ctx = None
742
+ custom_headers = parse_headers(header) or {}
743
+
744
+ if mock:
745
+ ctx = new_context(method="video_edit.audio_to_subtitle", headers=custom_headers)
746
+ custom_headers[RUN_MODE_HEADER] = RUN_MODE_TEST
747
+ console.print("[yellow]🧪 Mock 模式已启用(测试运行)[/yellow]")
748
+
749
+ client = VideoEditClient(
750
+ config, ctx=ctx, custom_headers=custom_headers, verbose=verbose
751
+ )
752
+
753
+ with Progress(
754
+ SpinnerColumn(),
755
+ TextColumn("[progress.description]{task.description}"),
756
+ console=console,
757
+ ) as progress:
758
+ task = progress.add_task("[cyan]音频转字幕中...", total=None)
759
+
760
+ try:
761
+ response = client.audio_to_subtitle(source=audio)
762
+ progress.update(task, description="[green]✓ 音频转字幕完成!")
763
+
764
+ except APIError as e:
765
+ progress.update(task, description="[red]✗ 音频转字幕失败")
766
+ console.print(f"[red]错误: {str(e)}[/red]")
767
+ raise click.Abort()
768
+
769
+ table = Table(title="音频转字幕结果")
770
+ table.add_column("字段", style="cyan", no_wrap=True)
771
+ table.add_column("值", style="green", overflow="fold")
772
+
773
+ table.add_row("请求 ID", response.req_id)
774
+ table.add_row("字幕 URL", response.url)
775
+ if response.message:
776
+ table.add_row("消息", response.message)
777
+
778
+ console.print(table)
779
+ console.print(f"\n[cyan]完整字幕 URL:[/cyan]")
780
+ console.print(f"[green]{response.url}[/green]")
781
+
782
+ if output:
783
+ result = {
784
+ "req_id": response.req_id,
785
+ "url": response.url,
786
+ "message": response.message
787
+ }
788
+ with open(output, "w") as f:
789
+ json.dump(result, f, indent=2, ensure_ascii=False)
790
+ console.print(f"\n[green]✓[/green] 结果已保存到: {output}")
791
+
792
+ except Exception as e:
793
+ console.print(f"[red]✗ 错误: {str(e)}[/red]")
794
+ raise click.Abort()
795
+
796
+
797
+ @video_edit.command()
798
+ @click.option("--video", "-v", required=True, help="视频 URL")
799
+ @click.option("--audio", "-a", required=True, help="音频 URL")
800
+ @click.option("--output", "-o", type=click.Path(), help="输出文件路径(JSON)")
801
+ @click.option("--mock", is_flag=True, help="使用 mock 模式(测试运行)")
802
+ @click.option(
803
+ "--header",
804
+ "-H",
805
+ multiple=True,
806
+ help="自定义 HTTP 请求头 (格式: 'Key: Value' 或 'Key=Value',可多次使用)",
807
+ )
808
+ @click.option("--verbose", is_flag=True, help="显示详细的 HTTP 请求日志")
809
+ def merge_audio(
810
+ video: str,
811
+ audio: str,
812
+ output: Optional[str],
813
+ mock: bool,
814
+ header: tuple,
815
+ verbose: bool,
816
+ ):
817
+ """合成视频和音频"""
818
+ try:
819
+ from .utils import parse_headers
820
+
821
+ config = Config()
822
+
823
+ ctx = None
824
+ custom_headers = parse_headers(header) or {}
825
+
826
+ if mock:
827
+ ctx = new_context(method="video_edit.merge_audio", headers=custom_headers)
828
+ custom_headers[RUN_MODE_HEADER] = RUN_MODE_TEST
829
+ console.print("[yellow]🧪 Mock 模式已启用(测试运行)[/yellow]")
830
+
831
+ client = VideoEditClient(
832
+ config, ctx=ctx, custom_headers=custom_headers, verbose=verbose
833
+ )
834
+
835
+ with Progress(
836
+ SpinnerColumn(),
837
+ TextColumn("[progress.description]{task.description}"),
838
+ console=console,
839
+ ) as progress:
840
+ task = progress.add_task("[cyan]合成视频和音频中...", total=None)
841
+
842
+ try:
843
+ response = client.compile_video_audio(video=video, audio=audio)
844
+ progress.update(task, description="[green]✓ 视频音频合成完成!")
845
+
846
+ except APIError as e:
847
+ progress.update(task, description="[red]✗ 视频音频合成失败")
848
+ console.print(f"[red]错误: {str(e)}[/red]")
849
+ raise click.Abort()
850
+
851
+ table = Table(title="视频音频合成结果")
852
+ table.add_column("字段", style="cyan", no_wrap=True)
853
+ table.add_column("值", style="green", overflow="fold")
854
+
855
+ table.add_row("请求 ID", response.req_id)
856
+ table.add_row("视频 URL", response.url)
857
+ if response.message:
858
+ table.add_row("消息", response.message)
859
+ if response.video_meta:
860
+ table.add_row("时长", f"{response.video_meta.duration:.2f}s")
861
+ table.add_row("分辨率", response.video_meta.resolution)
862
+
863
+ console.print(table)
864
+ console.print(f"\n[cyan]完整视频 URL:[/cyan]")
865
+ console.print(f"[green]{response.url}[/green]")
866
+
867
+ if output:
868
+ result = {
869
+ "req_id": response.req_id,
870
+ "url": response.url,
871
+ "message": response.message,
872
+ "video_meta": {
873
+ "duration": response.video_meta.duration,
874
+ "resolution": response.video_meta.resolution,
875
+ "type": response.video_meta.type
876
+ } if response.video_meta else None
877
+ }
878
+ with open(output, "w") as f:
879
+ json.dump(result, f, indent=2, ensure_ascii=False)
880
+ console.print(f"\n[green]✓[/green] 结果已保存到: {output}")
881
+
882
+ except Exception as e:
883
+ console.print(f"[red]✗ 错误: {str(e)}[/red]")
884
+ raise click.Abort()
885
+
886
+
887
+ if __name__ == "__main__":
888
+ video_edit()