Ava2lon commited on
Commit
fe7106b
·
verified ·
1 Parent(s): 6c45e6d

Upload 38 files

Browse files
renderer/__init__.py CHANGED
@@ -1,4 +1,4 @@
1
- """CPU-first video rendering engine for Basyx FFmpeg."""
2
 
3
  from renderer.core.config import Settings
4
  from renderer.core.models import RenderRequest, RenderResult
 
1
+ """CPU-first video automation backend for Ava2lon Studio AI."""
2
 
3
  from renderer.core.config import Settings
4
  from renderer.core.models import RenderRequest, RenderResult
renderer/core/config.py CHANGED
@@ -11,14 +11,14 @@ DEFAULT_ROOT = Path.cwd()
11
  class Settings:
12
  """Runtime settings tuned for small CPU-only Hugging Face Spaces."""
13
 
14
- base_dir: Path = Path(os.getenv("BASYX_BASE_DIR", str(DEFAULT_ROOT)))
15
  temp_dir: Path = Path(os.getenv("TEMP_DIR", str(DEFAULT_ROOT / "temp")))
16
  exports_dir: Path = Path(os.getenv("EXPORTS_DIR", str(DEFAULT_ROOT / "exports")))
17
  jobs_dir: Path = Path(os.getenv("JOBS_DIR", str(DEFAULT_ROOT / "jobs")))
18
  storage_dir: Path = Path(os.getenv("STORAGE_DIR", str(DEFAULT_ROOT / "storage")))
19
  metadata_cache: Path = Path(os.getenv("METADATA_CACHE", str(DEFAULT_ROOT / "temp" / "metadata_cache.json")))
