from __future__ import annotations from copy import deepcopy from pathlib import Path from typing import Any from renderer.core.config import Settings from renderer.core.utils import new_id, now, read_json, safe_filename, write_json from renderer.studio.capabilities import TIMELINE_TRACK_TYPES, is_timeline_operation, is_track_type DEFAULT_EXPORT_SETTINGS: dict[str, Any] = { "format": "mp4", "preset": "tiktok", "platform": "tiktok", "resolution": "1080p", "fps": 30, "codec": "h264", "audio_codec": "aac", } def default_project(name: str, metadata: dict[str, Any] | None = None, project_id: str | None = None) -> dict[str, Any]: created = now() project_id = project_id or new_id("project") return { "id": project_id, "name": name, "slug": safe_filename(name), "version": 1, "schema": "ava2lon.project.v1", "created_at": created, "updated_at": created, "metadata": metadata or {}, "timeline": { "duration": 0.0, "fps": 30, "tracks": {track_type: [] for track_type in TIMELINE_TRACK_TYPES}, "groups": [], "markers": [], }, "assets": [], "audio_tracks": [], "video_tracks": [], "text_layers": [], "sticker_layers": [], "effects": [], "filters": [], "keyframes": [], "captions": [], "templates": [], "export_settings": deepcopy(DEFAULT_EXPORT_SETTINGS), "plugins": [], "automation": {"webhooks": [], "batch": {}, "n8n": {"compatible": True}}, } class ProjectStore: def __init__(self, settings: Settings | None = None) -> None: self.settings = settings or Settings() self.settings.ensure_dirs() self.root = self.settings.storage_dir / "projects" self.root.mkdir(parents=True, exist_ok=True) def list(self) -> list[dict[str, Any]]: projects: list[dict[str, Any]] = [] for manifest in sorted(self.root.glob("*/project.json")): try: data = normalize_project(read_json(manifest, {})) projects.append(_summary(data, manifest.parent)) except Exception: continue return projects def create(self, name: str, metadata: dict[str, Any] | None = None, template: dict[str, Any] | None = None) -> dict[str, Any]: project = normalize_project(template or default_project(name, metadata)) project["name"] = name project["metadata"] = metadata or project.get("metadata", {}) if not project.get("id"): project["id"] = new_id("project") project["slug"] = safe_filename(str(project.get("slug") or name or project["id"])) project["created_at"] = project.get("created_at") or now() project["updated_at"] = now() self.save(project["id"], project) return project def get(self, project_id: str) -> dict[str, Any]: path = self._path(project_id) data = read_json(path, None) if data is None: raise KeyError(project_id) return normalize_project(data) def save(self, project_id: str, project: dict[str, Any]) -> dict[str, Any]: normalized = normalize_project(project) normalized["id"] = project_id or normalized.get("id") or new_id("project") normalized["slug"] = safe_filename(str(normalized.get("slug") or normalized.get("name") or normalized["id"])) normalized["updated_at"] = now() write_json(self._path(normalized["id"]), normalized) return normalized def delete(self, project_id: str) -> None: path = self._path(project_id) if not path.exists(): raise KeyError(project_id) directory = path.parent for child in directory.glob("*"): if child.is_file(): child.unlink() try: directory.rmdir() except OSError: pass def add_asset(self, project_id: str, asset: dict[str, Any]) -> dict[str, Any]: project = self.get(project_id) asset = deepcopy(asset) asset.setdefault("id", new_id("asset")) asset.setdefault("created_at", now()) project["assets"].append(asset) return self.save(project_id, project) def add_to_timeline(self, project_id: str, item: dict[str, Any], track_type: str = "video", track_id: str | None = None) -> dict[str, Any]: project = self.get(project_id) add_timeline_item(project, item, track_type=track_type, track_id=track_id) return self.save(project_id, project) def timeline_operation(self, project_id: str, operation: str, item_id: str | None = None, params: dict[str, Any] | None = None) -> dict[str, Any]: project = self.get(project_id) apply_timeline_operation(project, operation, item_id=item_id, params=params or {}) return self.save(project_id, project) def _path(self, project_id: str) -> Path: project_id = safe_filename(project_id) return self.root / project_id / "project.json" def normalize_project(project: dict[str, Any]) -> dict[str, Any]: normalized = deepcopy(project or {}) normalized.setdefault("id", new_id("project")) normalized.setdefault("name", "Untitled Project") normalized.setdefault("slug", safe_filename(str(normalized["name"]))) normalized.setdefault("version", 1) normalized.setdefault("schema", "ava2lon.project.v1") normalized.setdefault("created_at", now()) normalized.setdefault("updated_at", now()) normalized.setdefault("metadata", {}) normalized.setdefault("timeline", {}) timeline = normalized["timeline"] timeline.setdefault("duration", 0.0) timeline.setdefault("fps", 30) timeline.setdefault("tracks", {}) for track_type in TIMELINE_TRACK_TYPES: timeline["tracks"].setdefault(track_type, []) timeline.setdefault("groups", []) timeline.setdefault("markers", []) for key in ( "assets", "audio_tracks", "video_tracks", "text_layers", "sticker_layers", "effects", "filters", "keyframes", "captions", "templates", "plugins", ): normalized.setdefault(key, []) normalized.setdefault("export_settings", deepcopy(DEFAULT_EXPORT_SETTINGS)) normalized.setdefault("automation", {"webhooks": [], "batch": {}, "n8n": {"compatible": True}}) _recalculate_duration(normalized) return normalized def add_timeline_item(project: dict[str, Any], item: dict[str, Any], *, track_type: str = "video", track_id: str | None = None) -> dict[str, Any]: if not is_track_type(track_type): raise ValueError(f"Unsupported track type: {track_type}") normalized = normalize_project(project) item = deepcopy(item) item.setdefault("id", new_id("clip")) item.setdefault("type", track_type) item.setdefault("start", 0.0) item.setdefault("duration", max(float(item.get("end", 0.0)) - float(item.get("start", 0.0)), 0.0) or 1.0) item.setdefault("source_start", 0.0) item.setdefault("locked", False) item.setdefault("hidden", False) item.setdefault("keyframes", []) item.setdefault("effects", []) item.setdefault("filters", []) item.setdefault("metadata", {}) track = _ensure_track(normalized, track_type, track_id) track["items"].append(item) track["items"].sort(key=lambda entry: float(entry.get("start", 0.0))) project.clear() project.update(normalized) _mirror_layers(project, track_type, item) _recalculate_duration(project) return item def apply_timeline_operation(project: dict[str, Any], operation: str, *, item_id: str | None = None, params: dict[str, Any] | None = None) -> dict[str, Any]: if not is_timeline_operation(operation): raise ValueError(f"Unsupported timeline operation: {operation}") params = params or {} normalized = normalize_project(project) if operation == "insert": add_timeline_item( normalized, params.get("item", {}), track_type=str(params.get("track_type", "video")), track_id=params.get("track_id"), ) elif operation == "group": group_id = str(params.get("group_id") or new_id("group")) item_ids = [str(value) for value in params.get("item_ids", [])] normalized["timeline"]["groups"].append({"id": group_id, "item_ids": item_ids, "metadata": params.get("metadata", {})}) for grouped_id in item_ids: try: grouped_item, _ = _find_item(normalized, grouped_id) grouped_item["group_id"] = group_id except KeyError: continue else: if not item_id: raise ValueError(f"{operation} requires item_id") item, track = _find_item(normalized, item_id) if operation == "drag": item["start"] = max(0.0, float(params.get("start", item.get("start", 0.0)))) elif operation == "trim": if "start" in params: item["start"] = max(0.0, float(params["start"])) if "duration" in params: item["duration"] = max(0.001, float(params["duration"])) if "source_start" in params: item["source_start"] = max(0.0, float(params["source_start"])) elif operation == "split": offset = float(params.get("offset", 0.0)) duration = float(item.get("duration", 0.0)) if offset <= 0 or offset >= duration: raise ValueError("split offset must be inside the item duration") new_item = deepcopy(item) new_item["id"] = str(params.get("new_item_id") or new_id("clip")) new_item["start"] = float(item.get("start", 0.0)) + offset new_item["duration"] = duration - offset new_item["source_start"] = float(item.get("source_start", 0.0)) + offset item["duration"] = offset track["items"].append(new_item) track["items"].sort(key=lambda entry: float(entry.get("start", 0.0))) elif operation == "ripple_delete": start = float(item.get("start", 0.0)) duration = float(item.get("duration", 0.0)) track["items"] = [entry for entry in track["items"] if entry.get("id") != item_id] for entry in track["items"]: if float(entry.get("start", 0.0)) > start: entry["start"] = max(start, float(entry.get("start", 0.0)) - duration) elif operation == "replace": replacement = deepcopy(params.get("item", {})) replacement.setdefault("id", item_id) replacement.setdefault("start", item.get("start", 0.0)) replacement.setdefault("duration", item.get("duration", 1.0)) replacement.setdefault("type", item.get("type", track.get("type"))) index = track["items"].index(item) track["items"][index] = replacement elif operation == "lock": item["locked"] = bool(params.get("locked", True)) elif operation == "hide": item["hidden"] = bool(params.get("hidden", True)) elif operation == "duplicate": duplicate = deepcopy(item) duplicate["id"] = str(params.get("new_item_id") or new_id("clip")) duplicate["start"] = float(params.get("start", float(item.get("start", 0.0)) + float(item.get("duration", 1.0)))) track["items"].append(duplicate) track["items"].sort(key=lambda entry: float(entry.get("start", 0.0))) project.clear() project.update(normalized) _recalculate_duration(project) return project def add_effect(project: dict[str, Any], target_id: str, effect: str, params: dict[str, Any] | None = None) -> dict[str, Any]: effect_record = {"id": new_id("effect"), "target_id": target_id, "effect": effect, "params": params or {}, "created_at": now()} project.setdefault("effects", []).append(effect_record) try: item, _ = _find_item(project, target_id) item.setdefault("effects", []).append(effect_record) except KeyError: pass return effect_record def add_filter(project: dict[str, Any], target_id: str, filter_name: str, params: dict[str, Any] | None = None) -> dict[str, Any]: filter_record = {"id": new_id("filter"), "target_id": target_id, "filter": filter_name, "params": params or {}, "created_at": now()} project.setdefault("filters", []).append(filter_record) try: item, _ = _find_item(project, target_id) item.setdefault("filters", []).append(filter_record) except KeyError: pass return filter_record def add_transition(project: dict[str, Any], from_item_id: str, to_item_id: str, transition: str, duration: float = 0.45) -> dict[str, Any]: record = { "id": new_id("transition"), "from_item_id": from_item_id, "to_item_id": to_item_id, "transition": transition, "duration": duration, "created_at": now(), } project.setdefault("timeline", {}).setdefault("transitions", []).append(record) return record def add_keyframe( project: dict[str, Any], target_id: str, property_name: str, time: float, value: Any, easing: str = "linear", ) -> dict[str, Any]: record = { "id": new_id("keyframe"), "target_id": target_id, "property": property_name, "time": max(0.0, float(time)), "value": value, "easing": easing, } project.setdefault("keyframes", []).append(record) try: item, _ = _find_item(project, target_id) item.setdefault("keyframes", []).append(record) except KeyError: pass return record def _ensure_track(project: dict[str, Any], track_type: str, track_id: str | None = None) -> dict[str, Any]: tracks = project["timeline"]["tracks"].setdefault(track_type, []) if track_id: for track in tracks: if track.get("id") == track_id: return track if not tracks: track_id = track_id or f"{track_type}_1" else: track_id = track_id or f"{track_type}_{len(tracks) + 1}" track = {"id": track_id, "type": track_type, "name": f"{track_type.title()} {len(tracks) + 1}", "locked": False, "hidden": False, "items": []} tracks.append(track) return track def _find_item(project: dict[str, Any], item_id: str) -> tuple[dict[str, Any], dict[str, Any]]: for tracks in project.get("timeline", {}).get("tracks", {}).values(): for track in tracks: for item in track.get("items", []): if item.get("id") == item_id: return item, track raise KeyError(item_id) def _mirror_layers(project: dict[str, Any], track_type: str, item: dict[str, Any]) -> None: mirror_key = { "video": "video_tracks", "audio": "audio_tracks", "text": "text_layers", "sticker": "sticker_layers", "subtitle": "captions", }.get(track_type) if mirror_key: project.setdefault(mirror_key, []).append({"item_id": item["id"], **deepcopy(item)}) def _recalculate_duration(project: dict[str, Any]) -> None: duration = 0.0 for tracks in project.get("timeline", {}).get("tracks", {}).values(): for track in tracks: for item in track.get("items", []): duration = max(duration, float(item.get("start", 0.0)) + float(item.get("duration", 0.0))) project.setdefault("timeline", {})["duration"] = round(duration, 3) def _summary(project: dict[str, Any], directory: Path) -> dict[str, Any]: return { "id": project.get("id"), "name": project.get("name"), "slug": project.get("slug"), "path": str(directory), "updated_at": project.get("updated_at"), "duration": project.get("timeline", {}).get("duration", 0.0), "asset_count": len(project.get("assets", [])), "metadata": project.get("metadata", {}), }