Release standalone timeline editor 1.0.0
This commit is contained in:
@@ -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"])
|
||||
Reference in New Issue
Block a user