$ cat node-template.py
I
Image to Video
// Animates a first-frame image into a video guided by a text prompt. Optionally accepts an end frame, an audio track, and a motion reference video (support depends on the active video backend).
Process
Video
template.py
1import os2import sys3import json4import traceback5import random67from gais import Gais89INPUT_DIR = "/data/input"10OUTPUT_DIR = "/data/output"111213def main():14 try:15 input_json = sys.stdin.read()16 execution_input = json.loads(input_json)17 inputs = execution_input.get("inputs", {})1819 prompt = inputs.get("prompt", "")20 negative_prompt = inputs.get("negative_prompt", "")21 aspect_ratio = float(inputs.get("aspect_ratio", 1.7778) or 1.7778)22 megapixel = float(inputs.get("megapixel", 1.0) or 1.0)23 resolution = (inputs.get("resolution") or "").strip()24 num_frames = int(inputs.get("num_frames", 125) or 125)25 # fps unset → let the active video-creation backend pick its default26 # (Wan 2.2 = 16, LTX 2.3 = 24). Only pass when the user explicitly set it.27 fps_in = inputs.get("fps")28 fps = int(fps_in) if fps_in not in (None, "") else None2930 seed_mode = inputs.get("seed_mode", "random")31 seed_in = inputs.get("seed", -1)32 seed_input = int(seed_in) if seed_in not in (None, "") else -133 if seed_mode == "fixed" and seed_input >= 0:34 seed_value = seed_input35 else:36 seed_value = random.randint(0, 2**31 - 1)3738 if not prompt:39 raise ValueError("Prompt is required")4041 image_name = inputs.get("image", "")42 if not image_name:43 raise ValueError("An image is required")44 image_path = os.path.join(INPUT_DIR, image_name)45 if not os.path.exists(image_path):46 raise FileNotFoundError(f"Input image not found: {image_path}")4748 def _opt_input(name):49 v = (inputs.get(name) or "").strip()50 if not v:51 return None52 p = os.path.join(INPUT_DIR, v)53 if not os.path.exists(p):54 raise FileNotFoundError(f"Input file for {name!r} not found: {p}")55 return p5657 last_frame_path = _opt_input("last_frame")58 audio_path = _opt_input("audio")59 motion_video_path = _opt_input("motion_video")6061 # Width/height: explicit `resolution` override wins; otherwise62 # derive from aspect_ratio + megapixel, rounded to a multiple of 863 # (most video codecs / vendor backends prefer 8-divisible dims).64 if resolution:65 try:66 w_str, h_str = resolution.lower().split("x")67 width = int(w_str)68 height = int(h_str)69 except Exception as e:70 raise ValueError(71 f"Invalid resolution {resolution!r}; expected 'WxH'"72 ) from e73 else:74 import math75 target_pixels = max(0.25, min(8.0, megapixel)) * 1_000_000.076 h_raw = math.sqrt(target_pixels / max(0.1, aspect_ratio))77 height = max(8, int(round(h_raw / 8) * 8))78 width = max(8, int(round((height * aspect_ratio) / 8) * 8))79 print(80 f"[derived] aspect_ratio={aspect_ratio} megapixel={megapixel} "81 f"-> {width}x{height} (backend may snap further)",82 file=sys.stderr,83 )8485 if fps is not None and fps <= 0:86 raise ValueError(f"fps must be > 0, got {fps}")87 # When the user provided fps, derive duration_s locally so the service88 # gets a precise duration. When fps is unset, leave duration_s out too89 # — the backend will compute it from num_frames at its own default fps.90 duration_s = (num_frames / fps) if fps else None9192 os.makedirs(OUTPUT_DIR, exist_ok=True)9394 fps_label = f"{fps}" if fps else "backend-default"95 dur_label = f"{duration_s:.2f}s" if duration_s else "backend-derived"96 extras = ", ".join(97 n for n, p in (98 ("last_frame", last_frame_path),99 ("audio", audio_path),100 ("motion_video", motion_video_path),101 ) if p102 ) or "none"103 print(104 f"Requesting i2v via gais-video-creation slot: "105 f"{width}x{height} @ {fps_label}fps, num_frames={num_frames} "106 f"({dur_label}), seed={seed_value}, optional inputs: {extras}",107 file=sys.stderr,108 )109110 # Profile-routed: backend (Wan22 / LTX / Helios / Luma / future) is111 # picked by HARDWARE_PROFILE via PROFILE_OVERRIDES in _registry.py.112 result = Gais.video.create_i2v(113 first_frame=image_path,114 prompt=prompt,115 negative_prompt=negative_prompt,116 width=width,117 height=height,118 duration_s=duration_s,119 fps=fps,120 seed=seed_value,121 num_frames=num_frames,122 last_frame=last_frame_path,123 audio=audio_path,124 motion_video=motion_video_path,125 )126127 out_filename = "generated_video.mp4"128 out_path = os.path.join(OUTPUT_DIR, out_filename)129 with open(out_path, "wb") as f:130 f.write(result.content)131132 inference_time = result.metadata.get("inference_time_ms", "unknown")133 provider = result.metadata.get("provider_name", "local")134 print(135 f"video generated via {provider}: time={inference_time}ms, seed={seed_value}",136 file=sys.stderr,137 )138139 # Probe output dimensions and emit aspect_ratio + resolution so140 # downstream nodes can chain.141 try:142 import av143 with av.open(out_path) as _c:144 _s = next(s for s in _c.streams if s.type == "video")145 _w = int(_s.codec_context.width)146 _h = int(_s.codec_context.height)147 except Exception:148 _w, _h = 0, 0149150 print(json.dumps({151 "video": out_filename,152 "aspect_ratio": round(_w / _h, 4) if _h else 0.0,153 "resolution": f"{_w}x{_h}",154 }, indent=2))155156 except Exception as e:157 err = {158 "error": str(e),159 "errorType": type(e).__name__,160 }161 # Backend/provider failures (policy violations, quota, remote 4xx)162 # already carry a human-readable message — a Python traceback is163 # pure noise in the execution-error panel. Keep it for genuine164 # template bugs only.165 try:166 from gais._errors import GaisError167 is_backend_err = isinstance(e, GaisError)168 except Exception:169 is_backend_err = False170 if not is_backend_err:171 err["traceback"] = traceback.format_exc()172 print(json.dumps(err), file=sys.stderr)173 sys.exit(1)174175176if __name__ == "__main__":177 main()$ git log --oneline
v3.5.2
HEAD
2026-08-18v3.4.22026-05-22
v3.4.12026-05-07
v3.0.02026-04-23
v1.7.02026-04-22
v1.2.12026-04-09
v1.3.02026-03-29
v1.2.02026-03-20