"""Timeline JSON parsing and normalized layer/keyframe schema.""" import json from PIL import ImageColor from .core import ( ETK_LTXV_TIMELINE_SCHEMA_VERSION, _etk_ltxv_timeline_payload_keyframes, ) def _etk_bool(value, fallback=False): if isinstance(value, bool): return value if isinstance(value, str): lowered = value.strip().lower() if lowered in {"true", "1", "yes", "bold"}: return True if lowered in {"false", "0", "no", "normal"}: return False if isinstance(value, (int, float)): return value != 0 return bool(fallback) ETK_TIMELINE_TEXT_LAYER_FIELDS = frozenset({ "text", "fontFamily", "font_family", "fontSize", "font_size", "bold", "italic", "align", "textAlign", "text_align", "color", "outlineColor", "outline_color", "outlineWidth", "outline_width", "shadowColor", "shadow_color", "shadowBlur", "shadow_blur", "shadowOffsetX", "shadow_offset_x", "shadowOffsetY", "shadow_offset_y", }) def _etk_timeline_layer_is_text(raw, fallback=None): fallback = fallback or {} if not isinstance(raw, dict): return False layer_type = str( raw.get("type", raw.get("layerType", raw.get("layer_type", fallback.get("type", "")))) or "" ).lower() return layer_type == "text" or any(field in raw for field in ETK_TIMELINE_TEXT_LAYER_FIELDS) def _etk_normalize_timeline_text_color(value, fallback="#ffffff"): text = str(value if value is not None else fallback).strip() if not text: text = fallback try: ImageColor.getcolor(text, "RGBA") except ValueError: text = fallback return text def _etk_float(value, fallback=0.0, minimum=None, maximum=None): try: number = float(value) except (TypeError, ValueError): number = float(fallback) if minimum is not None: number = max(float(minimum), number) if maximum is not None: number = min(float(maximum), number) return number def _etk_normalize_timeline_text_align(value, fallback="center"): text = str(value if value is not None else fallback).strip().lower() return text if text in {"left", "center", "right"} else fallback def _etk_parse_timeline_layer(raw, fallback=None): fallback = fallback or {} if not isinstance(raw, dict): return None is_text_layer = _etk_timeline_layer_is_text(raw, fallback) image_info = None if is_text_layer else raw.get("image") or raw.get("file") or raw.get("filename") or fallback.get("image") has_layer_slot = bool( image_info or is_text_layer or raw.get("id") or raw.get("empty") or "image" in raw or "file" in raw or "filename" in raw or "type" in raw or "layerType" in raw or "layer_type" in raw or any(field in raw for field in ETK_TIMELINE_TEXT_LAYER_FIELDS) ) if not has_layer_slot: return None fit_mode = str(raw.get("fitMode", raw.get("fit_mode", fallback.get("fit_mode", "crop")))).lower() if fit_mode not in {"crop", "pad"}: fit_mode = "crop" try: pan_x = float(raw.get("panX", raw.get("pan_x", fallback.get("pan_x", 0.0)))) pan_y = float(raw.get("panY", raw.get("pan_y", fallback.get("pan_y", 0.0)))) zoom = max(0.01, min(20.0, float(raw.get("zoom", fallback.get("zoom", 1.0))))) brightness = max(0.0, min(2.0, float(raw.get("brightness", fallback.get("brightness", 1.0))))) contrast = max(0.0, min(2.0, float(raw.get("contrast", fallback.get("contrast", 1.0))))) except (TypeError, ValueError) as exc: raise ValueError("timeline layer has invalid pan/zoom/filter values") from exc parsed = { "id": str(raw.get("id", fallback.get("id", "")) or ""), "type": "text" if is_text_layer else "image", "image": image_info, "empty": not bool(image_info) and not is_text_layer, "fit_mode": fit_mode, "pan_x": pan_x, "pan_y": pan_y, "zoom": zoom, "brightness": brightness, "contrast": contrast, } if is_text_layer: try: font_size = max(1.0, min(512.0, float(raw.get("fontSize", raw.get("font_size", fallback.get("font_size", 72)))))) except (TypeError, ValueError) as exc: raise ValueError("timeline text layer has invalid font size") from exc parsed.update({ "text": str(raw.get("text", fallback.get("text", "Text")) or ""), "font_family": str(raw.get("fontFamily", raw.get("font_family", fallback.get("font_family", "sans-serif"))) or "sans-serif"), "font_size": font_size, "bold": _etk_bool(raw.get("bold", fallback.get("bold", False))), "italic": _etk_bool(raw.get("italic", fallback.get("italic", False))), "align": _etk_normalize_timeline_text_align(raw.get("align", raw.get("textAlign", raw.get("text_align", fallback.get("align", "center"))))), "color": _etk_normalize_timeline_text_color(raw.get("color", fallback.get("color", "#ffffff"))), "outline_color": _etk_normalize_timeline_text_color(raw.get("outlineColor", raw.get("outline_color", fallback.get("outline_color", "#000000"))), "#000000"), "outline_width": _etk_float(raw.get("outlineWidth", raw.get("outline_width", fallback.get("outline_width", 0.0))), 0.0, 0.0, 64.0), "shadow_color": _etk_normalize_timeline_text_color(raw.get("shadowColor", raw.get("shadow_color", fallback.get("shadow_color", "#000000"))), "#000000"), "shadow_blur": _etk_float(raw.get("shadowBlur", raw.get("shadow_blur", fallback.get("shadow_blur", 0.0))), 0.0, 0.0, 128.0), "shadow_offset_x": _etk_float(raw.get("shadowOffsetX", raw.get("shadow_offset_x", fallback.get("shadow_offset_x", 0.0))), 0.0, -512.0, 512.0), "shadow_offset_y": _etk_float(raw.get("shadowOffsetY", raw.get("shadow_offset_y", fallback.get("shadow_offset_y", 0.0))), 0.0, -512.0, 512.0), }) return parsed def _parse_etk_ltxv_timeline(timeline_json, include_empty=False, fps=25.0): if not timeline_json: return [] try: fps = max(0.001, float(fps)) except (TypeError, ValueError) as exc: raise ValueError("timeline fps must be a number") from exc try: payload = json.loads(timeline_json) except json.JSONDecodeError as exc: raise ValueError(f"timeline_json is not valid JSON: {exc}") from exc payload, schema_version = _etk_ltxv_timeline_payload_keyframes(payload) keyframes = [] for raw in payload: if not isinstance(raw, dict): continue image_info = raw.get("image") or raw.get("file") or raw.get("filename") positive_prompt = str(raw.get("positivePrompt", raw.get("positive_prompt", "")) or "") negative_prompt = str(raw.get("negativePrompt", raw.get("negative_prompt", "")) or "") if schema_version == ETK_LTXV_TIMELINE_SCHEMA_VERSION and raw.get("time") is None: raise ValueError("timeline_json schemaVersion 2 keyframes must include time") try: if raw.get("time") is not None: time_seconds = float(raw.get("time", 0.0)) elif raw.get("slot") is not None: time_seconds = float(raw.get("slot", 0.0)) * 8.0 / fps elif raw.get("frame") is not None: time_seconds = float(raw.get("frame", 0.0)) / fps else: time_seconds = 0.0 frame = int(round(time_seconds * fps)) slot = int((frame / 8.0) + 0.5) except (TypeError, ValueError) as exc: raise ValueError(f"timeline keyframe has invalid time value: {raw.get('time')}") from exc if time_seconds < 0 or slot < 0: raise ValueError(f"timeline keyframe time {time_seconds} maps outside video latent range") fit_mode = str(raw.get("fitMode", raw.get("fit_mode", "crop"))).lower() if fit_mode not in {"crop", "pad"}: fit_mode = "crop" try: pan_x = float(raw.get("panX", raw.get("pan_x", 0.0))) pan_y = float(raw.get("panY", raw.get("pan_y", 0.0))) zoom = max(0.01, min(20.0, float(raw.get("zoom", 1.0)))) brightness = max(0.0, min(2.0, float(raw.get("brightness", 1.0)))) contrast = max(0.0, min(2.0, float(raw.get("contrast", 1.0)))) prompt_strength = max(0.0, min(4.0, float(raw.get("promptStrength", raw.get("prompt_strength", 1.0))))) latent_strength_raw = raw.get("latentStrength", raw.get("latent_strength", raw.get("strength", None))) latent_strength = None if latent_strength_raw is None else max(0.0, min(1.0, float(latent_strength_raw))) except (TypeError, ValueError) as exc: raise ValueError("timeline keyframe has invalid pan/zoom/filter values") from exc fallback_layer = { "image": image_info, "fit_mode": fit_mode, "pan_x": pan_x, "pan_y": pan_y, "zoom": zoom, "brightness": brightness, "contrast": contrast, } layers = [] if isinstance(raw.get("layers"), list): for layer in raw.get("layers", []): parsed_layer = _etk_parse_timeline_layer(layer, {**fallback_layer, "image": None}) if parsed_layer is not None: layers.append(parsed_layer) if not layers and image_info: parsed_layer = _etk_parse_timeline_layer({"image": image_info}, fallback_layer) if parsed_layer is not None: layers.append(parsed_layer) if layers: image_info = layers[0]["image"] if not include_empty and not layers and not positive_prompt and not negative_prompt: continue keyframes.append( { "frame": frame, "slot": slot, "time": time_seconds, "fit_mode": fit_mode, "pan_x": pan_x, "pan_y": pan_y, "zoom": zoom, "brightness": brightness, "contrast": contrast, "image": image_info, "layers": layers, "selected_layer_id": str(raw.get("selectedLayerId", raw.get("selected_layer_id", "")) or ""), "input_image": bool(raw.get("inputImage", raw.get("input_image", False))), "positive_prompt": positive_prompt, "negative_prompt": negative_prompt, "prompt_strength": prompt_strength, "latent_strength": latent_strength, } ) return sorted(keyframes, key=lambda item: item["slot"])