ffmpeg / renderer /studio /projects.py
Ava2lon's picture
Upload 38 files
fe7106b verified
Raw
History Blame Contribute Delete
16.2 kB
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", {}),
}