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.
- video_compose/__init__.py +7 -0
- video_compose/_codec.py +65 -0
- video_compose/_fonts.py +51 -0
- video_compose/api.py +112 -0
- video_compose/assembler/__init__.py +4 -0
- video_compose/assembler/assembler.py +203 -0
- video_compose/assembler/concat.py +33 -0
- video_compose/assembler/encode.py +22 -0
- video_compose/assembler/png_export.py +34 -0
- video_compose/assets/placeholders/headshot.png +0 -0
- video_compose/assets/placeholders/landscape_motivational.png +0 -0
- video_compose/assets/placeholders/music_cinematic.mp3 +0 -0
- video_compose/assets/placeholders/music_corporate.mp3 +0 -0
- video_compose/assets/placeholders/music_lofi.mp3 +0 -0
- video_compose/assets/placeholders/product_hero.png +0 -0
- video_compose/assets/placeholders/real_estate.png +0 -0
- video_compose/assets/placeholders/team_photo.png +0 -0
- video_compose/assets/placeholders.py +57 -0
- video_compose/audio/__init__.py +2 -0
- video_compose/audio/mixer.py +119 -0
- video_compose/audio/pipeline.py +63 -0
- video_compose/audio/voiceover.py +88 -0
- video_compose/cli.py +451 -0
- video_compose/data/__init__.py +11 -0
- video_compose/data/api_source.py +74 -0
- video_compose/data/csv_source.py +29 -0
- video_compose/data/excel_source.py +51 -0
- video_compose/data/fetcher.py +179 -0
- video_compose/data/json_source.py +39 -0
- video_compose/data/registry.py +91 -0
- video_compose/data/sql_source.py +45 -0
- video_compose/fonts/Inter-Medium.ttf +0 -0
- video_compose/fonts/Inter-Regular.ttf +0 -0
- video_compose/fonts/Inter-SemiBold.ttf +0 -0
- video_compose/fonts/__init__.py +0 -0
- video_compose/grade/__init__.py +2 -0
- video_compose/grade/apply.py +100 -0
- video_compose/llm/__init__.py +19 -0
- video_compose/llm/prompt_builder.py +326 -0
- video_compose/llm/spec_generator.py +234 -0
- video_compose/llm/spec_validator.py +155 -0
- video_compose/llm/template_instantiator.py +180 -0
- video_compose/llm/template_picker.py +178 -0
- video_compose/overlays/__init__.py +2 -0
- video_compose/overlays/bar.py +99 -0
- video_compose/overlays/compositor.py +125 -0
- video_compose/overlays/stars.py +111 -0
- video_compose/overlays/text.py +255 -0
- video_compose/overlays/web.py +47 -0
- video_compose/renderers/__init__.py +5 -0
- video_compose/renderers/base.py +146 -0
- video_compose/renderers/blank.py +188 -0
- video_compose/renderers/chart.py +60 -0
- video_compose/renderers/dispatcher.py +21 -0
- video_compose/renderers/fractal.py +42 -0
- video_compose/renderers/geomap.py +166 -0
- video_compose/renderers/image.py +152 -0
- video_compose/renderers/mathviz.py +172 -0
- video_compose/renderers/registry.py +63 -0
- video_compose/renderers/shape.py +67 -0
- video_compose/renderers/slide.py +113 -0
- video_compose/renderers/split_screen.py +178 -0
- video_compose/renderers/still.py +240 -0
- video_compose/renderers/video.py +52 -0
- video_compose/schema/__init__.py +4 -0
- video_compose/schema/spec.py +554 -0
- video_compose/schema/tvcs_schema.json +2166 -0
- video_compose/schema/validator.py +411 -0
- video_compose/templates/__init__.py +15 -0
- video_compose/templates/bundled/ambient/ambient_pre_show_loop.json +156 -0
- video_compose/templates/bundled/ambient/fractal_title_card.json +138 -0
- video_compose/templates/bundled/ambient/neon_countdown.json +201 -0
- video_compose/templates/bundled/audio/audiogram_podcast.json +162 -0
- video_compose/templates/bundled/brand/company_logo_reveal.json +150 -0
- video_compose/templates/bundled/business/okr_quarterly_review.json +363 -0
- video_compose/templates/bundled/business/pitch_deck_hook.json +245 -0
- video_compose/templates/bundled/business/pitch_deck_hook_light.json +249 -0
- video_compose/templates/bundled/creator/youtube_channel_intro.json +150 -0
- video_compose/templates/bundled/creator/youtube_channel_intro_portrait.json +155 -0
- video_compose/templates/bundled/creator/youtube_end_screen.json +148 -0
- video_compose/templates/bundled/data_story/data_candlestick.json +192 -0
- video_compose/templates/bundled/data_story/data_europe_map.json +202 -0
- video_compose/templates/bundled/data_story/data_radar_performance.json +211 -0
- video_compose/templates/bundled/data_story/data_sankey_flow.json +211 -0
- video_compose/templates/bundled/data_story/data_story_bar_chart.json +197 -0
- video_compose/templates/bundled/data_story/data_story_bar_chart_light.json +201 -0
- video_compose/templates/bundled/data_story/data_story_kpi_dashboard.json +325 -0
- video_compose/templates/bundled/data_story/data_story_line_chart.json +156 -0
- video_compose/templates/bundled/data_story/data_treemap.json +177 -0
- video_compose/templates/bundled/data_story/data_waterfall_bridge.json +185 -0
- video_compose/templates/bundled/data_story/data_world_choropleth.json +211 -0
- video_compose/templates/bundled/entertainment/esports_match_overlay.json +192 -0
- video_compose/templates/bundled/entertainment/movie_end_credits.json +248 -0
- video_compose/templates/bundled/entertainment/music_release_card.json +194 -0
- video_compose/templates/bundled/entertainment/podcast_chapter_card.json +152 -0
- video_compose/templates/bundled/event/event_agenda.json +292 -0
- video_compose/templates/bundled/event/event_speaker_card.json +179 -0
- video_compose/templates/bundled/explainer/bullet_point_list.json +189 -0
- video_compose/templates/bundled/explainer/explainer_funnel.json +171 -0
- video_compose/templates/bundled/explainer/explainer_numbered_steps.json +229 -0
- video_compose/templates/bundled/explainer/explainer_pros_cons.json +282 -0
- video_compose/templates/bundled/explainer/explainer_timeline.json +305 -0
- video_compose/templates/bundled/explainer/faq_qa.json +191 -0
- video_compose/templates/bundled/explainer/numbered_step_list.json +228 -0
- video_compose/templates/bundled/explainer/tutorial_steps.json +206 -0
- video_compose/templates/bundled/financial/annual_report_summary.json +318 -0
- video_compose/templates/bundled/financial/earnings_quarterly.json +243 -0
- video_compose/templates/bundled/lower_third/interview_lower_third.json +119 -0
- video_compose/templates/bundled/lower_third/lower_third_cnn.json +99 -0
- video_compose/templates/bundled/lower_third/lower_third_score_bug.json +119 -0
- video_compose/templates/bundled/lower_third/lower_third_scrolling_ticker.json +98 -0
- video_compose/templates/bundled/news/breaking_news_alert.json +169 -0
- video_compose/templates/bundled/people/team_spotlight.json +235 -0
- video_compose/templates/bundled/people/testimonial_review.json +193 -0
- video_compose/templates/bundled/people/testimonial_review_light.json +197 -0
- video_compose/templates/bundled/people/testimonial_review_portrait.json +198 -0
- video_compose/templates/bundled/presentation/presentation_corporate.json +147 -0
- video_compose/templates/bundled/presentation/presentation_corporate_light.json +150 -0
- video_compose/templates/bundled/product/app_store_review.json +241 -0
- video_compose/templates/bundled/product/before_after_comparison.json +205 -0
- video_compose/templates/bundled/product/pricing_card.json +253 -0
- video_compose/templates/bundled/product/product_feature_split.json +199 -0
- video_compose/templates/bundled/product_launch/product_launch_dark.json +237 -0
- video_compose/templates/bundled/product_launch/product_launch_dark_portrait.json +240 -0
- video_compose/templates/bundled/product_launch/product_launch_minimal.json +137 -0
- video_compose/templates/bundled/product_launch/product_launch_minimal_light.json +139 -0
- video_compose/templates/bundled/real_estate/real_estate_listing.json +222 -0
- video_compose/templates/bundled/social/quote_with_photo.json +161 -0
- video_compose/templates/bundled/social/social_announcement.json +124 -0
- video_compose/templates/bundled/social/social_collab_announcement.json +225 -0
- video_compose/templates/bundled/social/social_countdown.json +124 -0
- video_compose/templates/bundled/social/social_milestone_counter.json +183 -0
- video_compose/templates/bundled/social/social_motivational.json +101 -0
- video_compose/templates/bundled/social/social_motivational_portrait.json +105 -0
- video_compose/templates/bundled/social/social_poll_result.json +194 -0
- video_compose/templates/bundled/social/social_proof_numbers.json +218 -0
- video_compose/templates/bundled/social/social_quote_card.json +111 -0
- video_compose/templates/bundled/social/social_quote_card_portrait.json +116 -0
- video_compose/templates/bundled/social/social_tiktok_hook.json +127 -0
- video_compose/templates/bundled/sports/sports_match_result.json +284 -0
- video_compose/templates/bundled/swedish/swedish_election_map.json +193 -0
- video_compose/templates/bundled/swedish/swedish_news_opener.json +191 -0
- video_compose/templates/bundled/swedish/swedish_regional_kpi.json +217 -0
- video_compose/templates/bundled/swedish/swedish_valdistrikt_map.json +216 -0
- video_compose/templates/config.py +112 -0
- video_compose/templates/engine.py +223 -0
- video_compose/templates/previews/full/ambient_pre_show_loop.jpg +0 -0
- video_compose/templates/previews/full/annual_report_summary.jpg +0 -0
- video_compose/templates/previews/full/audiogram_podcast.jpg +0 -0
- video_compose/templates/previews/full/data_candlestick.jpg +0 -0
- video_compose/templates/previews/full/data_europe_map.jpg +0 -0
- video_compose/templates/previews/full/data_radar_performance.jpg +0 -0
- video_compose/templates/previews/full/data_sankey_flow.jpg +0 -0
- video_compose/templates/previews/full/data_story_bar_chart.jpg +0 -0
- video_compose/templates/previews/full/data_story_kpi_dashboard.jpg +0 -0
- video_compose/templates/previews/full/data_story_line_chart.jpg +0 -0
- video_compose/templates/previews/full/data_treemap.jpg +0 -0
- video_compose/templates/previews/full/data_waterfall_bridge.jpg +0 -0
- video_compose/templates/previews/full/data_world_choropleth.jpg +0 -0
- video_compose/templates/previews/full/earnings_quarterly.jpg +0 -0
- video_compose/templates/previews/full/esports_match_overlay.jpg +0 -0
- video_compose/templates/previews/full/event_agenda.jpg +0 -0
- video_compose/templates/previews/full/event_speaker_card.jpg +0 -0
- video_compose/templates/previews/full/explainer_funnel.jpg +0 -0
- video_compose/templates/previews/full/explainer_numbered_steps.jpg +0 -0
- video_compose/templates/previews/full/explainer_pros_cons.jpg +0 -0
- video_compose/templates/previews/full/explainer_timeline.jpg +0 -0
- video_compose/templates/previews/full/fractal_title_card.jpg +0 -0
- video_compose/templates/previews/full/lower_third_cnn.jpg +0 -0
- video_compose/templates/previews/full/lower_third_score_bug.jpg +0 -0
- video_compose/templates/previews/full/lower_third_scrolling_ticker.jpg +0 -0
- video_compose/templates/previews/full/movie_end_credits.jpg +0 -0
- video_compose/templates/previews/full/music_release_card.jpg +0 -0
- video_compose/templates/previews/full/neon_countdown.jpg +0 -0
- video_compose/templates/previews/full/okr_quarterly_review.jpg +0 -0
- video_compose/templates/previews/full/pitch_deck_hook.jpg +0 -0
- video_compose/templates/previews/full/podcast_chapter_card.jpg +0 -0
- video_compose/templates/previews/full/presentation_corporate.jpg +0 -0
- video_compose/templates/previews/full/product_launch_dark.jpg +0 -0
- video_compose/templates/previews/full/product_launch_minimal.jpg +0 -0
- video_compose/templates/previews/full/real_estate_listing.jpg +0 -0
- video_compose/templates/previews/full/social_announcement.jpg +0 -0
- video_compose/templates/previews/full/social_collab_announcement.jpg +0 -0
- video_compose/templates/previews/full/social_countdown.jpg +0 -0
- video_compose/templates/previews/full/social_milestone_counter.jpg +0 -0
- video_compose/templates/previews/full/social_motivational.jpg +0 -0
- video_compose/templates/previews/full/social_poll_result.jpg +0 -0
- video_compose/templates/previews/full/social_quote_card.jpg +0 -0
- video_compose/templates/previews/full/social_tiktok_hook.jpg +0 -0
- video_compose/templates/previews/full/sports_match_result.jpg +0 -0
- video_compose/templates/previews/full/swedish_election_map.jpg +0 -0
- video_compose/templates/previews/full/swedish_news_opener.jpg +0 -0
- video_compose/templates/previews/full/swedish_regional_kpi.jpg +0 -0
- video_compose/templates/previews/full/swedish_valdistrikt_map.jpg +0 -0
- video_compose/templates/previews/full/team_spotlight.jpg +0 -0
- video_compose/templates/previews/full/testimonial_review.jpg +0 -0
- video_compose/templates/previews/full/youtube_channel_intro.jpg +0 -0
- video_compose/templates/previews/thumbnails/ambient_pre_show_loop.jpg +0 -0
- video_compose/templates/previews/thumbnails/annual_report_summary.jpg +0 -0
- video_compose/templates/previews/thumbnails/audiogram_podcast.jpg +0 -0
- video_compose/templates/previews/thumbnails/data_candlestick.jpg +0 -0
- video_compose/templates/previews/thumbnails/data_europe_map.jpg +0 -0
- video_compose/templates/previews/thumbnails/data_radar_performance.jpg +0 -0
- video_compose/templates/previews/thumbnails/data_sankey_flow.jpg +0 -0
- video_compose/templates/previews/thumbnails/data_story_bar_chart.jpg +0 -0
- video_compose/templates/previews/thumbnails/data_story_kpi_dashboard.jpg +0 -0
- video_compose/templates/previews/thumbnails/data_story_line_chart.jpg +0 -0
- video_compose/templates/previews/thumbnails/data_treemap.jpg +0 -0
- video_compose/templates/previews/thumbnails/data_waterfall_bridge.jpg +0 -0
- video_compose/templates/previews/thumbnails/data_world_choropleth.jpg +0 -0
- video_compose/templates/previews/thumbnails/earnings_quarterly.jpg +0 -0
- video_compose/templates/previews/thumbnails/esports_match_overlay.jpg +0 -0
- video_compose/templates/previews/thumbnails/event_agenda.jpg +0 -0
- video_compose/templates/previews/thumbnails/event_speaker_card.jpg +0 -0
- video_compose/templates/previews/thumbnails/explainer_funnel.jpg +0 -0
- video_compose/templates/previews/thumbnails/explainer_numbered_steps.jpg +0 -0
- video_compose/templates/previews/thumbnails/explainer_pros_cons.jpg +0 -0
- video_compose/templates/previews/thumbnails/explainer_timeline.jpg +0 -0
- video_compose/templates/previews/thumbnails/fractal_title_card.jpg +0 -0
- video_compose/templates/previews/thumbnails/lower_third_cnn.jpg +0 -0
- video_compose/templates/previews/thumbnails/lower_third_score_bug.jpg +0 -0
- video_compose/templates/previews/thumbnails/lower_third_scrolling_ticker.jpg +0 -0
- video_compose/templates/previews/thumbnails/movie_end_credits.jpg +0 -0
- video_compose/templates/previews/thumbnails/music_release_card.jpg +0 -0
- video_compose/templates/previews/thumbnails/neon_countdown.jpg +0 -0
- video_compose/templates/previews/thumbnails/okr_quarterly_review.jpg +0 -0
- video_compose/templates/previews/thumbnails/pitch_deck_hook.jpg +0 -0
- video_compose/templates/previews/thumbnails/podcast_chapter_card.jpg +0 -0
- video_compose/templates/previews/thumbnails/presentation_corporate.jpg +0 -0
- video_compose/templates/previews/thumbnails/product_launch_dark.jpg +0 -0
- video_compose/templates/previews/thumbnails/product_launch_minimal.jpg +0 -0
- video_compose/templates/previews/thumbnails/real_estate_listing.jpg +0 -0
- video_compose/templates/previews/thumbnails/social_announcement.jpg +0 -0
- video_compose/templates/previews/thumbnails/social_collab_announcement.jpg +0 -0
- video_compose/templates/previews/thumbnails/social_countdown.jpg +0 -0
- video_compose/templates/previews/thumbnails/social_milestone_counter.jpg +0 -0
- video_compose/templates/previews/thumbnails/social_motivational.jpg +0 -0
- video_compose/templates/previews/thumbnails/social_poll_result.jpg +0 -0
- video_compose/templates/previews/thumbnails/social_quote_card.jpg +0 -0
- video_compose/templates/previews/thumbnails/social_tiktok_hook.jpg +0 -0
- video_compose/templates/previews/thumbnails/sports_match_result.jpg +0 -0
- video_compose/templates/previews/thumbnails/swedish_election_map.jpg +0 -0
- video_compose/templates/previews/thumbnails/swedish_news_opener.jpg +0 -0
- video_compose/templates/previews/thumbnails/swedish_regional_kpi.jpg +0 -0
- video_compose/templates/previews/thumbnails/swedish_valdistrikt_map.jpg +0 -0
- video_compose/templates/previews/thumbnails/team_spotlight.jpg +0 -0
- video_compose/templates/previews/thumbnails/testimonial_review.jpg +0 -0
- video_compose/templates/previews/thumbnails/youtube_channel_intro.jpg +0 -0
- video_compose/templates/registry.py +241 -0
- video_compose/tools/__init__.py +0 -0
- video_compose/tools/report.py +167 -0
- video_compose/tools/thumbnail.py +130 -0
- video_compose/transition/__init__.py +2 -0
- video_compose/transition/apply.py +63 -0
- video_compose-0.3.0.dist-info/METADATA +150 -0
- video_compose-0.3.0.dist-info/RECORD +258 -0
- video_compose-0.3.0.dist-info/WHEEL +4 -0
- video_compose-0.3.0.dist-info/entry_points.txt +4 -0
video_compose/_codec.py
ADDED
|
@@ -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
|
video_compose/_fonts.py
ADDED
|
@@ -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,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"))
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|