video-compose 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. video_compose/__init__.py +7 -0
  2. video_compose/_codec.py +65 -0
  3. video_compose/_fonts.py +51 -0
  4. video_compose/api.py +112 -0
  5. video_compose/assembler/__init__.py +4 -0
  6. video_compose/assembler/assembler.py +203 -0
  7. video_compose/assembler/concat.py +33 -0
  8. video_compose/assembler/encode.py +22 -0
  9. video_compose/assembler/png_export.py +34 -0
  10. video_compose/assets/placeholders/headshot.png +0 -0
  11. video_compose/assets/placeholders/landscape_motivational.png +0 -0
  12. video_compose/assets/placeholders/music_cinematic.mp3 +0 -0
  13. video_compose/assets/placeholders/music_corporate.mp3 +0 -0
  14. video_compose/assets/placeholders/music_lofi.mp3 +0 -0
  15. video_compose/assets/placeholders/product_hero.png +0 -0
  16. video_compose/assets/placeholders/real_estate.png +0 -0
  17. video_compose/assets/placeholders/team_photo.png +0 -0
  18. video_compose/assets/placeholders.py +57 -0
  19. video_compose/audio/__init__.py +2 -0
  20. video_compose/audio/mixer.py +119 -0
  21. video_compose/audio/pipeline.py +63 -0
  22. video_compose/audio/voiceover.py +88 -0
  23. video_compose/cli.py +451 -0
  24. video_compose/data/__init__.py +11 -0
  25. video_compose/data/api_source.py +74 -0
  26. video_compose/data/csv_source.py +29 -0
  27. video_compose/data/excel_source.py +51 -0
  28. video_compose/data/fetcher.py +179 -0
  29. video_compose/data/json_source.py +39 -0
  30. video_compose/data/registry.py +91 -0
  31. video_compose/data/sql_source.py +45 -0
  32. video_compose/fonts/Inter-Medium.ttf +0 -0
  33. video_compose/fonts/Inter-Regular.ttf +0 -0
  34. video_compose/fonts/Inter-SemiBold.ttf +0 -0
  35. video_compose/fonts/__init__.py +0 -0
  36. video_compose/grade/__init__.py +2 -0
  37. video_compose/grade/apply.py +100 -0
  38. video_compose/llm/__init__.py +19 -0
  39. video_compose/llm/prompt_builder.py +326 -0
  40. video_compose/llm/spec_generator.py +234 -0
  41. video_compose/llm/spec_validator.py +155 -0
  42. video_compose/llm/template_instantiator.py +180 -0
  43. video_compose/llm/template_picker.py +178 -0
  44. video_compose/overlays/__init__.py +2 -0
  45. video_compose/overlays/bar.py +99 -0
  46. video_compose/overlays/compositor.py +125 -0
  47. video_compose/overlays/stars.py +111 -0
  48. video_compose/overlays/text.py +255 -0
  49. video_compose/overlays/web.py +47 -0
  50. video_compose/renderers/__init__.py +5 -0
  51. video_compose/renderers/base.py +146 -0
  52. video_compose/renderers/blank.py +188 -0
  53. video_compose/renderers/chart.py +60 -0
  54. video_compose/renderers/dispatcher.py +21 -0
  55. video_compose/renderers/fractal.py +42 -0
  56. video_compose/renderers/geomap.py +166 -0
  57. video_compose/renderers/image.py +152 -0
  58. video_compose/renderers/mathviz.py +172 -0
  59. video_compose/renderers/registry.py +63 -0
  60. video_compose/renderers/shape.py +67 -0
  61. video_compose/renderers/slide.py +113 -0
  62. video_compose/renderers/split_screen.py +178 -0
  63. video_compose/renderers/still.py +240 -0
  64. video_compose/renderers/video.py +52 -0
  65. video_compose/schema/__init__.py +4 -0
  66. video_compose/schema/spec.py +554 -0
  67. video_compose/schema/tvcs_schema.json +2166 -0
  68. video_compose/schema/validator.py +411 -0
  69. video_compose/templates/__init__.py +15 -0
  70. video_compose/templates/bundled/ambient/ambient_pre_show_loop.json +156 -0
  71. video_compose/templates/bundled/ambient/fractal_title_card.json +138 -0
  72. video_compose/templates/bundled/ambient/neon_countdown.json +201 -0
  73. video_compose/templates/bundled/audio/audiogram_podcast.json +162 -0
  74. video_compose/templates/bundled/brand/company_logo_reveal.json +150 -0
  75. video_compose/templates/bundled/business/okr_quarterly_review.json +363 -0
  76. video_compose/templates/bundled/business/pitch_deck_hook.json +245 -0
  77. video_compose/templates/bundled/business/pitch_deck_hook_light.json +249 -0
  78. video_compose/templates/bundled/creator/youtube_channel_intro.json +150 -0
  79. video_compose/templates/bundled/creator/youtube_channel_intro_portrait.json +155 -0
  80. video_compose/templates/bundled/creator/youtube_end_screen.json +148 -0
  81. video_compose/templates/bundled/data_story/data_candlestick.json +192 -0
  82. video_compose/templates/bundled/data_story/data_europe_map.json +202 -0
  83. video_compose/templates/bundled/data_story/data_radar_performance.json +211 -0
  84. video_compose/templates/bundled/data_story/data_sankey_flow.json +211 -0
  85. video_compose/templates/bundled/data_story/data_story_bar_chart.json +197 -0
  86. video_compose/templates/bundled/data_story/data_story_bar_chart_light.json +201 -0
  87. video_compose/templates/bundled/data_story/data_story_kpi_dashboard.json +325 -0
  88. video_compose/templates/bundled/data_story/data_story_line_chart.json +156 -0
  89. video_compose/templates/bundled/data_story/data_treemap.json +177 -0
  90. video_compose/templates/bundled/data_story/data_waterfall_bridge.json +185 -0
  91. video_compose/templates/bundled/data_story/data_world_choropleth.json +211 -0
  92. video_compose/templates/bundled/entertainment/esports_match_overlay.json +192 -0
  93. video_compose/templates/bundled/entertainment/movie_end_credits.json +248 -0
  94. video_compose/templates/bundled/entertainment/music_release_card.json +194 -0
  95. video_compose/templates/bundled/entertainment/podcast_chapter_card.json +152 -0
  96. video_compose/templates/bundled/event/event_agenda.json +292 -0
  97. video_compose/templates/bundled/event/event_speaker_card.json +179 -0
  98. video_compose/templates/bundled/explainer/bullet_point_list.json +189 -0
  99. video_compose/templates/bundled/explainer/explainer_funnel.json +171 -0
  100. video_compose/templates/bundled/explainer/explainer_numbered_steps.json +229 -0
  101. video_compose/templates/bundled/explainer/explainer_pros_cons.json +282 -0
  102. video_compose/templates/bundled/explainer/explainer_timeline.json +305 -0
  103. video_compose/templates/bundled/explainer/faq_qa.json +191 -0
  104. video_compose/templates/bundled/explainer/numbered_step_list.json +228 -0
  105. video_compose/templates/bundled/explainer/tutorial_steps.json +206 -0
  106. video_compose/templates/bundled/financial/annual_report_summary.json +318 -0
  107. video_compose/templates/bundled/financial/earnings_quarterly.json +243 -0
  108. video_compose/templates/bundled/lower_third/interview_lower_third.json +119 -0
  109. video_compose/templates/bundled/lower_third/lower_third_cnn.json +99 -0
  110. video_compose/templates/bundled/lower_third/lower_third_score_bug.json +119 -0
  111. video_compose/templates/bundled/lower_third/lower_third_scrolling_ticker.json +98 -0
  112. video_compose/templates/bundled/news/breaking_news_alert.json +169 -0
  113. video_compose/templates/bundled/people/team_spotlight.json +235 -0
  114. video_compose/templates/bundled/people/testimonial_review.json +193 -0
  115. video_compose/templates/bundled/people/testimonial_review_light.json +197 -0
  116. video_compose/templates/bundled/people/testimonial_review_portrait.json +198 -0
  117. video_compose/templates/bundled/presentation/presentation_corporate.json +147 -0
  118. video_compose/templates/bundled/presentation/presentation_corporate_light.json +150 -0
  119. video_compose/templates/bundled/product/app_store_review.json +241 -0
  120. video_compose/templates/bundled/product/before_after_comparison.json +205 -0
  121. video_compose/templates/bundled/product/pricing_card.json +253 -0
  122. video_compose/templates/bundled/product/product_feature_split.json +199 -0
  123. video_compose/templates/bundled/product_launch/product_launch_dark.json +237 -0
  124. video_compose/templates/bundled/product_launch/product_launch_dark_portrait.json +240 -0
  125. video_compose/templates/bundled/product_launch/product_launch_minimal.json +137 -0
  126. video_compose/templates/bundled/product_launch/product_launch_minimal_light.json +139 -0
  127. video_compose/templates/bundled/real_estate/real_estate_listing.json +222 -0
  128. video_compose/templates/bundled/social/quote_with_photo.json +161 -0
  129. video_compose/templates/bundled/social/social_announcement.json +124 -0
  130. video_compose/templates/bundled/social/social_collab_announcement.json +225 -0
  131. video_compose/templates/bundled/social/social_countdown.json +124 -0
  132. video_compose/templates/bundled/social/social_milestone_counter.json +183 -0
  133. video_compose/templates/bundled/social/social_motivational.json +101 -0
  134. video_compose/templates/bundled/social/social_motivational_portrait.json +105 -0
  135. video_compose/templates/bundled/social/social_poll_result.json +194 -0
  136. video_compose/templates/bundled/social/social_proof_numbers.json +218 -0
  137. video_compose/templates/bundled/social/social_quote_card.json +111 -0
  138. video_compose/templates/bundled/social/social_quote_card_portrait.json +116 -0
  139. video_compose/templates/bundled/social/social_tiktok_hook.json +127 -0
  140. video_compose/templates/bundled/sports/sports_match_result.json +284 -0
  141. video_compose/templates/bundled/swedish/swedish_election_map.json +193 -0
  142. video_compose/templates/bundled/swedish/swedish_news_opener.json +191 -0
  143. video_compose/templates/bundled/swedish/swedish_regional_kpi.json +217 -0
  144. video_compose/templates/bundled/swedish/swedish_valdistrikt_map.json +216 -0
  145. video_compose/templates/config.py +112 -0
  146. video_compose/templates/engine.py +223 -0
  147. video_compose/templates/previews/full/ambient_pre_show_loop.jpg +0 -0
  148. video_compose/templates/previews/full/annual_report_summary.jpg +0 -0
  149. video_compose/templates/previews/full/audiogram_podcast.jpg +0 -0
  150. video_compose/templates/previews/full/data_candlestick.jpg +0 -0
  151. video_compose/templates/previews/full/data_europe_map.jpg +0 -0
  152. video_compose/templates/previews/full/data_radar_performance.jpg +0 -0
  153. video_compose/templates/previews/full/data_sankey_flow.jpg +0 -0
  154. video_compose/templates/previews/full/data_story_bar_chart.jpg +0 -0
  155. video_compose/templates/previews/full/data_story_kpi_dashboard.jpg +0 -0
  156. video_compose/templates/previews/full/data_story_line_chart.jpg +0 -0
  157. video_compose/templates/previews/full/data_treemap.jpg +0 -0
  158. video_compose/templates/previews/full/data_waterfall_bridge.jpg +0 -0
  159. video_compose/templates/previews/full/data_world_choropleth.jpg +0 -0
  160. video_compose/templates/previews/full/earnings_quarterly.jpg +0 -0
  161. video_compose/templates/previews/full/esports_match_overlay.jpg +0 -0
  162. video_compose/templates/previews/full/event_agenda.jpg +0 -0
  163. video_compose/templates/previews/full/event_speaker_card.jpg +0 -0
  164. video_compose/templates/previews/full/explainer_funnel.jpg +0 -0
  165. video_compose/templates/previews/full/explainer_numbered_steps.jpg +0 -0
  166. video_compose/templates/previews/full/explainer_pros_cons.jpg +0 -0
  167. video_compose/templates/previews/full/explainer_timeline.jpg +0 -0
  168. video_compose/templates/previews/full/fractal_title_card.jpg +0 -0
  169. video_compose/templates/previews/full/lower_third_cnn.jpg +0 -0
  170. video_compose/templates/previews/full/lower_third_score_bug.jpg +0 -0
  171. video_compose/templates/previews/full/lower_third_scrolling_ticker.jpg +0 -0
  172. video_compose/templates/previews/full/movie_end_credits.jpg +0 -0
  173. video_compose/templates/previews/full/music_release_card.jpg +0 -0
  174. video_compose/templates/previews/full/neon_countdown.jpg +0 -0
  175. video_compose/templates/previews/full/okr_quarterly_review.jpg +0 -0
  176. video_compose/templates/previews/full/pitch_deck_hook.jpg +0 -0
  177. video_compose/templates/previews/full/podcast_chapter_card.jpg +0 -0
  178. video_compose/templates/previews/full/presentation_corporate.jpg +0 -0
  179. video_compose/templates/previews/full/product_launch_dark.jpg +0 -0
  180. video_compose/templates/previews/full/product_launch_minimal.jpg +0 -0
  181. video_compose/templates/previews/full/real_estate_listing.jpg +0 -0
  182. video_compose/templates/previews/full/social_announcement.jpg +0 -0
  183. video_compose/templates/previews/full/social_collab_announcement.jpg +0 -0
  184. video_compose/templates/previews/full/social_countdown.jpg +0 -0
  185. video_compose/templates/previews/full/social_milestone_counter.jpg +0 -0
  186. video_compose/templates/previews/full/social_motivational.jpg +0 -0
  187. video_compose/templates/previews/full/social_poll_result.jpg +0 -0
  188. video_compose/templates/previews/full/social_quote_card.jpg +0 -0
  189. video_compose/templates/previews/full/social_tiktok_hook.jpg +0 -0
  190. video_compose/templates/previews/full/sports_match_result.jpg +0 -0
  191. video_compose/templates/previews/full/swedish_election_map.jpg +0 -0
  192. video_compose/templates/previews/full/swedish_news_opener.jpg +0 -0
  193. video_compose/templates/previews/full/swedish_regional_kpi.jpg +0 -0
  194. video_compose/templates/previews/full/swedish_valdistrikt_map.jpg +0 -0
  195. video_compose/templates/previews/full/team_spotlight.jpg +0 -0
  196. video_compose/templates/previews/full/testimonial_review.jpg +0 -0
  197. video_compose/templates/previews/full/youtube_channel_intro.jpg +0 -0
  198. video_compose/templates/previews/thumbnails/ambient_pre_show_loop.jpg +0 -0
  199. video_compose/templates/previews/thumbnails/annual_report_summary.jpg +0 -0
  200. video_compose/templates/previews/thumbnails/audiogram_podcast.jpg +0 -0
  201. video_compose/templates/previews/thumbnails/data_candlestick.jpg +0 -0
  202. video_compose/templates/previews/thumbnails/data_europe_map.jpg +0 -0
  203. video_compose/templates/previews/thumbnails/data_radar_performance.jpg +0 -0
  204. video_compose/templates/previews/thumbnails/data_sankey_flow.jpg +0 -0
  205. video_compose/templates/previews/thumbnails/data_story_bar_chart.jpg +0 -0
  206. video_compose/templates/previews/thumbnails/data_story_kpi_dashboard.jpg +0 -0
  207. video_compose/templates/previews/thumbnails/data_story_line_chart.jpg +0 -0
  208. video_compose/templates/previews/thumbnails/data_treemap.jpg +0 -0
  209. video_compose/templates/previews/thumbnails/data_waterfall_bridge.jpg +0 -0
  210. video_compose/templates/previews/thumbnails/data_world_choropleth.jpg +0 -0
  211. video_compose/templates/previews/thumbnails/earnings_quarterly.jpg +0 -0
  212. video_compose/templates/previews/thumbnails/esports_match_overlay.jpg +0 -0
  213. video_compose/templates/previews/thumbnails/event_agenda.jpg +0 -0
  214. video_compose/templates/previews/thumbnails/event_speaker_card.jpg +0 -0
  215. video_compose/templates/previews/thumbnails/explainer_funnel.jpg +0 -0
  216. video_compose/templates/previews/thumbnails/explainer_numbered_steps.jpg +0 -0
  217. video_compose/templates/previews/thumbnails/explainer_pros_cons.jpg +0 -0
  218. video_compose/templates/previews/thumbnails/explainer_timeline.jpg +0 -0
  219. video_compose/templates/previews/thumbnails/fractal_title_card.jpg +0 -0
  220. video_compose/templates/previews/thumbnails/lower_third_cnn.jpg +0 -0
  221. video_compose/templates/previews/thumbnails/lower_third_score_bug.jpg +0 -0
  222. video_compose/templates/previews/thumbnails/lower_third_scrolling_ticker.jpg +0 -0
  223. video_compose/templates/previews/thumbnails/movie_end_credits.jpg +0 -0
  224. video_compose/templates/previews/thumbnails/music_release_card.jpg +0 -0
  225. video_compose/templates/previews/thumbnails/neon_countdown.jpg +0 -0
  226. video_compose/templates/previews/thumbnails/okr_quarterly_review.jpg +0 -0
  227. video_compose/templates/previews/thumbnails/pitch_deck_hook.jpg +0 -0
  228. video_compose/templates/previews/thumbnails/podcast_chapter_card.jpg +0 -0
  229. video_compose/templates/previews/thumbnails/presentation_corporate.jpg +0 -0
  230. video_compose/templates/previews/thumbnails/product_launch_dark.jpg +0 -0
  231. video_compose/templates/previews/thumbnails/product_launch_minimal.jpg +0 -0
  232. video_compose/templates/previews/thumbnails/real_estate_listing.jpg +0 -0
  233. video_compose/templates/previews/thumbnails/social_announcement.jpg +0 -0
  234. video_compose/templates/previews/thumbnails/social_collab_announcement.jpg +0 -0
  235. video_compose/templates/previews/thumbnails/social_countdown.jpg +0 -0
  236. video_compose/templates/previews/thumbnails/social_milestone_counter.jpg +0 -0
  237. video_compose/templates/previews/thumbnails/social_motivational.jpg +0 -0
  238. video_compose/templates/previews/thumbnails/social_poll_result.jpg +0 -0
  239. video_compose/templates/previews/thumbnails/social_quote_card.jpg +0 -0
  240. video_compose/templates/previews/thumbnails/social_tiktok_hook.jpg +0 -0
  241. video_compose/templates/previews/thumbnails/sports_match_result.jpg +0 -0
  242. video_compose/templates/previews/thumbnails/swedish_election_map.jpg +0 -0
  243. video_compose/templates/previews/thumbnails/swedish_news_opener.jpg +0 -0
  244. video_compose/templates/previews/thumbnails/swedish_regional_kpi.jpg +0 -0
  245. video_compose/templates/previews/thumbnails/swedish_valdistrikt_map.jpg +0 -0
  246. video_compose/templates/previews/thumbnails/team_spotlight.jpg +0 -0
  247. video_compose/templates/previews/thumbnails/testimonial_review.jpg +0 -0
  248. video_compose/templates/previews/thumbnails/youtube_channel_intro.jpg +0 -0
  249. video_compose/templates/registry.py +241 -0
  250. video_compose/tools/__init__.py +0 -0
  251. video_compose/tools/report.py +167 -0
  252. video_compose/tools/thumbnail.py +130 -0
  253. video_compose/transition/__init__.py +2 -0
  254. video_compose/transition/apply.py +63 -0
  255. video_compose-0.3.0.dist-info/METADATA +150 -0
  256. video_compose-0.3.0.dist-info/RECORD +258 -0
  257. video_compose-0.3.0.dist-info/WHEEL +4 -0
  258. video_compose-0.3.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from video_compose.api import compose, preview, validate
