@remixmate/cli 0.12.9 → 0.13.0

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.
@@ -37,7 +37,7 @@ import time
37
37
  import urllib.error
38
38
  import urllib.request
39
39
  from datetime import datetime, timezone
40
- from typing import Optional
40
+ from typing import Callable, Optional
41
41
 
42
42
 
43
43
 
@@ -700,15 +700,10 @@ def validate_and_fix_render_plan(render_plan: dict) -> list:
700
700
  if asset.get("duration") is None and needs_video_duration:
701
701
 
702
702
 
703
+
703
704
  fallback_dur = None
704
705
  for entry in render_plan.get("timeline", []):
705
- props = entry.get("props") or {}
706
- refs = (
707
- props.get("videoAssetId"),
708
- props.get("backgroundAssetId"),
709
- props.get("imageAssetId"),
710
- )
711
- if asset.get("assetId") in refs:
706
+ if _props_reference(entry.get("props") or {}, asset.get("assetId")):
712
707
  fallback_dur = entry.get("durationFrames", 150) / fps
713
708
  break
714
709
  if fallback_dur:
@@ -785,6 +780,19 @@ def _probe_video_meta(url: str) -> dict:
785
780
  return probe_video_meta(url)
786
781
 
787
782
 
783
+ def _props_reference(value, asset_id) -> bool:
784
+
785
+ if not asset_id:
786
+ return False
787
+ if isinstance(value, str):
788
+ return value == asset_id
789
+ if isinstance(value, dict):
790
+ return any(_props_reference(v, asset_id) for v in value.values())
791
+ if isinstance(value, list):
792
+ return any(_props_reference(v, asset_id) for v in value)
793
+ return False
794
+
795
+
788
796
  def build_render_plan(dsl: dict, binding: dict) -> dict:
789
797
 
790
798
  fps = dsl.get("global", {}).get("fps", 30)
@@ -881,6 +889,7 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
881
889
 
882
890
 
883
891
 
892
+
884
893
  meta_by_url: dict = {}
885
894
  for aid, asset in assets_by_id.items():
886
895
  url = asset.get("url")
@@ -988,6 +997,10 @@ def build_render_plan(dsl: dict, binding: dict) -> dict:
988
997
  "subtitleAtSec": subtitle_at_sec,
989
998
  "transition": {"type": scene_trans_type, "durationFrames": scene_trans_dur},
990
999
  }
1000
+
1001
+
1002
+ if isinstance(scene.get("textLayers"), list) and scene["textLayers"]:
1003
+ entry["textLayers"] = scene["textLayers"]
991
1004
  timeline.append(entry)
992
1005
  current_frame += duration_frames
993
1006
 
