Release standalone timeline editor 1.0.0

This commit is contained in:
Hopping Mad Games
2026-08-26 09:55:12 -06:00
commit 869537cefd
27 changed files with 7739 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
"""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"])