6
+
7
+ __all__ = ["__version__", "compose", "validate", "preview"]
@@ -0,0 +1,65 @@
1
+ """
2
+ GPU-accelerated codec selection with automatic h264_nvenc → libx264 fallback.
3
+
4
+ Usage:
5
+ from video_compose._codec import codec_params
6
+
7
+ cmd = ["ffmpeg", "-y", "-i", "input.mp4", *codec_params(crf=18), "output.mp4"]
8
+
9
+ Codec selection order:
10
+ 1. VIDEO_COMPOSE_CODEC env var (e.g. "libx264", "h264_nvenc", "hevc_nvenc")
11
+ 2. h264_nvenc if detected via ffmpeg -encoders
12
+ 3. libx264 as fallback
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import functools
17
+ import os
18
+ import subprocess
19
+
20
+
21
+ def _probe_nvenc() -> bool:
22
+ try:
23
+ r = subprocess.run(
24
+ ["ffmpeg", "-hide_banner", "-encoders"],
25
+ capture_output=True, text=True, timeout=5,
26
+ )
27
+ return "h264_nvenc" in r.stdout
28
+ except Exception:
29
+ return False
30
+
31
+
32
+ @functools.lru_cache(maxsize=1)
33
+ def get_codec() -> str:
34
+ """Return the active codec name (detected once, then cached)."""
35
+ forced = os.environ.get("VIDEO_COMPOSE_CODEC", "").strip()
36
+ if forced:
37
+ return forced
38
+ if _probe_nvenc():
39
+ return "h264_nvenc"
40
+ return "libx264"
41
+
42
+
43
+ def codec_params(crf: int = 20, *, profile: str | None = None) -> list[str]:
44
+ """Return ffmpeg -c:v … quality arguments for the active codec.
45
+
46
+ Args:
47
+ crf: Quality level (0–51, lower = better). Maps to -crf for libx264
48
+ and -cq for h264_nvenc — same perceptual scale.
49
+ profile: H.264 profile ("high", "main", "baseline"). Applied for both
50
+ libx264 and h264_nvenc when specified.
51
+ """
52
+ codec = get_codec()
53
+ is_nvenc = codec == "h264_nvenc"
54
+
55
+ params: list[str] = ["-c:v", codec, "-pix_fmt", "yuv420p"]
56
+
57
+ if is_nvenc:
58
+ params += ["-cq", str(crf), "-preset", "p4"]
59
+ else:
60
+ params += ["-crf", str(crf)]
61
+
62
+ if profile:
63
+ params += ["-profile:v", profile]
64
+
65
+ return params
@@ -0,0 +1,51 @@
1
+ """
2
+ Font resolution for video-compose.
3
+
4
+ Maps logical font family names to bundled TTF paths so templates work
5
+ on any system without requiring fonts to be installed.
6
+
7
+ Bundled fonts take priority; system fonts are used as fallback for
8
+ families not in the bundle (e.g. custom brand fonts).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ _FONTS_DIR = Path(__file__).parent / "fonts"
15
+
16
+ _BUNDLED: dict[str, dict[str, str]] = {
17
+ "inter": {
18
+ "regular": "Inter-Regular.ttf",
19
+ "medium": "Inter-Medium.ttf",
20
+ "bold": "Inter-SemiBold.ttf",
21
+ "semibold": "Inter-SemiBold.ttf",
22
+ "black": "Inter-SemiBold.ttf",
23
+ },
24
+ }
25
+
26
+
27
+ def resolve_font_family(family: str, weight: str = "bold") -> str:
28
+ """Return an absolute path to a bundled TTF, or the original family name.
29
+
30
+ If the family is bundled (e.g. "Inter"), returns the absolute path so
31
+ text-fx loads it directly without a system font lookup.
32
+
33
+ If the family is not bundled, returns it unchanged so text-fx attempts
34
+ its normal matplotlib / filesystem discovery.
35
+ """
36
+ key = family.lower().strip()
37
+ weight_key = weight.lower().strip()
38
+
39
+ bundle = _BUNDLED.get(key)
40
+ if bundle is None:
41
+ return family
42
+
43
+ filename = bundle.get(weight_key) or bundle.get("regular", "")
44
+ if not filename:
45
+ return family
46
+
47
+ path = _FONTS_DIR / filename
48
+ if path.exists():
49
+ return str(path)
50
+
51
+ return family
video_compose/api.py ADDED
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class ComposeResult:
12
+ video_path: Path | None = None
13
+ png_dir: Path | None = None
14
+ warnings: list[str] = field(default_factory=list)
15
+
16
+ def __repr__(self) -> str:
17
+ return f"ComposeResult(video={self.video_path}, warnings={len(self.warnings)})"
18
+
19
+
20
+ def compose(
21
+ spec: dict | str | Path,
22
+ output_dir: str | Path | None = None,
23
+ progress_cb: Callable[[str, float], None] | None = None,
24
+ export_png: bool = False,
25
+ ) -> ComposeResult:
26
+ """Render a TVCS spec to MP4 and optionally PNG frames.
27
+
28
+ Args:
29
+ spec: TVCS spec as dict, JSON string, or path to a .json file.
30
+ output_dir: Output directory. Defaults to ./video_compose_output/.
31
+ progress_cb: Optional callback(stage: str, fraction: float).
32
+ export_png: If True, also extract PNG frames alongside the MP4.
33
+
34
+ Returns:
35
+ ComposeResult with paths to rendered outputs and any non-fatal warnings.
36
+
37
+ Raises:
38
+ ValueError: If the spec fails structural or semantic validation.
39
+ """
40
+ from video_compose.schema.spec import TVCSSpec
41
+ from video_compose.schema.validator import validate as _validate
42
+ from video_compose.assembler.assembler import Assembler
43
+
44
+ raw = _load_raw(spec)
45
+ vr = _validate(raw)
46
+ if vr.has_errors:
47
+ raise ValueError("Invalid TVCS spec:\n" + "\n".join(vr.errors))
48
+
49
+ parsed_spec = TVCSSpec.model_validate(raw)
50
+
51
+ out_dir = Path(output_dir) if output_dir else Path("video_compose_output")
52
+ out_dir.mkdir(parents=True, exist_ok=True)
53
+
54
+ assembler = Assembler(parsed_spec, output_dir=out_dir, progress_cb=progress_cb)
55
+ video_path = assembler.run()
56
+
57
+ png_dir: Path | None = None
58
+ if export_png:
59
+ from video_compose.assembler.png_export import export_frames
60
+ png_dir = out_dir / "frames"
61
+ export_frames(video_path, png_dir)
62
+
63
+ return ComposeResult(
64
+ video_path=video_path,
65
+ png_dir=png_dir,
66
+ warnings=vr.warnings,
67
+ )
68
+
69
+
70
+ def validate(spec: dict | str | Path):
71
+ """Validate a TVCS spec without rendering.
72
+
73
+ Returns:
74
+ ValidationResult with .is_valid, .errors, .warnings.
75
+ """
76
+ from video_compose.schema.validator import validate as _validate
77
+ return _validate(_load_raw(spec))
78
+
79
+
80
+ def preview(
81
+ spec: dict | str | Path,
82
+ segment_id: str,
83
+ output_path: str | Path | None = None,
84
+ ) -> Path:
85
+ """Render a single segment to a preview MP4.
86
+
87
+ Args:
88
+ spec: TVCS spec.
89
+ segment_id: ID of the segment to render.
90
+ output_path: Destination path. Defaults to ./preview_{segment_id}.mp4.
91
+
92
+ Returns:
93
+ Path to the rendered preview MP4.
94
+ """
95
+ from video_compose.schema.spec import TVCSSpec
96
+ from video_compose.assembler.assembler import Assembler
97
+
98
+ raw = _load_raw(spec)
99
+ parsed_spec = TVCSSpec.model_validate(raw)
100
+ assembler = Assembler(parsed_spec)
101
+ if output_path is None:
102
+ output_path = Path(f"preview_{segment_id}.mp4")
103
+ return assembler.render_segment_preview(segment_id, Path(output_path))
104
+
105
+
106
+ def _load_raw(spec: dict | str | Path) -> dict:
107
+ if isinstance(spec, dict):
108
+ return spec
109
+ text = Path(spec).read_text(encoding="utf-8") if isinstance(spec, Path) else str(spec)
110
+ if text.strip().startswith("{") or text.strip().startswith("["):
111
+ return json.loads(text)
112
+ return json.loads(Path(text).read_text(encoding="utf-8"))
@@ -0,0 +1,4 @@
1
+ from video_compose.assembler.assembler import Assembler
2
+ from video_compose.assembler.png_export import export_frames
3
+
4
+ __all__ = ["Assembler", "export_frames"]
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import tempfile
5
+ from pathlib import Path
6
+ from typing import Callable, Any
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class Assembler:
12
+ """Full render pipeline: segment render → overlays → grade → transitions → concat → audio.
13
+
14
+ Pipeline per segment:
15
+ 1. Render segment (via renderers.dispatcher)
16
+ 2. Apply overlays (via overlays.compositor)
17
+ 3. Apply per-segment grade (via grade.apply)
18
+
19
+ Then global pipeline:
20
+ 4. Apply transitions between segments (via transition.apply)
21
+ 5. Concatenate all segments
22
+ 6. Apply global theme grade (if any, no per-segment grade yet applied)
23
+ 7. Mix audio (via audio.pipeline)
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ spec,
29
+ output_dir: Path | None = None,
30
+ progress_cb: Callable[[str, float], None] | None = None,
31
+ ) -> None:
32
+ self._spec = spec
33
+ self._output_dir = Path(output_dir) if output_dir else None
34
+ self._progress_cb = progress_cb or (lambda msg, pct: None)
35
+
36
+ def run(self) -> Path:
37
+ """Execute the full render pipeline. Returns path to the final MP4."""
38
+ spec = self._spec
39
+ output = spec.output
40
+
41
+ width = output.width if output else 1920
42
+ height = output.height if output else 1080
43
+ fps = float(output.fps if output else 30)
44
+
45
+ grade_slug_global: str | None = None
46
+ if spec.theme:
47
+ grade_slug_global = getattr(spec.theme, "grade", None)
48
+
49
+ with tempfile.TemporaryDirectory() as td:
50
+ work_dir = Path(td)
51
+
52
+ from video_compose.data import DataResolver
53
+ resolver = DataResolver(spec)
54
+
55
+ # ── Phase 1: Render each segment ──────────────────────────────────
56
+ segment_clips: list[Path] = []
57
+ segment_timing: dict[str, float] = {}
58
+ current_time = 0.0
59
+
60
+ for i, seg in enumerate(spec.segments):
61
+ self._progress_cb(f"Rendering segment {seg.id}", i / len(spec.segments) * 0.6)
62
+ logger.info("[%d/%d] Rendering segment %r (type=%s)", i + 1, len(spec.segments), seg.id, seg.type)
63
+
64
+ segment_timing[seg.id] = current_time
65
+
66
+ # 1a. Resolve data ref
67
+ data_ref = getattr(seg, "data", None)
68
+ data = resolver.resolve(data_ref) if data_ref is not None else None
69
+
70
+ # 1b. Render the segment content
71
+ clip_path = work_dir / f"seg_{i:03d}_{seg.id}.mp4"
72
+ from video_compose.renderers.dispatcher import dispatch
73
+ dispatch(seg, data, clip_path, width=width, height=height, fps=fps)
74
+
75
+ # 1c. Apply overlays
76
+ overlays = getattr(seg, "overlays", None) or []
77
+ if overlays:
78
+ from video_compose.overlays.compositor import apply_overlays
79
+ clip_path = apply_overlays(
80
+ clip_path, overlays, seg.duration, width, height, fps
81
+ )
82
+
83
+ # 1d. Per-segment grade (prefer segment.grade, else global theme grade)
84
+ grade_slug = getattr(seg, "grade", None) or grade_slug_global
85
+ if grade_slug:
86
+ from video_compose.grade.apply import apply_grade
87
+ clip_path = apply_grade(clip_path, grade_slug)
88
+
89
+ segment_clips.append(clip_path)
90
+ current_time += seg.duration
91
+
92
+ total_duration = current_time
93
+
94
+ # ── Phase 2: Apply transitions ──────────────────────────────────
95
+ self._progress_cb("Applying transitions", 0.65)
96
+ clips_with_transitions = self._apply_transitions(segment_clips, spec, work_dir)
97
+
98
+ # ── Phase 3: Concatenate ─────────────────────────────────────────
99
+ self._progress_cb("Concatenating", 0.75)
100
+ from video_compose.assembler.concat import concat_clips
101
+ silent_video = concat_clips(clips_with_transitions, work_dir / "assembled_silent.mp4")
102
+
103
+ # ── Phase 4: Audio ───────────────────────────────────────────────
104
+ self._progress_cb("Mixing audio", 0.85)
105
+ from video_compose.audio.pipeline import AudioPipeline
106
+
107
+ if self._output_dir:
108
+ self._output_dir.mkdir(parents=True, exist_ok=True)
109
+ final_path = self._output_dir / "output.mp4"
110
+ else:
111
+ final_path = work_dir / "final.mp4"
112
+
113
+ audio_pipeline = AudioPipeline()
114
+ final_video = audio_pipeline.run(
115
+ spec=spec,
116
+ video_path=silent_video,
117
+ segment_timing=segment_timing,
118
+ total_duration=total_duration,
119
+ work_dir=work_dir,
120
+ output_path=work_dir / "final_with_audio.mp4",
121
+ )
122
+
123
+ # Copy final to output_dir
124
+ import shutil
125
+ shutil.copy2(final_video, final_path)
126
+
127
+ self._progress_cb("Done", 1.0)
128
+ logger.info("Render complete: %s", final_path)
129
+ return final_path
130
+
131
+ def render_segment_preview(self, segment_id: str, output_path: Path | None = None) -> Path:
132
+ """Render a single segment to a temporary file for preview."""
133
+ spec = self._spec
134
+ seg = next((s for s in spec.segments if s.id == segment_id), None)
135
+ if seg is None:
136
+ raise ValueError(f"No segment with id {segment_id!r}")
137
+
138
+ output = spec.output
139
+ width = output.width if output else 1920
140
+ height = output.height if output else 1080
141
+ fps = float(output.fps if output else 30)
142
+
143
+ if output_path is None:
144
+ import tempfile
145
+ tf = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
146
+ output_path = Path(tf.name)
147
+ tf.close()
148
+
149
+ from video_compose.data import DataResolver
150
+ resolver = DataResolver(spec)
151
+ data_ref = getattr(seg, "data", None)
152
+ data = resolver.resolve(data_ref) if data_ref is not None else None
153
+
154
+ from video_compose.renderers.dispatcher import dispatch
155
+ dispatch(seg, data, output_path, width=width, height=height, fps=fps)
156
+
157
+ return output_path
158
+
159
+ def _apply_transitions(
160
+ self,
161
+ clips: list[Path],
162
+ spec,
163
+ work_dir: Path,
164
+ ) -> list[Path]:
165
+ """Apply transitions between consecutive clips.
166
+
167
+ Returns a list of paths — may be shorter than *clips* if transitions
168
+ merge pairs, or same length if transitions use cut-fx's overlap mode.
169
+ """
170
+ if len(clips) <= 1:
171
+ return clips
172
+
173
+ from video_compose.transition.apply import apply_transition
174
+
175
+ transitions_block = getattr(spec, "transitions", None)
176
+ default_ref = transitions_block.default if transitions_block else None
177
+ overrides_list = (transitions_block.overrides or []) if transitions_block else []
178
+
179
+ # Build override map: (from_id, to_id) → TransitionRef
180
+ override_map: dict[tuple[str, str], Any] = {}
181
+ for ov in overrides_list:
182
+ from_id = ov.from_segment
183
+ to_id = ov.to
184
+ override_map[(from_id, to_id)] = ov # use ov as config (has .type and .duration)
185
+
186
+ segments = spec.segments
187
+ result_clips = [clips[0]]
188
+
189
+ for i in range(len(clips) - 1):
190
+ from_id = segments[i].id
191
+ to_id = segments[i + 1].id
192
+ transition_config = override_map.get((from_id, to_id), default_ref)
193
+
194
+ joined = work_dir / f"joined_{i:03d}_{i+1:03d}.mp4"
195
+ try:
196
+ joined_path = apply_transition(result_clips[-1], clips[i + 1], transition_config, joined)
197
+ # Replace last clip with the joined result
198
+ result_clips[-1] = joined_path
199
+ except Exception as exc:
200
+ logger.warning("Transition %d→%d failed: %s — using hard cut", i, i + 1, exc)
201
+ result_clips.append(clips[i + 1])
202
+
203
+ return result_clips
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import tempfile
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def concat_clips(clip_paths: list[Path], output: Path) -> Path:
10
+ """Concatenate a list of clip files into a single MP4 using ffmpeg concat."""
11
+ if not clip_paths:
12
+ raise ValueError("concat_clips: no clips to concatenate")
13
+
14
+ if len(clip_paths) == 1:
15
+ return clip_paths[0]
16
+
17
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False, encoding="utf-8") as f:
18
+ for p in clip_paths:
19
+ f.write(f"file '{Path(p).as_posix()}'\n")
20
+ concat_list = f.name
21
+
22
+ try:
23
+ result = subprocess.run(
24
+ ["ffmpeg", "-y", "-f", "concat", "-safe", "0",
25
+ "-i", concat_list, "-c", "copy", str(output)],
26
+ capture_output=True, text=True,
27
+ )
28
+ if result.returncode != 0:
29
+ raise RuntimeError(f"ffmpeg concat failed: {result.stderr[:500]}")
30
+ finally:
31
+ os.unlink(concat_list)
32
+
33
+ return output
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from video_compose._codec import codec_params
7
+
8
+
9
+ def remux_mp4(source: Path, output: Path, *, width: int, height: int, fps: float) -> Path:
10
+ """Re-encode *source* to a clean H.264 MP4 at the specified resolution and fps."""
11
+ cmd = [
12
+ "ffmpeg", "-y", "-i", str(source),
13
+ "-vf", f"scale={width}:{height}",
14
+ "-r", str(fps),
15
+ *codec_params(crf=20),
16
+ "-c:a", "copy",
17
+ str(output),
18
+ ]
19
+ result = subprocess.run(cmd, capture_output=True, text=True)
20
+ if result.returncode != 0:
21
+ raise RuntimeError(f"remux failed: {result.stderr[:500]}")
22
+ return output
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+
7
+ def export_frames(video_path: Path, output_dir: Path, fps: float | None = None) -> list[Path]:
8
+ """Extract all frames from *video_path* as PNG files in *output_dir*.
9
+
10
+ Args:
11
+ video_path: Source MP4.
12
+ output_dir: Directory to write PNG frames.
13
+ fps: If set, extract at this rate; otherwise extract every frame.
14
+
15
+ Returns:
16
+ Sorted list of Path objects for the extracted PNGs.
17
+ """
18
+ output_dir = Path(output_dir)
19
+ output_dir.mkdir(parents=True, exist_ok=True)
20
+
21
+ vf_args = []
22
+ if fps is not None:
23
+ vf_args = ["-vf", f"fps={fps}"]
24
+
25
+ cmd = [
26
+ "ffmpeg", "-y", "-i", str(video_path),
27
+ *vf_args,
28
+ str(output_dir / "frame_%06d.png"),
29
+ ]
30
+ result = subprocess.run(cmd, capture_output=True, text=True)
31
+ if result.returncode != 0:
32
+ raise RuntimeError(f"PNG export failed: {result.stderr[:500]}")
33
+
34
+ return sorted(output_dir.glob("frame_*.png"))
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ _ASSETS_DIR = Path(__file__).parent / "placeholders"
6
+
7
+ # keyword → placeholder filename (checked against lowercase variable name)
8
+ _IMAGE_RULES: list[tuple[tuple[str, ...], str]] = [
9
+ (("headshot", "portrait", "speaker_photo", "avatar", "profile"), "headshot.png"),
10
+ (("product", "hero_image", "item_photo"), "product_hero.png"),
11
+ (("real_estate", "property", "house", "exterior", "listing"), "real_estate.png"),
12
+ (("team",), "team_photo.png"),
13
+ (("landscape", "background_image", "scene", "cover"), "landscape_motivational.png"),
14
+ ]
15
+
16
+ _AUDIO_RULES: list[tuple[tuple[str, ...], str]] = [
17
+ (("lofi", "chill", "ambient_track"), "music_lofi.mp3"),
18
+ (("cinematic", "dramatic", "epic"), "music_cinematic.mp3"),
19
+ # default fallback for any music/audio var
20
+ (("music", "track", "audio_file", "bed", "bgm", "background_audio", "soundtrack"), "music_corporate.mp3"),
21
+ ]
22
+
23
+
24
+ def auto_placeholder(var_name: str, var_type: str) -> str | None:
25
+ """Return an absolute path to a bundled placeholder asset for *var_name*.
26
+
27
+ Matches on keyword substrings in the variable name (case-insensitive).
28
+ Returns None if no match is found.
29
+
30
+ Args:
31
+ var_name: Template variable name (e.g. 'speaker_photo', 'bg_music').
32
+ var_type: TVCS variable type: 'image_path', 'video_path', 'audio_path', etc.
33
+
34
+ Returns:
35
+ Absolute path string to the placeholder file, or None.
36
+ """
37
+ name_lower = var_name.lower()
38
+
39
+ if var_type in ("image_path", "video_path"):
40
+ for keywords, filename in _IMAGE_RULES:
41
+ if any(kw in name_lower for kw in keywords):
42
+ p = _ASSETS_DIR / filename
43
+ return str(p) if p.exists() else None
44
+ # Generic fallback for any image_path
45
+ fallback = _ASSETS_DIR / "landscape_motivational.png"
46
+ return str(fallback) if fallback.exists() else None
47
+
48
+ if var_type == "audio_path":
49
+ for keywords, filename in _AUDIO_RULES:
50
+ if any(kw in name_lower for kw in keywords):
51
+ p = _ASSETS_DIR / filename
52
+ return str(p) if p.exists() else None
53
+ # Generic fallback for any audio_path
54
+ fallback = _ASSETS_DIR / "music_corporate.mp3"
55
+ return str(fallback) if fallback.exists() else None
56
+
57
+ return None
@@ -0,0 +1,2 @@
1
+ from video_compose.audio.pipeline import AudioPipeline
2
+ __all__ = ['AudioPipeline']