@@ -1617,8 +1630,6 @@ def _auto_highlight_map(
1617
1630
 
1618
1631
 
1619
1632
 
1620
-
1621
-
1622
1633
  if not narration_asset:
1623
1634
  return
1624
1635
  line_count = narration_asset.get("narrationLineCount")
@@ -1879,17 +1890,6 @@ def adjust_timeline_to_audio(render_plan: dict) -> dict:
1879
1890
 
1880
1891
  entry.pop("layers", None)
1881
1892
 
1882
-
1883
- slides = entry.get("props", {}).get("slides")
1884
- if slides and isinstance(slides, list) and len(slides) > 0:
1885
- scene_dur = entry["durationFrames"]
1886
- per_slide = scene_dur // len(slides)
1887
- remainder = scene_dur - per_slide * len(slides)
1888
- for si, slide in enumerate(slides):
1889
- if not isinstance(slide, dict):
1890
- continue
1891
- slide["durationFrames"] = per_slide + (1 if si < remainder else 0)
1892
-
1893
1893
  current_frame += entry["durationFrames"]
1894
1894
 
1895
1895
  render_plan["renderConfig"]["totalFrames"] = current_frame
@@ -2137,8 +2137,6 @@ def render_with_remote_api(
2137
2137
 
2138
2138
 
2139
2139
 
2140
- import remote_renderer_client
2141
-
2142
2140
  render_plan["status"] = "rendering"
2143
2141
  render_plan["renderMode"] = "remote"
2144
2142
  render_plan["logs"].append({
@@ -2147,6 +2145,30 @@ def render_with_remote_api(
2147
2145
  "timestamp": now_iso(),
2148
2146
  })
2149
2147
 
2148
+ request = _remote_render_request(render_plan, output_path, upload_title)
2149
+ submitted = _submit_or_resume_remote_task(
2150
+ render_plan, request["payload"], request["render_path"], request["status_path"],
2151
+ private_token=private_token, conversation_id=conversation_id, job_id=job_id,
2152
+ )
2153
+ if submitted is None:
2154
+ return False
2155
+ task_id, status_path = submitted
2156
+ finished, result = _poll_remote_task(
2157
+ render_plan, task_id, status_path,
2158
+ private_token=private_token, poll_timeout=poll_timeout, poll_interval=poll_interval, job_id=job_id,
2159
+ )
2160
+ if not finished:
2161
+ return False
2162
+ return _apply_remote_result(
2163
+ render_plan, result,
2164
+ effective_title=request["effective_title"],
2165
+ cover_composition_id=request["cover_composition_id"],
2166
+ private_token=private_token,
2167
+ )
2168
+
2169
+
2170
+ def _remote_render_request(render_plan: dict, output_path: str, upload_title: Optional[str]) -> dict:
2171
+
2150
2172
  https_assets = []
2151
2173
  for asset in render_plan.get("resolvedAssets", []):
2152
2174
  asset = asset.copy()
@@ -2260,6 +2282,27 @@ def render_with_remote_api(
2260
2282
  LogPrint(f" size: {config.get('width')}x{config.get('height')}", file=sys.stderr)
2261
2283
  LogPrint(f" total frames: {total_frames}", file=sys.stderr)
2262
2284
  sys.stderr.flush()
2285
+ return {
2286
+ "payload": payload,
2287
+ "render_path": render_path,
2288
+ "status_path": status_path,
2289
+ "effective_title": effective_title,
2290
+ "cover_composition_id": cover_composition_id,
2291
+ }
2292
+
2293
+
2294
+ def _submit_or_resume_remote_task(
2295
+ render_plan: dict,
2296
+ payload: dict,
2297
+ render_path: str,
2298
+ status_path: str,
2299
+ *,
2300
+ private_token: str,
2301
+ conversation_id: Optional[str],
2302
+ job_id: Optional[int],
2303
+ ) -> Optional[tuple[str, str]]:
2304
+
2305
+ import remote_renderer_client
2263
2306
 
2264
2307
 
2265
2308
 
@@ -2290,7 +2333,7 @@ def render_with_remote_api(
2290
2333
  "message": f"remote start_render failed: {exc}",
2291
2334
  "timestamp": now_iso(),
2292
2335
  })
2293
- return False
2336
+ return None
2294
2337
 
2295
2338
  LogPrint(f" āœ… task submitted, taskId={task_id}", file=sys.stderr)
2296
2339
  render_plan["remoteTaskId"] = task_id
@@ -2306,6 +2349,11 @@ def render_with_remote_api(
2306
2349
 
2307
2350
  _persist_plan_checkpoint(render_plan, job_id, private_token)
2308
2351
 
2352
+ return task_id, status_path
2353
+
2354
+
2355
+ def _remote_progress_reporter(render_plan: dict, task_id: str) -> Callable[[dict], None]:
2356
+
2309
2357
  last_progress = -1.0
2310
2358
  last_status: Optional[str] = None
2311
2359
  last_stage: Optional[str] = None
@@ -2364,6 +2412,24 @@ def render_with_remote_api(
2364
2412
  last_stage = stage
2365
2413
  last_emit_at = now
2366
2414
 
2415
+ return _on_progress
2416
+
2417
+
2418
+ def _poll_remote_task(
2419
+ render_plan: dict,
2420
+ task_id: str,
2421
+ status_path: str,
2422
+ *,
2423
+ private_token: str,
2424
+ poll_timeout: float,
2425
+ poll_interval: float,
2426
+ job_id: Optional[int],
2427
+ ) -> tuple[bool, Optional[dict]]:
2428
+
2429
+ import remote_renderer_client
2430
+
2431
+ _on_progress = _remote_progress_reporter(render_plan, task_id)
2432
+
2367
2433
 
2368
2434
 
2369
2435
 
@@ -2411,7 +2477,7 @@ def render_with_remote_api(
2411
2477
  f"ē”ØåŒäø€äøŖ job_id å†č°ƒäø€ę¬” render_video å³åÆē»­äøŠļ¼Œäøä¼šé‡ę–°ęø²ęŸ“ć€‚",
2412
2478
  flush=True,
2413
2479
  )
2414
- return False
2480
+ return False, None
2415
2481
  except Exception as exc:
2416
2482
  LogPrint(f"āŒ remote render polling failed: {exc}", file=sys.stderr)
2417
2483
  render_plan["status"] = "failed"
@@ -2426,10 +2492,21 @@ def render_with_remote_api(
2426
2492
  "message": f"remote poll_render failed: {exc}",
2427
2493
  "timestamp": now_iso(),
2428
2494
  })
2429
- return False
2495
+ return False, None
2430
2496
  finally:
2431
2497
 
2432
2498
  signal.signal(signal.SIGINT, prev_sigint)
2499
+ return True, result
2500
+
2501
+
2502
+ def _apply_remote_result(
2503
+ render_plan: dict,
2504
+ result: Optional[dict],
2505
+ *,
2506
+ effective_title: str,
2507
+ cover_composition_id: Optional[str],
2508
+ private_token: str,
2509
+ ) -> bool:
2433
2510
 
2434
2511
  file_url =(result or {}).get("fileUrl") or (result or {}).get("videoUrl")
2435
2512
  if not file_url:
@@ -2597,9 +2674,7 @@ def _log_render_plan_summary(render_plan: dict) -> None:
2597
2674
  pass
2598
2675
 
2599
2676
 
2600
- def main():
2601
-
2602
- atexit.register(print_billing_footer)
2677
+ def _build_arg_parser() -> argparse.ArgumentParser:
2603
2678
  parser = argparse.ArgumentParser(
2604
2679
  description="Video render tool — DSL + TemplateBinding → Remotion video.",
2605
2680
  formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -2672,154 +2747,164 @@ Examples:
2672
2747
  default=None,
2673
2748
  help="Test mode: every source=gen-video asset is short-circuited to this URL, no gen-video API call (env STUB_VIDEO_URL works too, but the explicit CLI flag is preferred to avoid cross-session leakage).",
2674
2749
  )
2750
+ return parser
2675
2751
 
2676
- args = parser.parse_args()
2677
2752
 
2678
- private_token = args.priv_token or os.environ.get("PRIV_TOKEN", "")
2753
+ def _load_plan_from_job(job_id: int, private_token: str) -> dict:
2679
2754
 
2680
2755
 
2681
- if args.priv_token:
2682
- os.environ["PRIV_TOKEN"] = private_token
2756
+ LogPrint(f"šŸ“‹ loading RenderPlan from database (jobId={job_id})...", file=sys.stderr)
2757
+ if not private_token:
2758
+ LogPrint("āŒ --job-id mode requires PRIV_TOKEN or --priv-token", file=sys.stderr)
2759
+ sys.exit(1)
2683
2760
 
2684
2761
 
2685
- if args.save_job and not private_token:
2686
- LogPrint("ā„¹ļø PRIV_TOKEN not configured; --save-job auto-disabled (will not write to database)", file=sys.stderr)
2687
- args.save_job = False
2688
2762
 
2689
- if args.job_id is not None and args.job_id > 0:
2763
+ from render_job_client import get_plan as rjc_get_plan
2764
+ try:
2765
+ render_plan_str = rjc_get_plan(job_id, private_token)
2766
+ except RuntimeError as e:
2767
+ LogPrint(f"āŒ failed to fetch RenderPlan: {e}", file=sys.stderr)
2768
+ sys.exit(1)
2769
+ render_plan = json.loads(render_plan_str)
2770
+ LogPrint(f"āœ… RenderPlan loaded (jobId={job_id})", file=sys.stderr)
2771
+ _log_render_plan_summary(render_plan)
2772
+ return render_plan
2690
2773
 
2691
- LogPrint(f"šŸ“‹ loading RenderPlan from database (jobId={args.job_id})...", file=sys.stderr)
2692
- if not private_token:
2693
- LogPrint("āŒ --job-id mode requires PRIV_TOKEN or --priv-token", file=sys.stderr)
2694
- sys.exit(1)
2695
2774
 
2775
+ def _load_plan_from_file(plan_path: str, dsl_path: str | None) -> dict:
2696
2776
 
2777
+ if not os.path.exists(plan_path):
2778
+ LogPrint(f"āŒ file does not exist: {plan_path}", file=sys.stderr)
2779
+ sys.exit(1)
2780
+ render_plan = load_json(plan_path)
2781
+ LogPrint(f"šŸ“‹ loaded existing RenderPlan: {plan_path}", file=sys.stderr)
2782
+ _log_render_plan_summary(render_plan)
2697
2783
 
2698
- from render_job_client import get_plan as rjc_get_plan
2784
+
2785
+
2786
+
2787
+ if dsl_path and os.path.exists(dsl_path):
2699
2788
  try:
2700
- render_plan_str = rjc_get_plan(args.job_id, private_token)
2701
- except RuntimeError as e:
2702
- LogPrint(f"āŒ failed to fetch RenderPlan: {e}", file=sys.stderr)
2703
- sys.exit(1)
2704
- render_plan = json.loads(render_plan_str)
2705
- LogPrint(f"āœ… RenderPlan loaded (jobId={args.job_id})", file=sys.stderr)
2706
- _log_render_plan_summary(render_plan)
2707
- elif args.render_plan:
2708
- if not os.path.exists(args.render_plan):
2709
- LogPrint(f"āŒ file does not exist: {args.render_plan}", file=sys.stderr)
2710
- sys.exit(1)
2711
- render_plan = load_json(args.render_plan)
2712
- LogPrint(f"šŸ“‹ loaded existing RenderPlan: {args.render_plan}", file=sys.stderr)
2713
- _log_render_plan_summary(render_plan)
2789
+ if os.path.getmtime(plan_path) < os.path.getmtime(dsl_path):
2790
+ LogPrint(
2791
+ f"āš ļø STALE RenderPlan? '{plan_path}' is OLDER than the DSL "
2792
+ f"'{dsl_path}'. You may be rendering a leftover plan from a previous "
2793
+ "run. Regenerate with --dsl ... --template-id ... (drop --render-plan) "
2794
+ "if the summary above doesn't match what you expect.",
2795
+ file=sys.stderr,
2796
+ )
2797
+ except OSError:
2798
+ pass
2799
+ return render_plan
2714
2800
 
2715
2801
 
2802
+ def _parse_dsl_json(dsl_json: str) -> dict:
2716
2803
 
2717
2804
 
2718
- if args.dsl and os.path.exists(args.dsl):
2719
- try:
2720
- if os.path.getmtime(args.render_plan) < os.path.getmtime(args.dsl):
2721
- LogPrint(
2722
- f"āš ļø STALE RenderPlan? '{args.render_plan}' is OLDER than the DSL "
2723
- f"'{args.dsl}'. You may be rendering a leftover plan from a previous "
2724
- "run. Regenerate with --dsl ... --template-id ... (drop --render-plan) "
2725
- "if the summary above doesn't match what you expect.",
2726
- file=sys.stderr,
2727
- )
2728
- except OSError:
2729
- pass
2730
- else:
2731
- if not args.dsl and not args.dsl_json:
2732
- LogPrint(
2733
- "āŒ render_video needs a DSL source. You provided neither --dsl/--dsl-json "
2734
- "nor --render-plan/--job-id.\n"
2735
- " Typical flow: 1) gen_script → DSL skeleton 2) prepare_video_assets "
2736
- "(returns a job_id) 3) render_video with job_id=<int>.\n"
2737
- " Or pass the DSL inline: render_video --dsl-json '<json>' --template-id <id>.",
2738
- file=sys.stderr,
2739
- )
2740
- sys.exit(1)
2741
2805
 
2742
- if not args.template_id:
2743
- LogPrint(
2744
- "āŒ render_video needs --template-id when rendering from --dsl/--dsl-json "
2745
- "(binding is computed in-memory from the template id; --binding is no longer supported).",
2746
- file=sys.stderr,
2747
- )
2806
+
2807
+
2808
+
2809
+
2810
+ try:
2811
+ dsl = json.loads(dsl_json)
2812
+ except json.JSONDecodeError:
2813
+ dsl = None
2814
+ if dsl is None:
2815
+ try:
2816
+
2817
+ _dsl_str = dsl_json
2818
+
2819
+ _dsl_str = _dsl_str.replace('\u201c', '"').replace('\u201d', '"')
2820
+ _dsl_str = _dsl_str.replace('\u2018', "'").replace('\u2019', "'")
2821
+
2822
+ _dsl_str = _dsl_str.replace('\uff1a', ':')
2823
+ _dsl_str = _dsl_str.replace('\uff0c', ',')
2824
+ _dsl_str = _dsl_str.replace('\uff08', '(')
2825
+ _dsl_str = _dsl_str.replace('\uff09', ')')
2826
+ _dsl_str = _dsl_str.replace('\u3010', '[')
2827
+ _dsl_str = _dsl_str.replace('\u3011', ']')
2828
+ _dsl_str = _dsl_str.replace('\uff3b', '[')
2829
+ _dsl_str = _dsl_str.replace('\uff3d', ']')
2830
+ _dsl_str = _dsl_str.replace('\uff5b', '{')
2831
+ _dsl_str = _dsl_str.replace('\uff5d', '}')
2832
+ dsl = json.loads(_dsl_str)
2833
+ LogPrint("āš ļø --dsl-json was not valid JSON; parsed after normalizing full-width punctuation", file=sys.stderr)
2834
+ except json.JSONDecodeError as e:
2835
+ LogPrint(f"āŒ --dsl-json parse failed: {e}", file=sys.stderr)
2748
2836
  sys.exit(1)
2837
+ return dsl
2749
2838
 
2750
2839
 
2751
- if args.dsl_json:
2840
+ def _load_dsl(args) -> dict:
2752
2841
 
2842
+ if not args.dsl and not args.dsl_json:
2843
+ LogPrint(
2844
+ "āŒ render_video needs a DSL source. You provided neither --dsl/--dsl-json "
2845
+ "nor --render-plan/--job-id.\n"
2846
+ " Typical flow: 1) gen_script → DSL skeleton 2) prepare_video_assets "
2847
+ "(returns a job_id) 3) render_video with job_id=<int>.\n"
2848
+ " Or pass the DSL inline: render_video --dsl-json '<json>' --template-id <id>.",
2849
+ file=sys.stderr,
2850
+ )
2851
+ sys.exit(1)
2753
2852
 
2853
+ if not args.template_id:
2854
+ LogPrint(
2855
+ "āŒ render_video needs --template-id when rendering from --dsl/--dsl-json "
2856
+ "(binding is computed in-memory from the template id; --binding is no longer supported).",
2857
+ file=sys.stderr,
2858
+ )
2859
+ sys.exit(1)
2754
2860
 
2755
2861
 
2862
+ if args.dsl_json:
2863
+ dsl = _parse_dsl_json(args.dsl_json)
2864
+ else:
2865
+ if not os.path.exists(args.dsl):
2866
+ LogPrint(f"āŒ file does not exist: {args.dsl}", file=sys.stderr)
2867
+ sys.exit(1)
2868
+ dsl = load_json(args.dsl)
2756
2869
 
2757
2870
 
2758
- try:
2759
- dsl = json.loads(args.dsl_json)
2760
- except json.JSONDecodeError:
2761
- dsl = None
2762
- if dsl is None:
2763
- try:
2871
+ if "version" not in dsl:
2872
+ dsl["version"] = "v1alpha1"
2873
+ LogPrint("āš ļø DSL missing version field; auto-filled with v1alpha1", file=sys.stderr)
2764
2874
 
2765
- _dsl_str = args.dsl_json
2766
-
2767
- _dsl_str = _dsl_str.replace('\u201c', '"').replace('\u201d', '"')
2768
- _dsl_str = _dsl_str.replace('\u2018', "'").replace('\u2019', "'")
2769
-
2770
- _dsl_str = _dsl_str.replace('\uff1a', ':')
2771
- _dsl_str = _dsl_str.replace('\uff0c', ',')
2772
- _dsl_str = _dsl_str.replace('\uff08', '(')
2773
- _dsl_str = _dsl_str.replace('\uff09', ')')
2774
- _dsl_str = _dsl_str.replace('\u3010', '[')
2775
- _dsl_str = _dsl_str.replace('\u3011', ']')
2776
- _dsl_str = _dsl_str.replace('\uff3b', '[')
2777
- _dsl_str = _dsl_str.replace('\uff3d', ']')
2778
- _dsl_str = _dsl_str.replace('\uff5b', '{')
2779
- _dsl_str = _dsl_str.replace('\uff5d', '}')
2780
- dsl = json.loads(_dsl_str)
2781
- LogPrint("āš ļø --dsl-json was not valid JSON; parsed after normalizing full-width punctuation", file=sys.stderr)
2782
- except json.JSONDecodeError as e:
2783
- LogPrint(f"āŒ --dsl-json parse failed: {e}", file=sys.stderr)
2784
- sys.exit(1)
2785
- else:
2786
- if not os.path.exists(args.dsl):
2787
- LogPrint(f"āŒ file does not exist: {args.dsl}", file=sys.stderr)
2788
- sys.exit(1)
2789
- dsl = load_json(args.dsl)
2875
+ return dsl
2790
2876
 
2791
2877
 
2792
- if "version" not in dsl:
2793
- dsl["version"] = "v1alpha1"
2794
- LogPrint("āš ļø DSL missing version field; auto-filled with v1alpha1", file=sys.stderr)
2878
+ def _reject_skeleton_dsl(dsl: dict) -> None:
2795
2879
 
2796
2880
 
2797
2881
 
2882
+ _skeleton_offenders: list[str] = []
2883
+ _skel_markers = ("[éŖØęž¶å¾…å”«å……]", "[skeleton placeholder]", "čæ™ę˜Æéœ€č¦ę›æę¢ēš„å ä½ę–‡ę”ˆ")
2884
+ for _scene in dsl.get("scenes", []) or []:
2885
+ _nar = ((_scene.get("audio") or {}).get("narration") or {})
2886
+ _sid = _scene.get("id", "?")
2887
+ if _nar.get("needsFill") is True:
2888
+ _skeleton_offenders.append(f"scene {_sid}: narration.needsFill=true")
2889
+ continue
2890
+ _t = _nar.get("text") or ""
2891
+ if any(m in _t for m in _skel_markers):
2892
+ _skeleton_offenders.append(f"scene {_sid}: narration.text still has the skeleton marker")
2893
+ if _skeleton_offenders:
2894
+ LogPrint("āŒ DSL narration is still the gen_script skeleton and was not replaced with real content; refusing to continue:", file=sys.stderr)
2895
+ for _off in _skeleton_offenders[:5]:
2896
+ LogPrint(f" - {_off}", file=sys.stderr)
2897
+ if len(_skeleton_offenders) > 5:
2898
+ LogPrint(f" ... ({len(_skeleton_offenders)} total)", file=sys.stderr)
2899
+ LogPrint(
2900
+ " Fill audio.narration.text with the real narration for every scene "
2901
+ "and remove the needsFill field, then retry.",
2902
+ file=sys.stderr,
2903
+ )
2904
+ sys.exit(2)
2798
2905
 
2799
- _skeleton_offenders: list[str] = []
2800
- _skel_markers = ("[éŖØęž¶å¾…å”«å……]", "[skeleton placeholder]", "čæ™ę˜Æéœ€č¦ę›æę¢ēš„å ä½ę–‡ę”ˆ")
2801
- for _scene in dsl.get("scenes", []) or []:
2802
- _nar = ((_scene.get("audio") or {}).get("narration") or {})
2803
- _sid = _scene.get("id", "?")
2804
- if _nar.get("needsFill") is True:
2805
- _skeleton_offenders.append(f"scene {_sid}: narration.needsFill=true")
2806
- continue
2807
- _t = _nar.get("text") or ""
2808
- if any(m in _t for m in _skel_markers):
2809
- _skeleton_offenders.append(f"scene {_sid}: narration.text still has the skeleton marker")
2810
- if _skeleton_offenders:
2811
- LogPrint("āŒ DSL narration is still the gen_script skeleton and was not replaced with real content; refusing to continue:", file=sys.stderr)
2812
- for _off in _skeleton_offenders[:5]:
2813
- LogPrint(f" - {_off}", file=sys.stderr)
2814
- if len(_skeleton_offenders) > 5:
2815
- LogPrint(f" ... ({len(_skeleton_offenders)} total)", file=sys.stderr)
2816
- LogPrint(
2817
- " Fill audio.narration.text with the real narration for every scene "
2818
- "and remove the needsFill field, then retry.",
2819
- file=sys.stderr,
2820
- )
2821
- sys.exit(2)
2822
2906
 
2907
+ def _plan_from_dsl(dsl: dict, args) -> tuple[dict, dict]:
2823
2908
 
2824
2909
 
2825
2910
 
@@ -2830,44 +2915,49 @@ Examples:
2830
2915
 
2831
2916
 
2832
2917
 
2833
- _hydrate_dsl_payload(dsl, args.template_id, args.source_document_url)
2834
2918
 
2919
+ _hydrate_dsl_payload(dsl, args.template_id, args.source_document_url)
2835
2920
 
2836
2921
 
2837
- _compose_image_prompts(dsl, args.template_id)
2838
2922
 
2839
- errors = validate_dsl(dsl)
2840
- if errors:
2841
- LogPrint("āŒ DSL validation failed:", file=sys.stderr)
2842
- for err in errors:
2843
- LogPrint(f" - {err}", file=sys.stderr)
2844
- sys.exit(1)
2923
+ _compose_image_prompts(dsl, args.template_id)
2845
2924
 
2925
+ errors = validate_dsl(dsl)
2926
+ if errors:
2927
+ LogPrint("āŒ DSL validation failed:", file=sys.stderr)
2928
+ for err in errors:
2929
+ LogPrint(f" - {err}", file=sys.stderr)
2930
+ sys.exit(1)
2846
2931
 
2847
2932
 
2848
2933
 
2849
2934
 
2850
2935
 
2851
- binding = auto_bind_template(dsl, args.template_id)
2852
2936
 
2853
- LogPrint(f"šŸ“‹ Building RenderPlan...", file=sys.stderr)
2854
- render_plan = build_render_plan(dsl, binding)
2855
- render_plan["_dsl_assets"] = dsl.get("assets", [])
2937
+ binding = auto_bind_template(dsl, args.template_id)
2856
2938
 
2939
+ LogPrint(f"šŸ“‹ Building RenderPlan...", file=sys.stderr)
2940
+ render_plan = build_render_plan(dsl, binding)
2941
+ render_plan["_dsl_assets"] = dsl.get("assets", [])
2942
+ return render_plan, binding
2857
2943
 
2858
2944
 
2859
- if args.job_id or args.render_plan:
2860
- from registry_loader import get_template
2861
- from required_props import validate_required_props
2862
- try:
2863
- template = get_template(render_plan.get("templateId", ""), include_all_statuses=True)
2864
- if not template:
2865
- raise ValueError("Cannot validate RenderPlan: template metadata unavailable")
2866
- validate_required_props(template, render_plan.get("timeline", []))
2867
- except ValueError as exc:
2868
- LogPrint(f"āŒ {exc}", file=sys.stderr)
2869
- sys.exit(2)
2945
+ def _validate_saved_plan(render_plan: dict) -> None:
2946
+
2947
+
2948
+ from registry_loader import get_template
2949
+ from required_props import validate_required_props
2950
+ try:
2951
+ template = get_template(render_plan.get("templateId", ""), include_all_statuses=True)
2952
+ if not template:
2953
+ raise ValueError("Cannot validate RenderPlan: template metadata unavailable")
2954
+ validate_required_props(template, render_plan.get("timeline", []))
2955
+ except ValueError as exc:
2956
+ LogPrint(f"āŒ {exc}", file=sys.stderr)
2957
+ sys.exit(2)
2870
2958
 
2959
+
2960
+ def _reject_bad_asset_refs(render_plan: dict) -> None:
2871
2961
  from video_dsl.runtime.payload_contract import plan_asset_errors
2872
2962
  reference_errors = plan_asset_errors(render_plan)
2873
2963
  if reference_errors:
@@ -2876,212 +2966,197 @@ Examples:
2876
2966
  LogPrint(f" - {error}", file=sys.stderr)
2877
2967
  sys.exit(2)
2878
2968
 
2879
- if not args.skip_asset_resolve:
2880
2969
 
2970
+ def _resolve_plan_assets(render_plan: dict, args, private_token: str) -> dict:
2881
2971
 
2882
2972
 
2883
- stub_image_url = args.stub_image_url
2884
- stub_video_url = args.stub_video_url
2885
- if stub_image_url is None:
2886
- env_v = os.environ.get("STUB_IMAGE_URL", "")
2887
- if env_v:
2888
- LogPrint(f"āš ļø detected env var STUB_IMAGE_URL={env_v}; using it as the image stub URL (pass --stub-image-url explicitly or unset the env var)", file=sys.stderr)
2889
- stub_image_url = env_v
2890
- if stub_video_url is None:
2891
- env_v = os.environ.get("STUB_VIDEO_URL", "")
2892
- if env_v:
2893
- LogPrint(f"āš ļø detected env var STUB_VIDEO_URL={env_v}; using it as the video stub URL (pass --stub-video-url explicitly or unset the env var)", file=sys.stderr)
2894
- stub_video_url = env_v
2895
-
2896
- if stub_image_url or stub_video_url:
2897
- stubbed = apply_stub_urls(
2898
- render_plan,
2899
- stub_image_url=stub_image_url,
2900
- stub_video_url=stub_video_url,
2901
- )
2902
- if stubbed:
2903
- LogPrint(f"🧪 Stub mode: {stubbed} asset(s) using a stub URL; generation API skipped", file=sys.stderr)
2904
- else:
2905
- LogPrint(f"🧪 Stub mode is on but no matching assets were found (no pending gen-image / gen-video resources)", file=sys.stderr)
2906
- pending_count = sum(1 for a in render_plan["resolvedAssets"] if a["status"] == "pending")
2907
- if pending_count > 0:
2908
-
2909
-
2910
-
2911
- if not private_token:
2912
- LogPrint("āŒ asset generation needs an auth token, but PRIV_TOKEN env var is not set and --priv-token was not passed", file=sys.stderr)
2913
- LogPrint(" configure it one of these ways:", file=sys.stderr)
2914
- LogPrint(" 1. export PRIV_TOKEN=<your-token>", file=sys.stderr)
2915
- LogPrint(" 2. python3 render_video.py --priv-token <your-token> ...", file=sys.stderr)
2916
- sys.exit(1)
2917
-
2918
- LogPrint(f"šŸ” Resolving {pending_count} missing asset(s)...", file=sys.stderr)
2919
- render_plan = resolve_assets(
2920
- render_plan,
2921
- private_token=private_token,
2922
- max_retries=args.max_asset_retries,
2923
- timeout=args.asset_timeout,
2924
- defer_digital_human=True,
2925
- )
2973
+
2974
+
2975
+ stub_image_url = args.stub_image_url
2976
+ stub_video_url = args.stub_video_url
2977
+ if stub_image_url is None:
2978
+ env_v = os.environ.get("STUB_IMAGE_URL", "")
2979
+ if env_v:
2980
+ LogPrint(f"āš ļø detected env var STUB_IMAGE_URL={env_v}; using it as the image stub URL (pass --stub-image-url explicitly or unset the env var)", file=sys.stderr)
2981
+ stub_image_url = env_v
2982
+ if stub_video_url is None:
2983
+ env_v = os.environ.get("STUB_VIDEO_URL", "")
2984
+ if env_v:
2985
+ LogPrint(f"āš ļø detected env var STUB_VIDEO_URL={env_v}; using it as the video stub URL (pass --stub-video-url explicitly or unset the env var)", file=sys.stderr)
2986
+ stub_video_url = env_v
2987
+
2988
+ if stub_image_url or stub_video_url:
2989
+ stubbed = apply_stub_urls(
2990
+ render_plan,
2991
+ stub_image_url=stub_image_url,
2992
+ stub_video_url=stub_video_url,
2993
+ )
2994
+ if stubbed:
2995
+ LogPrint(f"🧪 Stub mode: {stubbed} asset(s) using a stub URL; generation API skipped", file=sys.stderr)
2926
2996
  else:
2927
- LogPrint(f"āœ… all assets ready", file=sys.stderr)
2928
- render_plan["status"] = "assets-ready"
2997
+ LogPrint(f"🧪 Stub mode is on but no matching assets were found (no pending gen-image / gen-video resources)", file=sys.stderr)
2998
+ pending_count = sum(1 for a in render_plan["resolvedAssets"] if a["status"] == "pending")
2999
+ if pending_count > 0:
3000
+
2929
3001
 
2930
- if not args.skip_asset_resolve:
2931
- render_plan = adjust_timeline_to_audio(render_plan)
2932
3002
 
3003
+ if not private_token:
3004
+ LogPrint("āŒ asset generation needs an auth token, but PRIV_TOKEN env var is not set and --priv-token was not passed", file=sys.stderr)
3005
+ LogPrint(" configure it one of these ways:", file=sys.stderr)
3006
+ LogPrint(" 1. export PRIV_TOKEN=<your-token>", file=sys.stderr)
3007
+ LogPrint(" 2. python3 render_video.py --priv-token <your-token> ...", file=sys.stderr)
3008
+ sys.exit(1)
2933
3009
 
2934
- render_plan = resolve_digital_human_segments(
3010
+ LogPrint(f"šŸ” Resolving {pending_count} missing asset(s)...", file=sys.stderr)
3011
+ render_plan = resolve_assets(
2935
3012
  render_plan,
2936
3013
  private_token=private_token,
2937
3014
  max_retries=args.max_asset_retries,
2938
3015
  timeout=args.asset_timeout,
3016
+ defer_digital_human=True,
2939
3017
  )
3018
+ else:
3019
+ LogPrint(f"āœ… all assets ready", file=sys.stderr)
3020
+ render_plan["status"] = "assets-ready"
2940
3021
 
2941
- render_plan.pop("_dsl_assets", None)
3022
+ render_plan = adjust_timeline_to_audio(render_plan)
2942
3023
 
2943
- output_path = os.path.join(OUTPUT_DIR, "video.mp4")
2944
- rp_output = args.render_plan_output or os.path.join(OUTPUT_DIR, "render-plan.json")
2945
3024
 
3025
+ render_plan = resolve_digital_human_segments(
3026
+ render_plan,
3027
+ private_token=private_token,
3028
+ max_retries=args.max_asset_retries,
3029
+ timeout=args.asset_timeout,
3030
+ )
3031
+ return render_plan
2946
3032
 
2947
- if args.save_render_plan or not args.save_job:
2948
- validate_and_fix_render_plan(render_plan)
2949
- save_json(render_plan, rp_output)
2950
- LogPrint(f"šŸ’¾ RenderPlan saved: {rp_output}", file=sys.stderr)
2951
3033
 
3034
+ def _persist_render_plan(render_plan: dict, args, private_token: str, dsl: dict | None, binding: dict | None) -> None:
2952
3035
 
2953
3036
 
3037
+ validate_and_fix_render_plan(render_plan)
2954
3038
 
2955
- if args.save_job:
3039
+ from render_job_client import create_job as rjc_create_job, save_plan as rjc_save_plan
3040
+ render_plan_json_str = json.dumps(render_plan, ensure_ascii=False)
2956
3041
 
2957
- validate_and_fix_render_plan(render_plan)
3042
+ _job_id = args.job_id
3043
+ if not _job_id:
2958
3044
 
2959
- from render_job_client import create_job as rjc_create_job, save_plan as rjc_save_plan
2960
- render_plan_json_str = json.dumps(render_plan, ensure_ascii=False)
2961
3045
 
2962
- _job_id = args.job_id
2963
- if not _job_id:
3046
+ _dsl_obj = dsl
3047
+ _binding_obj = binding
3048
+ _meta_summary: dict = {}
2964
3049
 
2965
3050
 
2966
- _locals = locals()
2967
- _dsl_obj = _locals.get("dsl")
2968
- _binding_obj = _locals.get("binding")
2969
- _meta_summary: dict = {}
3051
+ if _dsl_obj:
3052
+ _src_meta = (_dsl_obj.get("meta") or {})
3053
+ for _k in ("title", "topic", "platform", "templateId", "targetDuration"):
3054
+ _v = _src_meta.get(_k)
3055
+ if _v:
3056
+ _meta_summary[_k] = _v
3057
+ _ratio = (_dsl_obj.get("global") or {}).get("aspectRatio")
3058
+ if _ratio:
3059
+ _meta_summary["aspectRatio"] = _ratio
3060
+ else:
3061
+ for _k in ("title", "templateId", "targetDuration"):
3062
+ _v = render_plan.get(_k)
3063
+ if _v:
3064
+ _meta_summary[_k] = _v
3065
+ _rc = render_plan.get("renderConfig") or {}
3066
+ if _rc.get("width") and _rc.get("height"):
3067
+ _meta_summary["resolution"] = f"{_rc['width']}x{_rc['height']}"
3068
+ if _binding_obj and isinstance(_binding_obj, dict):
3069
+ _tid = _binding_obj.get("templateId")
3070
+ if _tid and "templateId" not in _meta_summary:
3071
+ _meta_summary["templateId"] = _tid
3072
+ _dsl_str = json.dumps(_dsl_obj, ensure_ascii=False) if _dsl_obj else ""
3073
+ try:
3074
+ _job_id = rjc_create_job(
3075
+ private_token,
3076
+ dsl=_dsl_str,
3077
+ dsl_meta=_meta_summary or None,
3078
+ )
3079
+ LogPrint(f"āœ… render job created: jobId={_job_id}", file=sys.stderr)
3080
+ except RuntimeError as e:
3081
+ LogPrint(f"āš ļø failed to create render job (--save-job auto-disabled, rendering continues): {e}", file=sys.stderr)
3082
+ args.save_job = False
3083
+ _job_id = None
3084
+ if args.save_job and _job_id:
3085
+ try:
3086
+ rjc_save_plan(_job_id, render_plan_json_str, private_token)
3087
+ LogPrint(f"āœ… RenderPlan saved to database: jobId={_job_id}", file=sys.stderr)
2970
3088
 
3089
+ args.job_id = _job_id
3090
+ print(f"\nšŸ“¦ render job jobId: {_job_id}")
3091
+ except RuntimeError as e:
3092
+ LogPrint(f"āš ļø failed to save RenderPlan to database (--save-job auto-disabled, rendering continues): {e}", file=sys.stderr)
3093
+ args.save_job = False
2971
3094
 
2972
- if _dsl_obj:
2973
- _src_meta = (_dsl_obj.get("meta") or {})
2974
- for _k in ("title", "topic", "platform", "templateId", "targetDuration"):
2975
- _v = _src_meta.get(_k)
2976
- if _v:
2977
- _meta_summary[_k] = _v
2978
- _ratio = (_dsl_obj.get("global") or {}).get("aspectRatio")
2979
- if _ratio:
2980
- _meta_summary["aspectRatio"] = _ratio
2981
- else:
2982
- for _k in ("title", "templateId", "targetDuration"):
2983
- _v = render_plan.get(_k)
2984
- if _v:
2985
- _meta_summary[_k] = _v
2986
- _rc = render_plan.get("renderConfig") or {}
2987
- if _rc.get("width") and _rc.get("height"):
2988
- _meta_summary["resolution"] = f"{_rc['width']}x{_rc['height']}"
2989
- if _binding_obj and isinstance(_binding_obj, dict):
2990
- _tid = _binding_obj.get("templateId")
2991
- if _tid and "templateId" not in _meta_summary:
2992
- _meta_summary["templateId"] = _tid
2993
- _dsl_str = json.dumps(_dsl_obj, ensure_ascii=False) if _dsl_obj else ""
2994
- try:
2995
- _job_id = rjc_create_job(
2996
- private_token,
2997
- dsl=_dsl_str,
2998
- dsl_meta=_meta_summary or None,
2999
- )
3000
- LogPrint(f"āœ… render job created: jobId={_job_id}", file=sys.stderr)
3001
- except RuntimeError as e:
3002
- LogPrint(f"āš ļø failed to create render job (--save-job auto-disabled, rendering continues): {e}", file=sys.stderr)
3003
- args.save_job = False
3004
- _job_id = None
3005
- if args.save_job and _job_id:
3006
- try:
3007
- rjc_save_plan(_job_id, render_plan_json_str, private_token)
3008
- LogPrint(f"āœ… RenderPlan saved to database: jobId={_job_id}", file=sys.stderr)
3009
3095
 
3010
- args.job_id = _job_id
3011
- print(f"\nšŸ“¦ render job jobId: {_job_id}")
3012
- except RuntimeError as e:
3013
- LogPrint(f"āš ļø failed to save RenderPlan to database (--save-job auto-disabled, rendering continues): {e}", file=sys.stderr)
3014
- args.save_job = False
3096
+ def _print_resolve_only_summary(render_plan: dict) -> int:
3015
3097
 
3016
- if args.resolve_only:
3017
- generated = sum(1 for a in render_plan["resolvedAssets"] if a["status"] == "generated")
3018
- failed = sum(1 for a in render_plan["resolvedAssets"] if a["status"] == "failed")
3019
- total = len(render_plan["resolvedAssets"])
3020
- LogPrint(f"\nšŸ“Š Asset resolution finished (resolve-only mode): total {total}, generated {generated}, failed {failed}", file=sys.stderr)
3098
+ generated = sum(1 for a in render_plan["resolvedAssets"] if a["status"] == "generated")
3099
+ failed = sum(1 for a in render_plan["resolvedAssets"] if a["status"] == "failed")
3100
+ total = len(render_plan["resolvedAssets"])
3101
+ LogPrint(f"\nšŸ“Š Asset resolution finished (resolve-only mode): total {total}, generated {generated}, failed {failed}", file=sys.stderr)
3021
3102
 
3022
- fps = render_plan.get("renderConfig", {}).get("fps", 30)
3023
- target_dur = render_plan.get("targetDuration")
3024
- actual_dur = render_plan.get("renderConfig", {}).get("totalDuration", 0)
3103
+ fps = render_plan.get("renderConfig", {}).get("fps", 30)
3104
+ target_dur = render_plan.get("targetDuration")
3105
+ actual_dur = render_plan.get("renderConfig", {}).get("totalDuration", 0)
3025
3106
 
3026
3107
 
3027
- print(f"\nšŸ“Š Asset resolution finished: total {total}, generated {generated}, failed {failed}")
3028
- if target_dur:
3029
- diff = actual_dur - target_dur
3030
- sign = "+" if diff >= 0 else ""
3031
- print(f"ā± Actual duration: {actual_dur}s (target {target_dur}s, {sign}{diff:.1f}s)")
3032
- else:
3033
- print(f"ā± Actual duration: {actual_dur}s")
3034
-
3035
- print(f"\nšŸŽ¬ Scene timeline:")
3036
- for entry in render_plan.get("timeline", []):
3037
- scene_dur = round(entry.get("durationFrames", 0) / fps, 1)
3038
- scene_id = entry.get("sceneId", "?")
3039
- print(f" {scene_id:<25} {scene_dur:>6.1f}s")
3040
-
3041
- audio_assets = [a for a in render_plan.get("resolvedAssets", [])
3042
- if a.get("type") == "audio" and a.get("status") == "generated"]
3043
- if audio_assets:
3044
- print(f"\nšŸ”Š TTS audio:")
3045
- for asset in audio_assets:
3046
- dur_s = round((asset.get("duration") or 0) / 1000, 1)
3047
- url = asset.get("url", "")
3048
- print(f" {asset['assetId']:<25} {dur_s:>6.1f}s {url}")
3049
-
3050
- image_assets = [a for a in render_plan.get("resolvedAssets", [])
3051
- if a.get("type") == "image" and a.get("status") == "generated"]
3052
- if image_assets:
3053
- print(f"\nšŸ–¼ Image assets:")
3054
- for asset in image_assets:
3055
- url = asset.get("url", "")
3056
- print(f" {asset['assetId']:<25} {url}")
3057
-
3058
-
3059
-
3060
-
3061
- avatar_assets = [a for a in render_plan.get("resolvedAssets", []) if a.get("type") == "avatar"]
3062
- avatar_scenes: dict = {}
3063
- for entry in render_plan.get("timeline", []):
3064
- ref = (entry.get("props") or {}).get("avatarAssetId")
3065
- if ref:
3066
- avatar_scenes.setdefault(ref, []).append(entry.get("sceneId", "?"))
3067
- if avatar_assets:
3068
- print(f"\nšŸ§‘ Digital-human segments:")
3069
- for asset in avatar_assets:
3070
- scenes_on = ", ".join(avatar_scenes.get(asset["assetId"], [])) or "(no scene)"
3071
- print(f" {asset['assetId']:<25} {asset.get('status', '?'):<10} scenes: {scenes_on} {asset.get('url', '')}")
3072
- else:
3073
- print(f"\nšŸ§‘ Digital-human segments: none (this render has no presenter)")
3108
+ print(f"\nšŸ“Š Asset resolution finished: total {total}, generated {generated}, failed {failed}")
3109
+ if target_dur:
3110
+ diff = actual_dur - target_dur
3111
+ sign = "+" if diff >= 0 else ""
3112
+ print(f"ā± Actual duration: {actual_dur}s (target {target_dur}s, {sign}{diff:.1f}s)")
3113
+ else:
3114
+ print(f"ā± Actual duration: {actual_dur}s")
3115
+
3116
+ print(f"\nšŸŽ¬ Scene timeline:")
3117
+ for entry in render_plan.get("timeline", []):
3118
+ scene_dur = round(entry.get("durationFrames", 0) / fps, 1)
3119
+ scene_id = entry.get("sceneId", "?")
3120
+ print(f" {scene_id:<25} {scene_dur:>6.1f}s")
3121
+
3122
+ audio_assets = [a for a in render_plan.get("resolvedAssets", [])
3123
+ if a.get("type") == "audio" and a.get("status") == "generated"]
3124
+ if audio_assets:
3125
+ print(f"\nšŸ”Š TTS audio:")
3126
+ for asset in audio_assets:
3127
+ dur_s = round((asset.get("duration") or 0) / 1000, 1)
3128
+ url = asset.get("url", "")
3129
+ print(f" {asset['assetId']:<25} {dur_s:>6.1f}s {url}")
3130
+
3131
+ image_assets = [a for a in render_plan.get("resolvedAssets", [])
3132
+ if a.get("type") == "image" and a.get("status") == "generated"]
3133
+ if image_assets:
3134
+ print(f"\nšŸ–¼ Image assets:")
3135
+ for asset in image_assets:
3136
+ url = asset.get("url", "")
3137
+ print(f" {asset['assetId']:<25} {url}")
3074
3138
 
3075
3139
 
3076
- if failed > 0:
3077
- sys.exit(1)
3078
- return
3079
3140
 
3080
- if render_plan["status"] == "failed":
3081
- LogPrint("āŒ asset resolution had failures; cannot continue rendering", file=sys.stderr)
3082
- if not args.save_job:
3083
- save_json(render_plan, rp_output)
3084
- sys.exit(1)
3141
+
3142
+ avatar_assets = [a for a in render_plan.get("resolvedAssets", []) if a.get("type") == "avatar"]
3143
+ avatar_scenes: dict = {}
3144
+ for entry in render_plan.get("timeline", []):
3145
+ ref = (entry.get("props") or {}).get("avatarAssetId")
3146
+ if ref:
3147
+ avatar_scenes.setdefault(ref, []).append(entry.get("sceneId", "?"))
3148
+ if avatar_assets:
3149
+ print(f"\nšŸ§‘ Digital-human segments:")
3150
+ for asset in avatar_assets:
3151
+ scenes_on = ", ".join(avatar_scenes.get(asset["assetId"], [])) or "(no scene)"
3152
+ print(f" {asset['assetId']:<25} {asset.get('status', '?'):<10} scenes: {scenes_on} {asset.get('url', '')}")
3153
+ else:
3154
+ print(f"\nšŸ§‘ Digital-human segments: none (this render has no presenter)")
3155
+
3156
+ return failed
3157
+
3158
+
3159
+ def _render(render_plan: dict, args, private_token: str, output_path: str) -> tuple[bool, str, str]:
3085
3160
 
3086
3161
  render_mode = args.renderer
3087
3162
  upload_title_hint = (
@@ -3114,6 +3189,75 @@ Examples:
3114
3189
  LogPrint(f"šŸ’» Using local render mode", file=sys.stderr)
3115
3190
  render_plan["renderMode"] = "local"
3116
3191
  success = render_with_local_cli(render_plan, output_path)
3192
+ return success, render_mode, upload_title_hint
3193
+
3194
+
3195
+ def _print_remote_playback(render_plan: dict, upload_info: dict, remote_url: str, playback_url: str) -> None:
3196
+
3197
+
3198
+ effective_playback = playback_url or remote_url
3199
+ if effective_playback and not effective_playback.startswith("vod://"):
3200
+ print(f"\nšŸŽ¬ Video playback URL: {effective_playback}")
3201
+
3202
+
3203
+ if upload_info.get("coverUrl"):
3204
+ print(f"šŸ–¼ļø Cover image URL: {upload_info['coverUrl']}")
3205
+
3206
+
3207
+
3208
+
3209
+
3210
+
3211
+
3212
+
3213
+
3214
+
3215
+
3216
+
3217
+ asset = {"url": effective_playback}
3218
+ cover = upload_info.get("coverUrl")
3219
+ if cover:
3220
+ asset["coverUrl"] = cover
3221
+ total_duration = (render_plan.get("renderConfig") or {}).get("totalDuration")
3222
+ if isinstance(total_duration, (int, float)) and total_duration > 0:
3223
+ asset["durationSec"] = total_duration
3224
+ print("__render_video_asset__ " + json.dumps(asset, ensure_ascii=False))
3225
+
3226
+
3227
+ def _upload_local_render(output_path: str, upload_title_hint: str, manifest: dict, manifest_path: str | None) -> None:
3228
+
3229
+ try:
3230
+ from upload_video import upload_media_file
3231
+
3232
+ upload_title = upload_title_hint
3233
+ LogPrint(f"\nšŸ“¤ Uploading video to OSS...", file=sys.stderr)
3234
+ upload_result = upload_media_file(
3235
+ file_path=output_path,
3236
+ title=upload_title,
3237
+ role="final_video",
3238
+ retention="output",
3239
+ )
3240
+ manifest["upload"] = {
3241
+ "fileUrl": upload_result["fileUrl"],
3242
+ "title": upload_title,
3243
+ "uploadedAt": now_iso(),
3244
+
3245
+ "fileId": upload_result.get("fileId"),
3246
+ }
3247
+
3248
+ if upload_result.get("fileUrl"):
3249
+ print(f"\nšŸŽ¬ Video playback URL: {upload_result['fileUrl']}")
3250
+ if manifest_path:
3251
+ save_json(manifest, manifest_path)
3252
+ LogPrint(f" Manifest updated: {manifest_path}", file=sys.stderr)
3253
+ except Exception as e:
3254
+ LogPrint(f"\nāš ļø upload failed (the video stays local): {e}", file=sys.stderr)
3255
+
3256
+
3257
+ def _report_render(
3258
+ render_plan: dict, args, private_token: str, success: bool, render_mode: str,
3259
+ upload_title_hint: str, output_path: str, rp_output: str,
3260
+ ) -> None:
3117
3261
 
3118
3262
 
3119
3263
  if not args.save_job:
@@ -3145,34 +3289,7 @@ Examples:
3145
3289
  LogPrint(f" Remote video: {remote_url}", file=sys.stderr)
3146
3290
  if playback_url:
3147
3291
  LogPrint(f" Playback URL: {playback_url}", file=sys.stderr)
3148
-
3149
- effective_playback = playback_url or remote_url
3150
- if effective_playback and not effective_playback.startswith("vod://"):
3151
- print(f"\nšŸŽ¬ Video playback URL: {effective_playback}")
3152
-
3153
-
3154
- if upload_info.get("coverUrl"):
3155
- print(f"šŸ–¼ļø Cover image URL: {upload_info['coverUrl']}")
3156
-
3157
-
3158
-
3159
-
3160
-
3161
-
3162
-
3163
-
3164
-
3165
-
3166
-
3167
-
3168
- asset = {"url": effective_playback}
3169
- cover = upload_info.get("coverUrl")
3170
- if cover:
3171
- asset["coverUrl"] = cover
3172
- total_duration = (render_plan.get("renderConfig") or {}).get("totalDuration")
3173
- if isinstance(total_duration, (int, float)) and total_duration > 0:
3174
- asset["durationSec"] = total_duration
3175
- print("__render_video_asset__ " + json.dumps(asset, ensure_ascii=False))
3292
+ _print_remote_playback(render_plan, upload_info, remote_url, playback_url)
3176
3293
  else:
3177
3294
  LogPrint(f" Video: {output_path}", file=sys.stderr)
3178
3295
  if args.save_job:
@@ -3189,32 +3306,7 @@ Examples:
3189
3306
  save_json(manifest, manifest_path)
3190
3307
  LogPrint(f" Manifest updated (remote fileUrl): {manifest_path}", file=sys.stderr)
3191
3308
  elif args.upload and not args.no_upload:
3192
- try:
3193
- from upload_video import upload_media_file
3194
-
3195
- upload_title = upload_title_hint
3196
- LogPrint(f"\nšŸ“¤ Uploading video to OSS...", file=sys.stderr)
3197
- upload_result = upload_media_file(
3198
- file_path=output_path,
3199
- title=upload_title,
3200
- role="final_video",
3201
- retention="output",
3202
- )
3203
- manifest["upload"] = {
3204
- "fileUrl": upload_result["fileUrl"],
3205
- "title": upload_title,
3206
- "uploadedAt": now_iso(),
3207
-
3208
- "fileId": upload_result.get("fileId"),
3209
- }
3210
-
3211
- if upload_result.get("fileUrl"):
3212
- print(f"\nšŸŽ¬ Video playback URL: {upload_result['fileUrl']}")
3213
- if manifest_path:
3214
- save_json(manifest, manifest_path)
3215
- LogPrint(f" Manifest updated: {manifest_path}", file=sys.stderr)
3216
- except Exception as e:
3217
- LogPrint(f"\nāš ļø upload failed (the video stays local): {e}", file=sys.stderr)
3309
+ _upload_local_render(output_path, upload_title_hint, manifest, manifest_path)
3218
3310
 
3219
3311
 
3220
3312
  if args.save_job and args.job_id:
@@ -3229,5 +3321,75 @@ Examples:
3229
3321
  sys.exit(1)
3230
3322
 
3231
3323
 
3324
+ def main():
3325
+
3326
+ atexit.register(print_billing_footer)
3327
+ args = _build_arg_parser().parse_args()
3328
+
3329
+ private_token = args.priv_token or os.environ.get("PRIV_TOKEN", "")
3330
+
3331
+
3332
+ if args.priv_token:
3333
+ os.environ["PRIV_TOKEN"] = private_token
3334
+
3335
+
3336
+ if args.save_job and not private_token:
3337
+ LogPrint("ā„¹ļø PRIV_TOKEN not configured; --save-job auto-disabled (will not write to database)", file=sys.stderr)
3338
+ args.save_job = False
3339
+
3340
+ dsl = binding = None
3341
+ if args.job_id is not None and args.job_id > 0:
3342
+ render_plan = _load_plan_from_job(args.job_id, private_token)
3343
+ elif args.render_plan:
3344
+ render_plan = _load_plan_from_file(args.render_plan, args.dsl)
3345
+ else:
3346
+ dsl = _load_dsl(args)
3347
+ _reject_skeleton_dsl(dsl)
3348
+ render_plan, binding = _plan_from_dsl(dsl, args)
3349
+
3350
+ if args.job_id or args.render_plan:
3351
+ _validate_saved_plan(render_plan)
3352
+ _reject_bad_asset_refs(render_plan)
3353
+
3354
+ if not args.skip_asset_resolve:
3355
+ render_plan = _resolve_plan_assets(render_plan, args, private_token)
3356
+
3357
+ render_plan.pop("_dsl_assets", None)
3358
+
3359
+ output_path = os.path.join(OUTPUT_DIR, "video.mp4")
3360
+ rp_output = args.render_plan_output or os.path.join(OUTPUT_DIR, "render-plan.json")
3361
+
3362
+
3363
+ if args.save_render_plan or not args.save_job:
3364
+ validate_and_fix_render_plan(render_plan)
3365
+ save_json(render_plan, rp_output)
3366
+ LogPrint(f"šŸ’¾ RenderPlan saved: {rp_output}", file=sys.stderr)
3367
+
3368
+
3369
+
3370
+
3371
+ if args.save_job:
3372
+ _persist_render_plan(render_plan, args, private_token, dsl, binding)
3373
+ if not args.save_job and not args.save_render_plan:
3374
+
3375
+
3376
+ save_json(render_plan, rp_output)
3377
+ LogPrint(f"šŸ’¾ RenderPlan saved: {rp_output}", file=sys.stderr)
3378
+
3379
+ if args.resolve_only:
3380
+ if _print_resolve_only_summary(render_plan) > 0:
3381
+ sys.exit(1)
3382
+ return
3383
+
3384
+ if render_plan["status"] == "failed":
3385
+ LogPrint("āŒ asset resolution had failures; cannot continue rendering", file=sys.stderr)
3386
+ if not args.save_job:
3387
+ save_json(render_plan, rp_output)
3388
+ sys.exit(1)
3389
+
3390
+ success, render_mode, upload_title_hint = _render(render_plan, args, private_token, output_path)
3391
+ _report_render(render_plan, args, private_token, success, render_mode, upload_title_hint, output_path, rp_output)
3392
+
3393
+
3232
3394
  if __name__ == "__main__":
3233
3395
  main()