258 lines
11 KiB
Python
258 lines
11 KiB
Python
"""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"])
|