20
- signing_secret: str = os.getenv("BASYX_SIGNING_SECRET", "dev-secret-change-me")
21
- api_key: str = os.getenv("BASYX_API_KEY", "")
22
  font_path: Path = Path(os.getenv("FONT_PATH", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"))
23
  output_width: int = int(os.getenv("OUTPUT_WIDTH", "1080"))
24
  output_height: int = int(os.getenv("OUTPUT_HEIGHT", "1920"))
 
11
  class Settings:
12
  """Runtime settings tuned for small CPU-only Hugging Face Spaces."""
13
 
14
+ base_dir: Path = Path(os.getenv("AVA2LON_BASE_DIR", os.getenv("BASYX_BASE_DIR", str(DEFAULT_ROOT))))
15
  temp_dir: Path = Path(os.getenv("TEMP_DIR", str(DEFAULT_ROOT / "temp")))
16
  exports_dir: Path = Path(os.getenv("EXPORTS_DIR", str(DEFAULT_ROOT / "exports")))
17
  jobs_dir: Path = Path(os.getenv("JOBS_DIR", str(DEFAULT_ROOT / "jobs")))
18
  storage_dir: Path = Path(os.getenv("STORAGE_DIR", str(DEFAULT_ROOT / "storage")))
19
  metadata_cache: Path = Path(os.getenv("METADATA_CACHE", str(DEFAULT_ROOT / "temp" / "metadata_cache.json")))
20
+ signing_secret: str = os.getenv("AVA2LON_SIGNING_SECRET", os.getenv("BASYX_SIGNING_SECRET", "dev-secret-change-me"))
21
+ api_key: str = os.getenv("AVA2LON_API_KEY", os.getenv("BASYX_API_KEY", ""))
22
  font_path: Path = Path(os.getenv("FONT_PATH", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"))
23
  output_width: int = int(os.getenv("OUTPUT_WIDTH", "1080"))
24
  output_height: int = int(os.getenv("OUTPUT_HEIGHT", "1920"))
renderer/jobs/manager.py CHANGED
@@ -215,7 +215,7 @@ class JobManager:
215
  request = urllib.request.Request(
216
  record.callback_url,
217
  data=payload,
218
- headers={"Content-Type": "application/json", "User-Agent": "basyx-ffmpeg-callback/1.0"},
219
  method="POST",
220
  )
221
  try:
@@ -234,7 +234,7 @@ def _put_file(url: str, path: Path) -> None:
234
  headers = {
235
  "Content-Type": "video/mp4",
236
  "Content-Length": str(path.stat().st_size),
237
- "User-Agent": "basyx-ffmpeg-export/1.0",
238
  }
239
  connection.putrequest("PUT", target)
240
  for key, value in headers.items():
 
215
  request = urllib.request.Request(
216
  record.callback_url,
217
  data=payload,
218
+ headers={"Content-Type": "application/json", "User-Agent": "ava2lon-studio-callback/2.0"},
219
  method="POST",
220
  )
221
  try:
 
234
  headers = {
235
  "Content-Type": "video/mp4",
236
  "Content-Length": str(path.stat().st_size),
237
+ "User-Agent": "ava2lon-studio-export/2.0",
238
  }
239
  connection.putrequest("PUT", target)
240
  for key, value in headers.items():
renderer/platform/processor.py CHANGED
@@ -19,6 +19,7 @@ from renderer.templates import get_platform_profile
19
 
20
 
21
  TOOLKIT_TASKS = {
 
22
  "trim",
23
  "split",
24
  "concat",
@@ -28,8 +29,21 @@ TOOLKIT_TASKS = {
28
  "resize",
29
  "crop",
30
  "rotate",
 
 
 
 
31
  "speed",
 
 
 
 
32
  "reverse",
 
 
 
 
 
33
  "loop",
34
  "extract_audio",
35
  "thumbnail",
@@ -42,7 +56,16 @@ TOOLKIT_TASKS = {
42
  "convert",
43
  "merge_audio",
44
  "noise_reduction",
 
 
 
 
 
 
45
  "green_screen",
 
 
 
46
  }
47
 
48
 
@@ -238,7 +261,7 @@ class PlatformProcessor:
238
  cmd = FFmpegCommand().add("-hide_banner")
239
  if task == "loop":
240
  cmd.add("-stream_loop", int(params.get("loops", -1)))
241
- if task in {"trim", "gif"} and params.get("start") is not None:
242
  cmd.add("-ss", float(params.get("start", 0)))
243
  cmd.input(source)
244
 
@@ -263,7 +286,7 @@ class PlatformProcessor:
263
  return FFmpegCommand().add("-hide_banner", "-f", "concat", "-safe", "0").input(concat_file).add("-c", "copy").overwrite().add(output).build()
264
 
265
  duration = params.get("duration")
266
- if task in {"trim", "gif", "loop"} and duration is not None:
267
  cmd.add("-t", float(duration))
268
 
269
  vf = _video_filter(task, params)
@@ -279,7 +302,45 @@ class PlatformProcessor:
279
  return cmd.add("-vf", vf or "fps=1", "-q:v", 2).overwrite().add(workdir / "frame_%04d.jpg").build()
280
  if task == "gif":
281
  return cmd.add("-loop", 0).overwrite().add(output).build()
282
- if task in {"compress", "normalize", "resize", "crop", "rotate", "speed", "reverse", "loop", "overlay_text", "blur_background", "burn_subtitles", "convert", "noise_reduction", "green_screen"}:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  cmd.add("-c:v", "libx264", "-preset", self.settings.preset, "-crf", int(params.get("crf", self.settings.crf)), "-c:a", "aac")
284
  return cmd.overwrite().add(output).build()
285
 
@@ -444,17 +505,48 @@ def _video_filter(task: str, params: dict[str, Any]) -> str:
444
  if task in {"compress", "normalize"}:
445
  profile = get_platform_profile(params.get("platform"))
446
  return f"scale={profile.width}:{profile.height}:force_original_aspect_ratio=increase,crop={profile.width}:{profile.height},fps={profile.fps},format=yuv420p"
447
- if task == "resize":
448
  return f"scale={int(params.get('width', 1080))}:{int(params.get('height', 1920))}"
449
  if task == "crop":
450
  return f"crop={int(params.get('width', 1080))}:{int(params.get('height', 1080))}:{int(params.get('x', 0))}:{int(params.get('y', 0))}"
451
  if task == "rotate":
452
  return {"90": "transpose=1", "180": "hflip,vflip", "270": "transpose=2"}.get(str(params.get("degrees", "90")), "transpose=1")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  if task == "speed":
454
  factor = max(0.25, min(4.0, float(params.get("factor", 1.0))))
455
  return f"setpts={1 / factor:.4f}*PTS"
456
- if task == "reverse":
457
- return "reverse"
458
  if task == "gif":
459
  return f"fps={int(params.get('fps', 12))},scale={int(params.get('width', 540))}:-1:flags=lanczos"
460
  if task == "frames":
@@ -466,22 +558,27 @@ def _video_filter(task: str, params: dict[str, Any]) -> str:
466
  if task == "burn_subtitles":
467
  subtitles = str(params.get("subtitles") or "").replace("\\", "/").replace(":", r"\:")
468
  return f"subtitles='{subtitles}'"
469
- if task == "green_screen":
470
- color = str(params.get("color") or "0x00ff00")
471
- similarity = float(params.get("similarity", 0.18))
472
- blend = float(params.get("blend", 0.08))
473
- return f"chromakey={color}:{similarity}:{blend}"
474
  return ""
475
 
476
 
477
  def _audio_filter(task: str, params: dict[str, Any]) -> str:
478
- if task == "speed":
479
- factor = max(0.5, min(2.0, float(params.get("factor", 1.0))))
 
480
  return f"atempo={factor}"
481
- if task == "reverse":
482
  return "areverse"
483
- if task == "noise_reduction":
484
  return "afftdn=nf=-25"
 
 
 
 
 
 
 
 
 
485
  return ""
486
 
487
 
 
19
 
20
 
21
  TOOLKIT_TASKS = {
22
+ "cut",
23
  "trim",
24
  "split",
25
  "concat",
 
29
  "resize",
30
  "crop",
31
  "rotate",
32
+ "flip",
33
+ "scale",
34
+ "zoom",
35
+ "pan",
36
  "speed",
37
+ "speed_ramp",
38
+ "time_remap",
39
+ "slow_motion",
40
+ "fast_motion",
41
  "reverse",
42
+ "reverse_playback",
43
+ "freeze_frame",
44
+ "motion_blur",
45
+ "stabilization",
46
+ "lens_correction",
47
  "loop",
48
  "extract_audio",
49
  "thumbnail",
 
56
  "convert",
57
  "merge_audio",
58
  "noise_reduction",
59
+ "equalizer",
60
+ "compressor",
61
+ "limiter",
62
+ "pitch_shift",
63
+ "voice_changer",
64
+ "ai_enhancement",
65
  "green_screen",
66
+ "chroma_key",
67
+ "blue_screen",
68
+ "ai_background_removal",
69
  }
70
 
71
 
 
261
  cmd = FFmpegCommand().add("-hide_banner")
262
  if task == "loop":
263
  cmd.add("-stream_loop", int(params.get("loops", -1)))
264
+ if task in {"cut", "trim", "gif", "freeze_frame"} and params.get("start") is not None:
265
  cmd.add("-ss", float(params.get("start", 0)))
266
  cmd.input(source)
267
 
 
286
  return FFmpegCommand().add("-hide_banner", "-f", "concat", "-safe", "0").input(concat_file).add("-c", "copy").overwrite().add(output).build()
287
 
288
  duration = params.get("duration")
289
+ if task in {"cut", "trim", "gif", "loop", "freeze_frame"} and duration is not None:
290
  cmd.add("-t", float(duration))
291
 
292
  vf = _video_filter(task, params)
 
302
  return cmd.add("-vf", vf or "fps=1", "-q:v", 2).overwrite().add(workdir / "frame_%04d.jpg").build()
303
  if task == "gif":
304
  return cmd.add("-loop", 0).overwrite().add(output).build()
305
+ if task in {
306
+ "cut",
307
+ "trim",
308
+ "compress",
309
+ "normalize",
310
+ "resize",
311
+ "crop",
312
+ "rotate",
313
+ "flip",
314
+ "scale",
315
+ "zoom",
316
+ "pan",
317
+ "speed",
318
+ "speed_ramp",
319
+ "time_remap",
320
+ "slow_motion",
321
+ "fast_motion",
322
+ "reverse",
323
+ "reverse_playback",
324
+ "freeze_frame",
325
+ "motion_blur",
326
+ "stabilization",
327
+ "lens_correction",
328
+ "loop",
329
+ "overlay_text",
330
+ "blur_background",
331
+ "burn_subtitles",
332
+ "convert",
333
+ "noise_reduction",
334
+ "equalizer",
335
+ "compressor",
336
+ "limiter",
337
+ "pitch_shift",
338
+ "voice_changer",
339
+ "green_screen",
340
+ "chroma_key",
341
+ "blue_screen",
342
+ "ai_background_removal",
343
+ }:
344
  cmd.add("-c:v", "libx264", "-preset", self.settings.preset, "-crf", int(params.get("crf", self.settings.crf)), "-c:a", "aac")
345
  return cmd.overwrite().add(output).build()
346
 
 
505
  if task in {"compress", "normalize"}:
506
  profile = get_platform_profile(params.get("platform"))
507
  return f"scale={profile.width}:{profile.height}:force_original_aspect_ratio=increase,crop={profile.width}:{profile.height},fps={profile.fps},format=yuv420p"
508
+ if task in {"resize", "scale"}:
509
  return f"scale={int(params.get('width', 1080))}:{int(params.get('height', 1920))}"
510
  if task == "crop":
511
  return f"crop={int(params.get('width', 1080))}:{int(params.get('height', 1080))}:{int(params.get('x', 0))}:{int(params.get('y', 0))}"
512
  if task == "rotate":
513
  return {"90": "transpose=1", "180": "hflip,vflip", "270": "transpose=2"}.get(str(params.get("degrees", "90")), "transpose=1")
514
+ if task == "flip":
515
+ axis = str(params.get("axis", "horizontal"))
516
+ return "vflip" if axis in {"vertical", "y"} else "hflip"
517
+ if task in {"speed", "speed_ramp", "time_remap", "slow_motion", "fast_motion"}:
518
+ default = 0.5 if task == "slow_motion" else 2.0 if task == "fast_motion" else 1.0
519
+ factor = max(0.25, min(4.0, float(params.get("factor", default))))
520
+ return f"setpts={1 / factor:.4f}*PTS"
521
+ if task == "zoom":
522
+ factor = max(1.0, min(4.0, float(params.get("factor", 1.2))))
523
+ return f"scale=iw*{factor:.3f}:ih*{factor:.3f},crop=iw/{factor:.3f}:ih/{factor:.3f}"
524
+ if task == "pan":
525
+ width = int(params.get("width", 1080))
526
+ height = int(params.get("height", 1920))
527
+ x = str(params.get("x", "(iw-ow)/2"))
528
+ y = str(params.get("y", "(ih-oh)/2"))
529
+ return f"crop={width}:{height}:{x}:{y}"
530
+ if task in {"motion_blur", "freeze_frame"}:
531
+ return "tmix=frames=3:weights='1 2 1'"
532
+ if task == "stabilization":
533
+ return "deshake"
534
+ if task == "lens_correction":
535
+ return f"lenscorrection=k1={float(params.get('k1', -0.15))}:k2={float(params.get('k2', 0.05))}"
536
+ if task in {"reverse", "reverse_playback"}:
537
+ return "reverse"
538
+ if task in {"green_screen", "chroma_key", "ai_background_removal"}:
539
+ color = str(params.get("color") or "0x00ff00")
540
+ similarity = float(params.get("similarity", 0.18))
541
+ blend = float(params.get("blend", 0.08))
542
+ return f"chromakey={color}:{similarity}:{blend}"
543
+ if task == "blue_screen":
544
+ similarity = float(params.get("similarity", 0.18))
545
+ blend = float(params.get("blend", 0.08))
546
+ return f"chromakey=0x0000ff:{similarity}:{blend}"
547
  if task == "speed":
548
  factor = max(0.25, min(4.0, float(params.get("factor", 1.0))))
549
  return f"setpts={1 / factor:.4f}*PTS"
 
 
550
  if task == "gif":
551
  return f"fps={int(params.get('fps', 12))},scale={int(params.get('width', 540))}:-1:flags=lanczos"
552
  if task == "frames":
 
558
  if task == "burn_subtitles":
559
  subtitles = str(params.get("subtitles") or "").replace("\\", "/").replace(":", r"\:")
560
  return f"subtitles='{subtitles}'"
 
 
 
 
 
561
  return ""
562
 
563
 
564
  def _audio_filter(task: str, params: dict[str, Any]) -> str:
565
+ if task in {"speed", "speed_ramp", "time_remap", "slow_motion", "fast_motion"}:
566
+ default = 0.5 if task == "slow_motion" else 2.0 if task == "fast_motion" else 1.0
567
+ factor = max(0.5, min(2.0, float(params.get("factor", default))))
568
  return f"atempo={factor}"
569
+ if task in {"reverse", "reverse_playback"}:
570
  return "areverse"
571
+ if task in {"noise_reduction", "ai_enhancement"}:
572
  return "afftdn=nf=-25"
573
+ if task == "equalizer":
574
+ return f"equalizer=f={float(params.get('frequency', 1000))}:width_type=o:width={float(params.get('width', 1))}:g={float(params.get('gain', 3))}"
575
+ if task == "compressor":
576
+ return "acompressor=threshold=-18dB:ratio=3:attack=20:release=250"
577
+ if task == "limiter":
578
+ return "alimiter=limit=0.95"
579
+ if task in {"pitch_shift", "voice_changer"}:
580
+ factor = max(0.5, min(2.0, float(params.get("factor", 1.0))))
581
+ return f"asetrate=48000*{factor:.4f},aresample=48000,atempo={1 / factor:.4f}"
582
  return ""
583
 
584
 
renderer/studio/__init__.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from renderer.studio.capabilities import capability_catalog
2
+ from renderer.studio.projects import (
3
+ ProjectStore,
4
+ add_effect,
5
+ add_filter,
6
+ add_keyframe,
7
+ add_timeline_item,
8
+ add_transition,
9
+ apply_timeline_operation,
10
+ default_project,
11
+ normalize_project,
12
+ )
13
+ from renderer.studio.tasks import StudioTaskProcessor
14
+
15
+ __all__ = [
16
+ "ProjectStore",
17
+ "StudioTaskProcessor",
18
+ "add_effect",
19
+ "add_filter",
20
+ "add_keyframe",
21
+ "add_timeline_item",
22
+ "add_transition",
23
+ "apply_timeline_operation",
24
+ "capability_catalog",
25
+ "default_project",
26
+ "normalize_project",
27
+ ]
renderer/studio/capabilities.py ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from typing import Any
5
+
6
+
7
+ TIMELINE_TRACK_TYPES = [
8
+ "video",
9
+ "audio",
10
+ "text",
11
+ "overlay",
12
+ "sticker",
13
+ "subtitle",
14
+ ]
15
+
16
+ TIMELINE_OPERATIONS = [
17
+ "drag",
18
+ "split",
19
+ "trim",
20
+ "ripple_delete",
21
+ "insert",
22
+ "replace",
23
+ "group",
24
+ "lock",
25
+ "hide",
26
+ "duplicate",
27
+ ]
28
+
29
+ VIDEO_EDITING_OPERATIONS = [
30
+ "cut",
31
+ "split",
32
+ "trim",
33
+ "merge",
34
+ "concat",
35
+ "reverse",
36
+ "freeze_frame",
37
+ "speed",
38
+ "speed_ramp",
39
+ "time_remap",
40
+ "slow_motion",
41
+ "fast_motion",
42
+ "reverse_playback",
43
+ "crop",
44
+ "rotate",
45
+ "flip",
46
+ "resize",
47
+ "scale",
48
+ "zoom",
49
+ "pan",
50
+ "motion_blur",
51
+ "stabilization",
52
+ "lens_correction",
53
+ "compress",
54
+ "normalize",
55
+ "loop",
56
+ "gif",
57
+ "frames",
58
+ "watermark",
59
+ "overlay_text",
60
+ "burn_subtitles",
61
+ "convert",
62
+ ]
63
+
64
+ AI_EDITING_FEATURES = [
65
+ "auto_edit",
66
+ "auto_highlight_detection",
67
+ "auto_scene_detection",
68
+ "auto_reframe",
69
+ "auto_crop",
70
+ "auto_remove_silence",
71
+ "auto_beat_sync",
72
+ "auto_color_match",
73
+ "auto_motion_tracking",
74
+ "auto_subtitle_generation",
75
+ "auto_hook_detection",
76
+ "auto_thumbnail_selection",
77
+ "auto_music_selection",
78
+ "auto_b_roll_placement",
79
+ "auto_caption_animation",
80
+ "auto_viral_score",
81
+ "auto_platform_optimization",
82
+ ]
83
+
84
+ KEYFRAME_PROPERTIES = [
85
+ "position",
86
+ "scale",
87
+ "rotation",
88
+ "opacity",
89
+ "blur",
90
+ "brightness",
91
+ "contrast",
92
+ "saturation",
93
+ "hue",
94
+ "volume",
95
+ "playback_speed",
96
+ "mask",
97
+ "shadow",
98
+ "glow",
99
+ "text_animation",
100
+ ]
101
+
102
+ TRANSITION_FAMILIES = [
103
+ "basic",
104
+ "fade",
105
+ "dissolve",
106
+ "slide",
107
+ "push",
108
+ "zoom",
109
+ "spin",
110
+ "blur",
111
+ "whip",
112
+ "flash",
113
+ "glitch",
114
+ "light_leak",
115
+ "film_burn",
116
+ "camera_shake",
117
+ "3d_flip",
118
+ "cube",
119
+ "ripple",
120
+ "ink",
121
+ "morph",
122
+ "stretch",
123
+ "liquid",
124
+ "elastic",
125
+ "motion_blur",
126
+ ]
127
+
128
+ VIDEO_EFFECTS = [
129
+ "glitch",
130
+ "rgb_split",
131
+ "shake",
132
+ "crt",
133
+ "vhs",
134
+ "noise",
135
+ "film_grain",
136
+ "bloom",
137
+ "glow",
138
+ "chromatic_aberration",
139
+ "lens_flare",
140
+ "dream",
141
+ "neon",
142
+ "cyberpunk",
143
+ "rain",
144
+ "snow",
145
+ "fog",
146
+ "lightning",
147
+ "fire",
148
+ "smoke",
149
+ "particle_system",
150
+ "spark",
151
+ "magic",
152
+ "comic",
153
+ "cartoon",
154
+ "anime",
155
+ "sketch",
156
+ "oil_painting",
157
+ "pixel_art",
158
+ "sharp_pop",
159
+ "clean_beauty",
160
+ "warm_glow",
161
+ "cinematic",
162
+ "dreamy",
163
+ "flash_pop",
164
+ "motion_blur",
165
+ "noir",
166
+ ]
167
+
168
+ FILTER_FORMATS = [".cube", ".3dl", ".csp"]
169
+
170
+ FILTER_PRESETS = [
171
+ "cinema",
172
+ "vintage",
173
+ "warm",
174
+ "cold",
175
+ "black_and_white",
176
+ "hdr",
177
+ "instagram",
178
+ "tiktok",
179
+ "moody",
180
+ "travel",
181
+ "nature",
182
+ "food",
183
+ "portrait",
184
+ "luxury",
185
+ "night",
186
+ ]
187
+
188
+ TEXT_FEATURES = [
189
+ "rich_text",
190
+ "curved_text",
191
+ "vertical_text",
192
+ "gradient_text",
193
+ "outline",
194
+ "shadow",
195
+ "glow",
196
+ "stroke",
197
+ "letter_spacing",
198
+ "word_spacing",
199
+ "animation_presets",
200
+ "typing_animation",
201
+ "bounce",
202
+ "wave",
203
+ "zoom",
204
+ "pop",
205
+ "fade",
206
+ "roll",
207
+ "tracking",
208
+ ]
209
+
210
+ CAPTION_FEATURES = [
211
+ "whisper_transcription",
212
+ "word_timestamps",
213
+ "sentence_timestamps",
214
+ "emoji_insertion",
215
+ "speaker_detection",
216
+ "karaoke_captions",
217
+ "tiktok_captions",
218
+ "capcut_captions",
219
+ "animated_captions",
220
+ "subtitle_templates",
221
+ ]
222
+
223
+ STICKER_PACKS = [
224
+ "png",
225
+ "svg",
226
+ "gif",
227
+ "animated_stickers",
228
+ "emoji_packs",
229
+ "reaction_packs",
230
+ "social_media_packs",
231
+ "call_to_action_packs",
232
+ ]
233
+
234
+ SHAPES = [
235
+ "rectangle",
236
+ "circle",
237
+ "triangle",
238
+ "arrow",
239
+ "line",
240
+ "polygon",
241
+ "speech_bubble",
242
+ "custom_svg",
243
+ ]
244
+
245
+ MASK_TYPES = [
246
+ "rectangle",
247
+ "circle",
248
+ "linear",
249
+ "radial",
250
+ "freehand",
251
+ "bezier",
252
+ "ai_subject_mask",
253
+ "ai_sky_mask",
254
+ "ai_person_mask",
255
+ ]
256
+
257
+ CHROMA_KEY_FEATURES = [
258
+ "green_screen",
259
+ "blue_screen",
260
+ "ai_background_removal",
261
+ "edge_feathering",
262
+ "spill_suppression",
263
+ "shadow_preservation",
264
+ ]
265
+
266
+ AUDIO_TOOLS = [
267
+ "music",
268
+ "voiceover",
269
+ "noise_reduction",
270
+ "equalizer",
271
+ "compressor",
272
+ "limiter",
273
+ "pitch_shift",
274
+ "voice_changer",
275
+ "fade",
276
+ "ducking",
277
+ "normalization",
278
+ "ai_enhancement",
279
+ "beat_detection",
280
+ "beat_markers",
281
+ ]
282
+
283
+ MUSIC_PROVIDERS = ["musicgen", "suno_api", "stable_audio"]
284
+ MUSIC_STYLES = ["background_music", "lo_fi", "cinematic", "nasheed", "hip_hop", "corporate", "podcast", "meditation"]
285
+
286
+ VOICE_PROVIDERS = ["kokoro", "xtts", "piper", "openvoice"]
287
+ VOICE_FEATURES = ["voice_cloning", "multi_speaker", "emotion", "speed", "pitch", "style_transfer"]
288
+
289
+ IMAGE_GENERATION_PROVIDERS = ["flux", "sdxl", "controlnet"]
290
+ IMAGE_GENERATION_FEATURES = ["image_editing", "background_replacement", "object_removal", "upscaling"]
291
+
292
+ VIDEO_GENERATION_PROVIDERS = ["wan", "ltx_video", "hunyuan_video", "veo_api"]
293
+ VIDEO_GENERATION_FEATURES = ["animate_images", "image_to_video", "text_to_video"]
294
+
295
+ AI_ASSISTANTS = [
296
+ "script_writer",
297
+ "hook_generator",
298
+ "title_generator",
299
+ "description_generator",
300
+ "hashtag_generator",
301
+ "seo_optimizer",
302
+ "thumbnail_prompt_generator",
303
+ "b_roll_planner",
304
+ "storyboard_generator",
305
+ ]
306
+
307
+ TEMPLATE_CATEGORIES = [
308
+ "youtube_shorts",
309
+ "tiktok",
310
+ "instagram_reels",
311
+ "facebook_reels",
312
+ "motivational",
313
+ "podcasts",
314
+ "gaming",
315
+ "news",
316
+ "luxury",
317
+ "business",
318
+ "education",
319
+ "finance",
320
+ "relationship",
321
+ "wedding",
322
+ "birthday",
323
+ "travel",
324
+ "cooking",
325
+ "fitness",
326
+ "anime",
327
+ "sports",
328
+ "product_ads",
329
+ "real_estate",
330
+ "e_commerce",
331
+ "faceless_channels",
332
+ "quote_videos",
333
+ "audiograms",
334
+ "story_videos",
335
+ "before_and_after",
336
+ "reaction_videos",
337
+ "countdown_videos",
338
+ ]
339
+
340
+ EXPORT_FORMATS = ["mp4", "mov", "avi", "mkv", "gif", "webm", "png_sequence", "jpeg_sequence", "audio_only"]
341
+
342
+ EXPORT_PRESETS = [
343
+ "1080p",
344
+ "2k",
345
+ "4k",
346
+ "8k",
347
+ "tiktok",
348
+ "youtube",
349
+ "instagram",
350
+ "facebook",
351
+ "twitter",
352
+ "linkedin",
353
+ ]
354
+
355
+ API_ENDPOINTS = {
356
+ "upload": "POST /upload",
357
+ "project_create": "POST /project/create",
358
+ "project_save": "POST /project/save",
359
+ "timeline_add": "POST /timeline/add",
360
+ "timeline_operation": "POST /timeline/operation",
361
+ "effect_apply": "POST /effect/apply",
362
+ "filter_apply": "POST /filter/apply",
363
+ "transition_add": "POST /transition/add",
364
+ "caption_generate": "POST /caption/generate",
365
+ "music_generate": "POST /music/generate",
366
+ "voice_generate": "POST /voice/generate",
367
+ "image_generate": "POST /image/generate",
368
+ "video_generate": "POST /video/generate",
369
+ "thumbnail_create": "POST /thumbnail/create",
370
+ "render": "POST /render",
371
+ "status": "GET /status/{job_id}",
372
+ "download": "GET /download/{job_id}",
373
+ "publish": "POST /publish",
374
+ }
375
+
376
+
377
+ def capability_catalog() -> dict[str, Any]:
378
+ return deepcopy(
379
+ {
380
+ "product": "Ava2lon Studio AI",
381
+ "principles": {
382
+ "cpu_first": True,
383
+ "optional_gpu": True,
384
+ "api_parity": True,
385
+ "async_long_running_tasks": True,
386
+ "status_polling": True,
387
+ "webhooks": True,
388
+ "plugin_support": True,
389
+ "template_driven": True,
390
+ "non_destructive_projects": True,
391
+ "multi_platform_export": True,
392
+ },
393
+ "timeline": {
394
+ "track_types": TIMELINE_TRACK_TYPES,
395
+ "operations": TIMELINE_OPERATIONS,
396
+ "unlimited_tracks": True,
397
+ },
398
+ "editing": VIDEO_EDITING_OPERATIONS,
399
+ "ai_editing": AI_EDITING_FEATURES,
400
+ "keyframes": KEYFRAME_PROPERTIES,
401
+ "transitions": TRANSITION_FAMILIES,
402
+ "effects": VIDEO_EFFECTS,
403
+ "filters": {"formats": FILTER_FORMATS, "presets": FILTER_PRESETS},
404
+ "text": TEXT_FEATURES,
405
+ "captions": CAPTION_FEATURES,
406
+ "stickers": STICKER_PACKS,
407
+ "shapes": SHAPES,
408
+ "masks": MASK_TYPES,
409
+ "chroma_key": CHROMA_KEY_FEATURES,
410
+ "audio": AUDIO_TOOLS,
411
+ "music_generator": {"providers": MUSIC_PROVIDERS, "styles": MUSIC_STYLES},
412
+ "voice_generator": {"providers": VOICE_PROVIDERS, "features": VOICE_FEATURES},
413
+ "image_generation": {"providers": IMAGE_GENERATION_PROVIDERS, "features": IMAGE_GENERATION_FEATURES},
414
+ "video_generation": {"providers": VIDEO_GENERATION_PROVIDERS, "features": VIDEO_GENERATION_FEATURES},
415
+ "assistants": AI_ASSISTANTS,
416
+ "templates": TEMPLATE_CATEGORIES,
417
+ "exports": {"formats": EXPORT_FORMATS, "presets": EXPORT_PRESETS},
418
+ "api_endpoints": API_ENDPOINTS,
419
+ }
420
+ )
421
+
422
+
423
+ def is_timeline_operation(operation: str) -> bool:
424
+ return operation in TIMELINE_OPERATIONS
425
+
426
+
427
+ def is_track_type(track_type: str) -> bool:
428
+ return track_type in TIMELINE_TRACK_TYPES
renderer/studio/projects.py ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from renderer.core.config import Settings
8
+ from renderer.core.utils import new_id, now, read_json, safe_filename, write_json
9
+ from renderer.studio.capabilities import TIMELINE_TRACK_TYPES, is_timeline_operation, is_track_type
10
+
11
+
12
+ DEFAULT_EXPORT_SETTINGS: dict[str, Any] = {
13
+ "format": "mp4",
14
+ "preset": "tiktok",
15
+ "platform": "tiktok",
16
+ "resolution": "1080p",
17
+ "fps": 30,
18
+ "codec": "h264",
19
+ "audio_codec": "aac",
20
+ }
21
+
22
+
23
+ def default_project(name: str, metadata: dict[str, Any] | None = None, project_id: str | None = None) -> dict[str, Any]:
24
+ created = now()
25
+ project_id = project_id or new_id("project")
26
+ return {
27
+ "id": project_id,
28
+ "name": name,
29
+ "slug": safe_filename(name),
30
+ "version": 1,
31
+ "schema": "ava2lon.project.v1",
32
+ "created_at": created,
33
+ "updated_at": created,
34
+ "metadata": metadata or {},
35
+ "timeline": {
36
+ "duration": 0.0,
37
+ "fps": 30,
38
+ "tracks": {track_type: [] for track_type in TIMELINE_TRACK_TYPES},
39
+ "groups": [],
40
+ "markers": [],
41
+ },
42
+ "assets": [],
43
+ "audio_tracks": [],
44
+ "video_tracks": [],
45
+ "text_layers": [],
46
+ "sticker_layers": [],
47
+ "effects": [],
48
+ "filters": [],
49
+ "keyframes": [],
50
+ "captions": [],
51
+ "templates": [],
52
+ "export_settings": deepcopy(DEFAULT_EXPORT_SETTINGS),
53
+ "plugins": [],
54
+ "automation": {"webhooks": [], "batch": {}, "n8n": {"compatible": True}},
55
+ }
56
+
57
+
58
+ class ProjectStore:
59
+ def __init__(self, settings: Settings | None = None) -> None:
60
+ self.settings = settings or Settings()
61
+ self.settings.ensure_dirs()
62
+ self.root = self.settings.storage_dir / "projects"
63
+ self.root.mkdir(parents=True, exist_ok=True)
64
+
65
+ def list(self) -> list[dict[str, Any]]:
66
+ projects: list[dict[str, Any]] = []
67
+ for manifest in sorted(self.root.glob("*/project.json")):
68
+ try:
69
+ data = normalize_project(read_json(manifest, {}))
70
+ projects.append(_summary(data, manifest.parent))
71
+ except Exception:
72
+ continue
73
+ return projects
74
+
75
+ def create(self, name: str, metadata: dict[str, Any] | None = None, template: dict[str, Any] | None = None) -> dict[str, Any]:
76
+ project = normalize_project(template or default_project(name, metadata))
77
+ project["name"] = name
78
+ project["metadata"] = metadata or project.get("metadata", {})
79
+ if not project.get("id"):
80
+ project["id"] = new_id("project")
81
+ project["slug"] = safe_filename(str(project.get("slug") or name or project["id"]))
82
+ project["created_at"] = project.get("created_at") or now()
83
+ project["updated_at"] = now()
84
+ self.save(project["id"], project)
85
+ return project
86
+
87
+ def get(self, project_id: str) -> dict[str, Any]:
88
+ path = self._path(project_id)
89
+ data = read_json(path, None)
90
+ if data is None:
91
+ raise KeyError(project_id)
92
+ return normalize_project(data)
93
+
94
+ def save(self, project_id: str, project: dict[str, Any]) -> dict[str, Any]:
95
+ normalized = normalize_project(project)
96
+ normalized["id"] = project_id or normalized.get("id") or new_id("project")
97
+ normalized["slug"] = safe_filename(str(normalized.get("slug") or normalized.get("name") or normalized["id"]))
98
+ normalized["updated_at"] = now()
99
+ write_json(self._path(normalized["id"]), normalized)
100
+ return normalized
101
+
102
+ def delete(self, project_id: str) -> None:
103
+ path = self._path(project_id)
104
+ if not path.exists():
105
+ raise KeyError(project_id)
106
+ directory = path.parent
107
+ for child in directory.glob("*"):
108
+ if child.is_file():
109
+ child.unlink()
110
+ try:
111
+ directory.rmdir()
112
+ except OSError:
113
+ pass
114
+
115
+ def add_asset(self, project_id: str, asset: dict[str, Any]) -> dict[str, Any]:
116
+ project = self.get(project_id)
117
+ asset = deepcopy(asset)
118
+ asset.setdefault("id", new_id("asset"))
119
+ asset.setdefault("created_at", now())
120
+ project["assets"].append(asset)
121
+ return self.save(project_id, project)
122
+
123
+ def add_to_timeline(self, project_id: str, item: dict[str, Any], track_type: str = "video", track_id: str | None = None) -> dict[str, Any]:
124
+ project = self.get(project_id)
125
+ add_timeline_item(project, item, track_type=track_type, track_id=track_id)
126
+ return self.save(project_id, project)
127
+
128
+ def timeline_operation(self, project_id: str, operation: str, item_id: str | None = None, params: dict[str, Any] | None = None) -> dict[str, Any]:
129
+ project = self.get(project_id)
130
+ apply_timeline_operation(project, operation, item_id=item_id, params=params or {})
131
+ return self.save(project_id, project)
132
+
133
+ def _path(self, project_id: str) -> Path:
134
+ project_id = safe_filename(project_id)
135
+ return self.root / project_id / "project.json"
136
+
137
+
138
+ def normalize_project(project: dict[str, Any]) -> dict[str, Any]:
139
+ normalized = deepcopy(project or {})
140
+ normalized.setdefault("id", new_id("project"))
141
+ normalized.setdefault("name", "Untitled Project")
142
+ normalized.setdefault("slug", safe_filename(str(normalized["name"])))
143
+ normalized.setdefault("version", 1)
144
+ normalized.setdefault("schema", "ava2lon.project.v1")
145
+ normalized.setdefault("created_at", now())
146
+ normalized.setdefault("updated_at", now())
147
+ normalized.setdefault("metadata", {})
148
+ normalized.setdefault("timeline", {})
149
+ timeline = normalized["timeline"]
150
+ timeline.setdefault("duration", 0.0)
151
+ timeline.setdefault("fps", 30)
152
+ timeline.setdefault("tracks", {})
153
+ for track_type in TIMELINE_TRACK_TYPES:
154
+ timeline["tracks"].setdefault(track_type, [])
155
+ timeline.setdefault("groups", [])
156
+ timeline.setdefault("markers", [])
157
+ for key in (
158
+ "assets",
159
+ "audio_tracks",
160
+ "video_tracks",
161
+ "text_layers",
162
+ "sticker_layers",
163
+ "effects",
164
+ "filters",
165
+ "keyframes",
166
+ "captions",
167
+ "templates",
168
+ "plugins",
169
+ ):
170
+ normalized.setdefault(key, [])
171
+ normalized.setdefault("export_settings", deepcopy(DEFAULT_EXPORT_SETTINGS))
172
+ normalized.setdefault("automation", {"webhooks": [], "batch": {}, "n8n": {"compatible": True}})
173
+ _recalculate_duration(normalized)
174
+ return normalized
175
+
176
+
177
+ def add_timeline_item(project: dict[str, Any], item: dict[str, Any], *, track_type: str = "video", track_id: str | None = None) -> dict[str, Any]:
178
+ if not is_track_type(track_type):
179
+ raise ValueError(f"Unsupported track type: {track_type}")
180
+ normalized = normalize_project(project)
181
+ item = deepcopy(item)
182
+ item.setdefault("id", new_id("clip"))
183
+ item.setdefault("type", track_type)
184
+ item.setdefault("start", 0.0)
185
+ item.setdefault("duration", max(float(item.get("end", 0.0)) - float(item.get("start", 0.0)), 0.0) or 1.0)
186
+ item.setdefault("source_start", 0.0)
187
+ item.setdefault("locked", False)
188
+ item.setdefault("hidden", False)
189
+ item.setdefault("keyframes", [])
190
+ item.setdefault("effects", [])
191
+ item.setdefault("filters", [])
192
+ item.setdefault("metadata", {})
193
+ track = _ensure_track(normalized, track_type, track_id)
194
+ track["items"].append(item)
195
+ track["items"].sort(key=lambda entry: float(entry.get("start", 0.0)))
196
+ project.clear()
197
+ project.update(normalized)
198
+ _mirror_layers(project, track_type, item)
199
+ _recalculate_duration(project)
200
+ return item
201
+
202
+
203
+ def apply_timeline_operation(project: dict[str, Any], operation: str, *, item_id: str | None = None, params: dict[str, Any] | None = None) -> dict[str, Any]:
204
+ if not is_timeline_operation(operation):
205
+ raise ValueError(f"Unsupported timeline operation: {operation}")
206
+ params = params or {}
207
+ normalized = normalize_project(project)
208
+
209
+ if operation == "insert":
210
+ add_timeline_item(
211
+ normalized,
212
+ params.get("item", {}),
213
+ track_type=str(params.get("track_type", "video")),
214
+ track_id=params.get("track_id"),
215
+ )
216
+ elif operation == "group":
217
+ group_id = str(params.get("group_id") or new_id("group"))
218
+ item_ids = [str(value) for value in params.get("item_ids", [])]
219
+ normalized["timeline"]["groups"].append({"id": group_id, "item_ids": item_ids, "metadata": params.get("metadata", {})})
220
+ for grouped_id in item_ids:
221
+ try:
222
+ grouped_item, _ = _find_item(normalized, grouped_id)
223
+ grouped_item["group_id"] = group_id
224
+ except KeyError:
225
+ continue
226
+ else:
227
+ if not item_id:
228
+ raise ValueError(f"{operation} requires item_id")
229
+ item, track = _find_item(normalized, item_id)
230
+ if operation == "drag":
231
+ item["start"] = max(0.0, float(params.get("start", item.get("start", 0.0))))
232
+ elif operation == "trim":
233
+ if "start" in params:
234
+ item["start"] = max(0.0, float(params["start"]))
235
+ if "duration" in params:
236
+ item["duration"] = max(0.001, float(params["duration"]))
237
+ if "source_start" in params:
238
+ item["source_start"] = max(0.0, float(params["source_start"]))
239
+ elif operation == "split":
240
+ offset = float(params.get("offset", 0.0))
241
+ duration = float(item.get("duration", 0.0))
242
+ if offset <= 0 or offset >= duration:
243
+ raise ValueError("split offset must be inside the item duration")
244
+ new_item = deepcopy(item)
245
+ new_item["id"] = str(params.get("new_item_id") or new_id("clip"))
246
+ new_item["start"] = float(item.get("start", 0.0)) + offset
247
+ new_item["duration"] = duration - offset
248
+ new_item["source_start"] = float(item.get("source_start", 0.0)) + offset
249
+ item["duration"] = offset
250
+ track["items"].append(new_item)
251
+ track["items"].sort(key=lambda entry: float(entry.get("start", 0.0)))
252
+ elif operation == "ripple_delete":
253
+ start = float(item.get("start", 0.0))
254
+ duration = float(item.get("duration", 0.0))
255
+ track["items"] = [entry for entry in track["items"] if entry.get("id") != item_id]
256
+ for entry in track["items"]:
257
+ if float(entry.get("start", 0.0)) > start:
258
+ entry["start"] = max(start, float(entry.get("start", 0.0)) - duration)
259
+ elif operation == "replace":
260
+ replacement = deepcopy(params.get("item", {}))
261
+ replacement.setdefault("id", item_id)
262
+ replacement.setdefault("start", item.get("start", 0.0))
263
+ replacement.setdefault("duration", item.get("duration", 1.0))
264
+ replacement.setdefault("type", item.get("type", track.get("type")))
265
+ index = track["items"].index(item)
266
+ track["items"][index] = replacement
267
+ elif operation == "lock":
268
+ item["locked"] = bool(params.get("locked", True))
269
+ elif operation == "hide":
270
+ item["hidden"] = bool(params.get("hidden", True))
271
+ elif operation == "duplicate":
272
+ duplicate = deepcopy(item)
273
+ duplicate["id"] = str(params.get("new_item_id") or new_id("clip"))
274
+ duplicate["start"] = float(params.get("start", float(item.get("start", 0.0)) + float(item.get("duration", 1.0))))
275
+ track["items"].append(duplicate)
276
+ track["items"].sort(key=lambda entry: float(entry.get("start", 0.0)))
277
+
278
+ project.clear()
279
+ project.update(normalized)
280
+ _recalculate_duration(project)
281
+ return project
282
+
283
+
284
+ def add_effect(project: dict[str, Any], target_id: str, effect: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
285
+ effect_record = {"id": new_id("effect"), "target_id": target_id, "effect": effect, "params": params or {}, "created_at": now()}
286
+ project.setdefault("effects", []).append(effect_record)
287
+ try:
288
+ item, _ = _find_item(project, target_id)
289
+ item.setdefault("effects", []).append(effect_record)
290
+ except KeyError:
291
+ pass
292
+ return effect_record
293
+
294
+
295
+ def add_filter(project: dict[str, Any], target_id: str, filter_name: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
296
+ filter_record = {"id": new_id("filter"), "target_id": target_id, "filter": filter_name, "params": params or {}, "created_at": now()}
297
+ project.setdefault("filters", []).append(filter_record)
298
+ try:
299
+ item, _ = _find_item(project, target_id)
300
+ item.setdefault("filters", []).append(filter_record)
301
+ except KeyError:
302
+ pass
303
+ return filter_record
304
+
305
+
306
+ def add_transition(project: dict[str, Any], from_item_id: str, to_item_id: str, transition: str, duration: float = 0.45) -> dict[str, Any]:
307
+ record = {
308
+ "id": new_id("transition"),
309
+ "from_item_id": from_item_id,
310
+ "to_item_id": to_item_id,
311
+ "transition": transition,
312
+ "duration": duration,
313
+ "created_at": now(),
314
+ }
315
+ project.setdefault("timeline", {}).setdefault("transitions", []).append(record)
316
+ return record
317
+
318
+
319
+ def add_keyframe(
320
+ project: dict[str, Any],
321
+ target_id: str,
322
+ property_name: str,
323
+ time: float,
324
+ value: Any,
325
+ easing: str = "linear",
326
+ ) -> dict[str, Any]:
327
+ record = {
328
+ "id": new_id("keyframe"),
329
+ "target_id": target_id,
330
+ "property": property_name,
331
+ "time": max(0.0, float(time)),
332
+ "value": value,
333
+ "easing": easing,
334
+ }
335
+ project.setdefault("keyframes", []).append(record)
336
+ try:
337
+ item, _ = _find_item(project, target_id)
338
+ item.setdefault("keyframes", []).append(record)
339
+ except KeyError:
340
+ pass
341
+ return record
342
+
343
+
344
+ def _ensure_track(project: dict[str, Any], track_type: str, track_id: str | None = None) -> dict[str, Any]:
345
+ tracks = project["timeline"]["tracks"].setdefault(track_type, [])
346
+ if track_id:
347
+ for track in tracks:
348
+ if track.get("id") == track_id:
349
+ return track
350
+ if not tracks:
351
+ track_id = track_id or f"{track_type}_1"
352
+ else:
353
+ track_id = track_id or f"{track_type}_{len(tracks) + 1}"
354
+ track = {"id": track_id, "type": track_type, "name": f"{track_type.title()} {len(tracks) + 1}", "locked": False, "hidden": False, "items": []}
355
+ tracks.append(track)
356
+ return track
357
+
358
+
359
+ def _find_item(project: dict[str, Any], item_id: str) -> tuple[dict[str, Any], dict[str, Any]]:
360
+ for tracks in project.get("timeline", {}).get("tracks", {}).values():
361
+ for track in tracks:
362
+ for item in track.get("items", []):
363
+ if item.get("id") == item_id:
364
+ return item, track
365
+ raise KeyError(item_id)
366
+
367
+
368
+ def _mirror_layers(project: dict[str, Any], track_type: str, item: dict[str, Any]) -> None:
369
+ mirror_key = {
370
+ "video": "video_tracks",
371
+ "audio": "audio_tracks",
372
+ "text": "text_layers",
373
+ "sticker": "sticker_layers",
374
+ "subtitle": "captions",
375
+ }.get(track_type)
376
+ if mirror_key:
377
+ project.setdefault(mirror_key, []).append({"item_id": item["id"], **deepcopy(item)})
378
+
379
+
380
+ def _recalculate_duration(project: dict[str, Any]) -> None:
381
+ duration = 0.0
382
+ for tracks in project.get("timeline", {}).get("tracks", {}).values():
383
+ for track in tracks:
384
+ for item in track.get("items", []):
385
+ duration = max(duration, float(item.get("start", 0.0)) + float(item.get("duration", 0.0)))
386
+ project.setdefault("timeline", {})["duration"] = round(duration, 3)
387
+
388
+
389
+ def _summary(project: dict[str, Any], directory: Path) -> dict[str, Any]:
390
+ return {
391
+ "id": project.get("id"),
392
+ "name": project.get("name"),
393
+ "slug": project.get("slug"),
394
+ "path": str(directory),
395
+ "updated_at": project.get("updated_at"),
396
+ "duration": project.get("timeline", {}).get("duration", 0.0),
397
+ "asset_count": len(project.get("assets", [])),
398
+ "metadata": project.get("metadata", {}),
399
+ }
renderer/studio/tasks.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from renderer.core.config import Settings
7
+ from renderer.core.models import TaskResult
8
+ from renderer.core.utils import now, safe_filename, write_json
9
+ from renderer.studio.capabilities import (
10
+ AI_ASSISTANTS,
11
+ AI_EDITING_FEATURES,
12
+ IMAGE_GENERATION_PROVIDERS,
13
+ MUSIC_PROVIDERS,
14
+ VIDEO_GENERATION_PROVIDERS,
15
+ VOICE_PROVIDERS,
16
+ )
17
+
18
+
19
+ class StudioTaskProcessor:
20
+ """Manifest-producing async handlers for optional AI providers and studio automation."""
21
+
22
+ def __init__(self, settings: Settings | None = None, log=None) -> None:
23
+ self.settings = settings or Settings()
24
+ self.settings.ensure_dirs()
25
+ self._logs: list[str] = []
26
+ self._log = log
27
+
28
+ def caption_generate(self, payload: dict[str, Any], job_id: str) -> TaskResult:
29
+ text = str(payload.get("text") or payload.get("transcript") or "")
30
+ captions = payload.get("events") if isinstance(payload.get("events"), list) else _captions_from_text(text)
31
+ manifest = {
32
+ "type": "caption_generation",
33
+ "status": "ready",
34
+ "engine": payload.get("engine", "whisper"),
35
+ "media": payload.get("media") or payload.get("audio"),
36
+ "template": payload.get("template", "capcut"),
37
+ "language": payload.get("language"),
38
+ "features": {
39
+ "word_timestamps": bool(payload.get("word_timestamps", True)),
40
+ "sentence_timestamps": True,
41
+ "emoji_insertion": bool(payload.get("emoji_insertion", False)),
42
+ "speaker_detection": bool(payload.get("speaker_detection", False)),
43
+ "karaoke": bool(payload.get("karaoke", True)),
44
+ "animated": bool(payload.get("animated", True)),
45
+ },
46
+ "captions": captions,
47
+ }
48
+ output = self._json_artifact(job_id, "captions", manifest)
49
+ return self._result(output, {"task": "caption_generate", "caption_count": len(captions)})
50
+
51
+ def music_generate(self, payload: dict[str, Any], job_id: str) -> TaskResult:
52
+ provider = _provider(payload.get("provider"), MUSIC_PROVIDERS, "musicgen")
53
+ prompt = str(payload.get("prompt") or payload.get("style") or "background music")
54
+ duration = float(payload.get("duration", 30))
55
+ manifest = {
56
+ "type": "music_generation",
57
+ "status": "provider_required",
58
+ "provider": provider,
59
+ "prompt": prompt,
60
+ "style": payload.get("style", "background_music"),
61
+ "duration": duration,
62
+ "bpm": payload.get("bpm"),
63
+ "license": payload.get("license", "user_configured"),
64
+ "next_step": "Configure provider credentials or connect this manifest to a local MusicGen runner.",
65
+ }
66
+ output = self._json_artifact(job_id, "music_request", manifest)
67
+ return self._result(output, {"task": "music_generate", "provider": provider, "duration": duration})
68
+
69
+ def voice_generate(self, payload: dict[str, Any], job_id: str) -> TaskResult:
70
+ provider = _provider(payload.get("provider"), VOICE_PROVIDERS, "kokoro")
71
+ text = str(payload.get("text") or "")
72
+ manifest = {
73
+ "type": "voice_generation",
74
+ "status": "provider_required",
75
+ "provider": provider,
76
+ "text": text,
77
+ "voice": payload.get("voice", "default"),
78
+ "emotion": payload.get("emotion"),
79
+ "speed": float(payload.get("speed", 1.0)),
80
+ "pitch": float(payload.get("pitch", 1.0)),
81
+ "clone_reference": payload.get("clone_reference"),
82
+ "multi_speaker": payload.get("speakers", []),
83
+ "next_step": "Configure the selected TTS backend to render audio for this manifest.",
84
+ }
85
+ output = self._json_artifact(job_id, "voice_request", manifest)
86
+ return self._result(output, {"task": "voice_generate", "provider": provider, "characters": len(text)})
87
+
88
+ def image_generate(self, payload: dict[str, Any], job_id: str) -> TaskResult:
89
+ provider = _provider(payload.get("provider"), IMAGE_GENERATION_PROVIDERS, "flux")
90
+ manifest = {
91
+ "type": "image_generation",
92
+ "status": "provider_required",
93
+ "provider": provider,
94
+ "prompt": payload.get("prompt", ""),
95
+ "negative_prompt": payload.get("negative_prompt", ""),
96
+ "mode": payload.get("mode", "text_to_image"),
97
+ "control_image": payload.get("control_image"),
98
+ "source_image": payload.get("source_image"),
99
+ "size": payload.get("size", "1024x1024"),
100
+ "features": {
101
+ "background_replacement": bool(payload.get("background_replacement", False)),
102
+ "object_removal": bool(payload.get("object_removal", False)),
103
+ "upscaling": bool(payload.get("upscaling", False)),
104
+ },
105
+ }
106
+ output = self._json_artifact(job_id, "image_request", manifest)
107
+ return self._result(output, {"task": "image_generate", "provider": provider})
108
+
109
+ def video_generate(self, payload: dict[str, Any], job_id: str) -> TaskResult:
110
+ provider = _provider(payload.get("provider"), VIDEO_GENERATION_PROVIDERS, "ltx_video")
111
+ manifest = {
112
+ "type": "video_generation",
113
+ "status": "provider_required",
114
+ "provider": provider,
115
+ "prompt": payload.get("prompt", ""),
116
+ "mode": payload.get("mode", "text_to_video"),
117
+ "image": payload.get("image"),
118
+ "duration": float(payload.get("duration", 5)),
119
+ "fps": int(payload.get("fps", 24)),
120
+ "size": payload.get("size", "1280x720"),
121
+ "next_step": "Connect Wan, LTX Video, Hunyuan Video, or Veo credentials/runtime to execute this request.",
122
+ }
123
+ output = self._json_artifact(job_id, "video_request", manifest)
124
+ return self._result(output, {"task": "video_generate", "provider": provider})
125
+
126
+ def ai_tool(self, tool: str, payload: dict[str, Any], job_id: str) -> TaskResult:
127
+ tool = _canonical(tool)
128
+ if tool not in AI_EDITING_FEATURES:
129
+ raise ValueError(f"Unsupported AI editing tool: {tool}")
130
+ manifest = {
131
+ "type": "ai_editing",
132
+ "tool": tool,
133
+ "status": "ready",
134
+ "media": payload.get("media"),
135
+ "project_id": payload.get("project_id"),
136
+ "platform": payload.get("platform", "tiktok"),
137
+ "result": _ai_result(tool, payload),
138
+ "created_at": now(),
139
+ }
140
+ output = self._json_artifact(job_id, tool, manifest)
141
+ return self._result(output, {"task": tool})
142
+
143
+ def assistant_tool(self, tool: str, payload: dict[str, Any], job_id: str) -> TaskResult:
144
+ tool = _canonical(tool)
145
+ if tool not in AI_ASSISTANTS:
146
+ raise ValueError(f"Unsupported assistant: {tool}")
147
+ text = str(payload.get("topic") or payload.get("transcript") or payload.get("prompt") or "")
148
+ manifest = {
149
+ "type": "assistant",
150
+ "tool": tool,
151
+ "status": "ready",
152
+ "input": text,
153
+ "platform": payload.get("platform", "general"),
154
+ "result": _assistant_result(tool, text, payload),
155
+ "created_at": now(),
156
+ }
157
+ output = self._json_artifact(job_id, tool, manifest)
158
+ return self._result(output, {"task": tool, "characters": len(text)})
159
+
160
+ def _json_artifact(self, job_id: str, name: str, payload: dict[str, Any]) -> Path:
161
+ output = self.settings.exports_dir / f"{job_id}_{safe_filename(name)}.json"
162
+ write_json(output, payload)
163
+ self._message(f"Wrote {name} manifest")
164
+ return output
165
+
166
+ def _result(self, output: Path, metrics: dict[str, Any]) -> TaskResult:
167
+ return TaskResult(output_path=output, commands=[], metrics=metrics, logs=list(self._logs))
168
+
169
+ def _message(self, message: str) -> None:
170
+ self._logs.append(message)
171
+ if self._log:
172
+ self._log(message)
173
+
174
+
175
+ def _provider(value: Any, supported: list[str], default: str) -> str:
176
+ provider = _canonical(str(value or default))
177
+ return provider if provider in supported else default
178
+
179
+
180
+ def _canonical(value: str) -> str:
181
+ return value.strip().lower().replace("-", "_").replace(" ", "_")
182
+
183
+
184
+ def _captions_from_text(text: str) -> list[dict[str, Any]]:
185
+ if not text:
186
+ return []
187
+ words = text.split()
188
+ chunks: list[list[str]] = []
189
+ while words:
190
+ chunks.append(words[:8])
191
+ words = words[8:]
192
+ captions = []
193
+ cursor = 0.0
194
+ for chunk in chunks:
195
+ duration = max(1.2, len(chunk) * 0.34)
196
+ captions.append({"start": round(cursor, 2), "end": round(cursor + duration, 2), "text": " ".join(chunk)})
197
+ cursor += duration
198
+ return captions
199
+
200
+
201
+ def _ai_result(tool: str, payload: dict[str, Any]) -> dict[str, Any]:
202
+ platform = str(payload.get("platform") or "tiktok")
203
+ if tool == "auto_highlight_detection":
204
+ return {"highlights": [{"start": 0, "end": 8, "reason": "opening hook"}]}
205
+ if tool == "auto_scene_detection":
206
+ return {"scenes": [{"start": 0, "end": 5, "label": "intro"}, {"start": 5, "end": 12, "label": "body"}]}
207
+ if tool in {"auto_reframe", "auto_crop", "auto_platform_optimization"}:
208
+ return {"platform": platform, "safe_zone": "vertical_center", "aspect_ratio": "9:16"}
209
+ if tool == "auto_viral_score":
210
+ return {"score": 74, "signals": ["short duration", "caption-ready", platform]}
211
+ if tool == "auto_hook_detection":
212
+ return {"hook": str(payload.get("transcript") or payload.get("text") or "")[:120], "score": 68}
213
+ if tool == "auto_thumbnail_selection":
214
+ return {"frames": [{"timestamp": 2.0, "score": 82}, {"timestamp": 6.5, "score": 75}]}
215
+ return {"plan": f"{tool} plan generated", "confidence": "heuristic", "platform": platform}
216
+
217
+
218
+ def _assistant_result(tool: str, text: str, payload: dict[str, Any]) -> dict[str, Any]:
219
+ subject = text.strip() or "your video"
220
+ short = " ".join(subject.split()[:12])
221
+ if tool == "script_writer":
222
+ return {"script": f"Hook: {short}\nValue: show the clearest proof.\nCTA: invite viewers to take the next step."}
223
+ if tool == "hook_generator":
224
+ return {"hooks": [f"Stop scrolling if you care about {short}", f"Nobody tells you this about {short}"]}
225
+ if tool == "title_generator":
226
+ return {"titles": [short.title(), f"How {short.title()} Changes Everything"]}
227
+ if tool == "description_generator":
228
+ return {"description": f"{subject}\n\nBuilt with Ava2lon Studio AI."}
229
+ if tool == "hashtag_generator":
230
+ tags = [word.strip(".,!?").lower() for word in subject.split() if len(word.strip(".,!?")) > 3]
231
+ return {"hashtags": ["#" + tag for tag in tags[:8]] or ["#video", "#creator"]}
232
+ if tool == "storyboard_generator":
233
+ return {"beats": [{"scene": 1, "goal": "hook"}, {"scene": 2, "goal": "proof"}, {"scene": 3, "goal": "CTA"}]}
234
+ if tool == "b_roll_planner":
235
+ return {"shots": [{"type": "close_up", "description": short}, {"type": "screen_recording", "description": "show the result"}]}
236
+ if tool == "thumbnail_prompt_generator":
237
+ return {"prompt": f"High contrast thumbnail for {short}, expressive face, bold text, clean background"}
238
+ if tool == "seo_optimizer":
239
+ return {"keywords": [word.strip(".,!?").lower() for word in subject.split()[:10]], "score": 72}
240
+ return {"result": subject, "options": payload}
renderer/templates/creative.py CHANGED
@@ -71,6 +71,66 @@ SCENE_EFFECTS: dict[str, dict[str, str]] = {
71
  "label": "Noir",
72
  "filter": "hue=s=0,eq=contrast=1.18:brightness=-0.02",
73
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  }
75
 
76
 
 
71
  "label": "Noir",
72
  "filter": "hue=s=0,eq=contrast=1.18:brightness=-0.02",
73
  },
74
+ "glitch": {
75
+ "label": "Glitch",
76
+ "filter": "rgbashift=rh=4:bh=-4,eq=contrast=1.12:saturation=1.18",
77
+ },
78
+ "rgb_split": {
79
+ "label": "RGB Split",
80
+ "filter": "rgbashift=rh=3:gv=1:bh=-3",
81
+ },
82
+ "vhs": {
83
+ "label": "VHS",
84
+ "filter": "noise=alls=18:allf=t+u,eq=saturation=0.82:contrast=1.08",
85
+ },
86
+ "crt": {
87
+ "label": "CRT",
88
+ "filter": "vignette=PI/4,noise=alls=10:allf=t+u,eq=contrast=1.15:saturation=0.9",
89
+ },
90
+ "bloom": {
91
+ "label": "Bloom",
92
+ "filter": "gblur=sigma=0.35,eq=brightness=0.025:saturation=1.14",
93
+ },
94
+ "glow": {
95
+ "label": "Glow",
96
+ "filter": "gblur=sigma=0.45,eq=contrast=1.05:brightness=0.03",
97
+ },
98
+ "chromatic_aberration": {
99
+ "label": "Chromatic Aberration",
100
+ "filter": "rgbashift=rh=2:rv=1:bh=-2:bv=-1",
101
+ },
102
+ "neon": {
103
+ "label": "Neon",
104
+ "filter": "eq=contrast=1.2:saturation=1.55:brightness=0.02",
105
+ },
106
+ "cyberpunk": {
107
+ "label": "Cyberpunk",
108
+ "filter": "eq=contrast=1.18:saturation=1.45:gamma_r=1.08:gamma_b=1.18",
109
+ },
110
+ "comic": {
111
+ "label": "Comic",
112
+ "filter": "edgedetect=low=0.08:high=0.25,eq=contrast=1.2:saturation=1.35",
113
+ },
114
+ "cartoon": {
115
+ "label": "Cartoon",
116
+ "filter": "edgedetect=low=0.05:high=0.2,eq=saturation=1.45:contrast=1.15",
117
+ },
118
+ "anime": {
119
+ "label": "Anime",
120
+ "filter": "eq=saturation=1.35:contrast=1.12:brightness=0.02,unsharp=5:5:0.6",
121
+ },
122
+ "sketch": {
123
+ "label": "Sketch",
124
+ "filter": "edgedetect=low=0.03:high=0.18,hue=s=0",
125
+ },
126
+ "oil_painting": {
127
+ "label": "Oil Painting",
128
+ "filter": "gblur=sigma=0.7,eq=saturation=1.25:contrast=1.1",
129
+ },
130
+ "pixel_art": {
131
+ "label": "Pixel Art",
132
+ "filter": "scale=iw/8:ih/8,scale=iw*8:ih*8:flags=neighbor",
133
+ },
134
  }
135
 
136
 
renderer/transitions/builder.py CHANGED
@@ -5,21 +5,43 @@ class TransitionBuilder:
5
  """Build FFmpeg xfade filters for scene joins."""
6
 
7
  TRANSITIONS = {
 
8
  "fade": "fade",
9
  "zoom": "zoomin",
10
  "slide": "slideleft",
 
 
 
 
11
  "push": "slideup",
12
  "blur": "fade",
13
  "whip": "smoothleft",
14
  "dissolve": "dissolve",
15
  "flash": "fadewhite",
16
  "glitch": "hlslice",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  "wipe": "wipeleft",
18
  "wipe_left": "wipeleft",
19
  "wipe_right": "wiperight",
 
 
20
  "smooth_left": "smoothleft",
21
  "smooth_right": "smoothright",
22
  "fadeblack": "fadeblack",
 
23
  "pixel": "pixelize",
24
  }
25
 
 
5
  """Build FFmpeg xfade filters for scene joins."""
6
 
7
  TRANSITIONS = {
8
+ "basic": "fade",
9
  "fade": "fade",
10
  "zoom": "zoomin",
11
  "slide": "slideleft",
12
+ "slide_left": "slideleft",
13
+ "slide_right": "slideright",
14
+ "slide_up": "slideup",
15
+ "slide_down": "slidedown",
16
  "push": "slideup",
17
  "blur": "fade",
18
  "whip": "smoothleft",
19
  "dissolve": "dissolve",
20
  "flash": "fadewhite",
21
  "glitch": "hlslice",
22
+ "light_leak": "fadewhite",
23
+ "film_burn": "fadegrays",
24
+ "camera_shake": "smoothleft",
25
+ "spin": "circleopen",
26
+ "3d_flip": "vertopen",
27
+ "flip": "vertopen",
28
+ "cube": "rectcrop",
29
+ "ripple": "radial",
30
+ "ink": "distance",
31
+ "morph": "dissolve",
32
+ "stretch": "squeezeh",
33
+ "liquid": "pixelize",
34
+ "elastic": "smoothup",
35
+ "motion_blur": "smoothleft",
36
  "wipe": "wipeleft",
37
  "wipe_left": "wipeleft",
38
  "wipe_right": "wiperight",
39
+ "wipe_up": "wipeup",
40
+ "wipe_down": "wipedown",
41
  "smooth_left": "smoothleft",
42
  "smooth_right": "smoothright",
43
  "fadeblack": "fadeblack",
44
+ "fade_white": "fadewhite",
45
  "pixel": "pixelize",
46
  }
47