Completes the v0.1.5 -> v1.0 roadmap and hardens the path handling around it. Features - Auto Stitch: recommends where to cut between two clips, scoring candidates on frame similarity and optical-flow motion continuity (recommend + Apply, never an automatic edit). - AI Detect: scene-change detection (ffmpeg scene score) and beat tracking (librosa), with beats used as snap targets. - Pre-render: flattens the marked range into one cached file for smooth playback; invalidated automatically by any edit. - Version Stack, Wipe Compare, Box/Shift select, per-clip Solo/Gain, MOV/WebM export, whole-timeline Send To ComfyUI. Security - Contain clip paths on export. A clip's path is read from the project file and POST /itda/api/project/<name> stores arbitrary JSON, so any local process could point a clip at any file, have ffmpeg composite it into the export, and fetch it back via /itda/api/file. - Contain the export path in ITDA Load Export. The node trusted "path" from last_export.json and handed it straight to a decoder, so a tampered manifest could emit any file's contents as node output. - Reject dot-only project names. safe_name() stripped separators but passed "." and ".." through, collapsing media/<project> to the whole ITDA root; media/delete would then accept any file under input/ITDA. - Validate drawtext colors. Unvalidated color/shadow_color were interpolated into the ffmpeg filter, so "ffffff:textfile=..." appended a real drawtext option. Also set expansion=none. Fixes - ffmpeg no longer blocks ComfyUI's event loop (an export froze the whole UI for its duration); all calls now run off-loop. - Auto Stitch no longer ranks on audio: measured against known-correct cut points it could not separate good cuts from bad, and demoted the perfect cut to last. Frame + motion scores 5/5 on ground truth. Removed itda_server.py, web/itda_editor.html/.css and an empty itda_client.js (all superseded and unreferenced), and stopped tracking __pycache__. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
87 lines
2.7 KiB
Python
87 lines
2.7 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
from dataclasses import dataclass, asdict
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from .paths import project_file, ensure_dirs, safe_name, itda_root
|
|
|
|
# Written into new project files as their "version" field. Existing projects
|
|
# keep whatever version they were saved with (load_project only fills it in
|
|
# when absent), so bumping this never rewrites older files.
|
|
PROJECT_VERSION = "1.0.0"
|
|
|
|
|
|
def default_project(name: str = "untitled") -> dict[str, Any]:
|
|
now = int(time.time())
|
|
safe = safe_name(name)
|
|
return {
|
|
"schema": "itda.project",
|
|
"version": PROJECT_VERSION,
|
|
"name": safe,
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"settings": {
|
|
"fps": 24,
|
|
"total_frames": 360,
|
|
"snap": True,
|
|
"preview_mode": "single",
|
|
"loop": False,
|
|
"mute": False,
|
|
},
|
|
"range": {"start": None, "end": None},
|
|
"media": [],
|
|
"clips": [],
|
|
"lanes": [],
|
|
}
|
|
|
|
|
|
def _repair_media_paths(data: dict[str, Any]) -> bool:
|
|
"""Rewrite clip/media "path" fields that point at a since-relocated input
|
|
directory (e.g. the --input-directory launch flag changed after the
|
|
project was last saved) so they resolve under the current media root.
|
|
Returns True if anything was rewritten.
|
|
"""
|
|
changed = False
|
|
safe = safe_name(data.get("name") or "project")
|
|
media_dir = itda_root() / "media" / safe
|
|
for bucket in (data.get("clips") or [], data.get("media") or []):
|
|
for item in bucket:
|
|
raw = item.get("path")
|
|
if not raw or Path(raw).exists():
|
|
continue
|
|
candidate = media_dir / Path(raw).name
|
|
if candidate.exists():
|
|
item["path"] = str(candidate)
|
|
changed = True
|
|
return changed
|
|
|
|
|
|
def load_project(name: str) -> dict[str, Any]:
|
|
path = project_file(name)
|
|
if not path.exists():
|
|
project = default_project(name)
|
|
save_project(name, project)
|
|
return project
|
|
with path.open("r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
if _repair_media_paths(data):
|
|
save_project(name, data)
|
|
return data
|
|
|
|
|
|
def save_project(name: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
safe = safe_name(data.get("name") or name)
|
|
data["name"] = safe
|
|
data["version"] = data.get("version") or PROJECT_VERSION
|
|
data["updated_at"] = int(time.time())
|
|
ensure_dirs(safe)
|
|
path = project_file(safe)
|
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
with tmp.open("w", encoding="utf-8") as f:
|
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
tmp.replace(path)
|
|
return {"ok": True, "project": safe, "path": str(path)}
|