Files
video-edit-planner-skill/scripts/index/manage_index.py
T
nite c771bdcfaf refactor: switch index from SQLite to JSON with relative paths
manage_index.py:
- Complete rewrite from SQLite to JSON file format
- All file paths stored as relative (relative to video directory)
- Moving the entire video directory does not break references
- Paths outside video dir stored as absolute (editable in text editor)
- Same 8 subcommands, same CLI interface

SKILL.md:
- Step 5: SQLite schema replaced with JSON structure example
- Verification checklist: .vedit.db → .vedit.json

README.md + README.zh.md:
- Features: 'SQLite database' → 'JSON file with relative paths'
- Project structure: 'SQLite index management' → 'JSON index management'
- 'Index Database' section → 'Index File' with relative path explanation

.gitignore:
- *.vedit.db → *.vedit.json
2026-07-14 13:12:54 +08:00

331 lines
12 KiB
Python

#!/usr/bin/env python3
"""Manage the video-edit-planner JSON index.
The index file lives next to the video as <video_stem>.vedit.json.
It tracks transcriptions, clips, and frames to avoid duplicate processing.
All file paths are stored as relative paths (relative to the video directory)
so that moving the entire directory does not break references.
Usage:
python manage_index.py init --video VIDEO_PATH
python manage_index.py add-transcription --json-path PATH --track-index N --duration S
python manage_index.py get-transcription
python manage_index.py add-clip --start S --end E --path PATH --reason "..."
python manage_index.py add-frames --clip-id N --frames-dir DIR --method scene
python manage_index.py list
python manage_index.py check-range --start S --end E
python manage_index.py clean
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
from typing import Any
def index_path_for_video(video_path: Path) -> Path:
"""Return the index file path for a given video file."""
return video_path.parent / f"{video_path.stem}.vedit.json"
def to_rel(path: str | Path, video_dir: Path) -> str:
"""Convert an absolute path to a path relative to the video directory."""
p = Path(path).expanduser().resolve()
try:
return str(p.relative_to(video_dir))
except ValueError:
# Path is outside the video directory; store as absolute
return str(p)
def to_abs(rel_path: str, video_dir: Path) -> Path:
"""Resolve a stored path (relative or absolute) to an absolute path."""
p = Path(rel_path)
if p.is_absolute():
return p
return (video_dir / p).resolve()
def load_index(video_path: Path) -> dict[str, Any]:
"""Load the index file. Returns the full index dict."""
idx_path = index_path_for_video(video_path)
if not idx_path.exists():
sys.exit(f"Index not found: {idx_path}. Run 'init' first.")
return json.loads(idx_path.read_text(encoding="utf-8"))
def save_index(video_path: Path, data: dict[str, Any]) -> None:
"""Write the index file."""
idx_path = index_path_for_video(video_path)
idx_path.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8",
)
def next_id(items: list[dict[str, Any]]) -> int:
"""Get the next ID for a list of records."""
if not items:
return 1
return max(r["id"] for r in items) + 1
def cmd_init(args: argparse.Namespace) -> None:
video = Path(args.video).expanduser().resolve()
idx_path = index_path_for_video(video)
if idx_path.exists():
print(json.dumps({"status": "ok", "index_path": str(idx_path), "message": "already exists"}))
return
data = {"transcriptions": [], "clips": [], "frames": []}
save_index(video, data)
print(json.dumps({"status": "ok", "index_path": str(idx_path)}))
def cmd_add_transcription(args: argparse.Namespace) -> None:
video = Path(args.video).expanduser().resolve()
data = load_index(video)
new_id = next_id(data["transcriptions"])
record = {
"id": new_id,
"json_path": to_rel(args.json_path, video.parent),
"track_index": args.track_index,
"created_at": time.time(),
"duration_s": args.duration,
}
data["transcriptions"].append(record)
save_index(video, data)
print(json.dumps({"status": "ok", "id": new_id}))
def cmd_get_transcription(args: argparse.Namespace) -> None:
video = Path(args.video).expanduser().resolve()
data = load_index(video)
records = data["transcriptions"]
if args.track is not None:
records = [r for r in records if r["track_index"] == args.track]
records.sort(key=lambda r: r["created_at"], reverse=True)
if not records:
print(json.dumps({"found": False}))
return
print(json.dumps({"found": True, "records": records}, ensure_ascii=False))
def cmd_add_clip(args: argparse.Namespace) -> None:
video = Path(args.video).expanduser().resolve()
data = load_index(video)
new_id = next_id(data["clips"])
record = {
"id": new_id,
"start_time": args.start,
"end_time": args.end,
"path": to_rel(args.path, video.parent),
"created_at": time.time(),
"reason": args.reason,
}
data["clips"].append(record)
save_index(video, data)
print(json.dumps({"status": "ok", "clip_id": new_id}))
def cmd_add_frames(args: argparse.Namespace) -> None:
"""Add frame records for all images in a directory, associating with a clip."""
video = Path(args.video).expanduser().resolve()
frames_dir = Path(args.frames_dir).expanduser().resolve()
if not frames_dir.is_dir():
sys.exit(f"Frames directory not found: {frames_dir}")
data = load_index(video)
# Read scene scores from showinfo log if available
scene_scores: dict[str, float] = {}
log_file = frames_dir / "showinfo.log"
if log_file.exists():
for line in log_file.read_text(errors="replace").splitlines():
score = None
t = None
for part in line.split():
if part.startswith("score:"):
score = float(part.split(":")[1])
elif part.startswith("pts_time:"):
t = float(part.split(":")[1])
if score is not None and t is not None:
scene_scores[f"{t:.3f}"] = score
fps = args.fps if args.fps else 0.5
start_offset = args.start if args.start else 0.0
frame_files = sorted(frames_dir.glob("frame_*.jpg"))
if not frame_files:
frame_files = sorted(frames_dir.glob("frame_*.png"))
if not frame_files:
sys.exit(f"No frame files found in {frames_dir}")
new_id = next_id(data["frames"])
count = 0
for i, frame_path in enumerate(frame_files):
timestamp = start_offset + (i / fps)
scene_score = scene_scores.get(f"{timestamp:.3f}")
record = {
"id": new_id + count,
"clip_id": args.clip_id,
"timestamp": timestamp,
"path": to_rel(frame_path, video.parent),
"scene_score": scene_score,
"method": args.method,
"created_at": time.time(),
}
data["frames"].append(record)
count += 1
save_index(video, data)
print(json.dumps({"status": "ok", "frames_added": count}))
def cmd_list(args: argparse.Namespace) -> None:
video = Path(args.video).expanduser().resolve()
data = load_index(video)
result = {
"transcriptions": sorted(data["transcriptions"], key=lambda r: r["created_at"], reverse=True),
"clips": [],
}
clips = sorted(data["clips"], key=lambda r: r["start_time"])
for clip in clips:
clip_dict = dict(clip)
frames = [f for f in data["frames"] if f["clip_id"] == clip["id"]]
frames.sort(key=lambda f: f["timestamp"])
clip_dict["frames"] = frames
result["clips"].append(clip_dict)
print(json.dumps(result, ensure_ascii=False, indent=2))
def cmd_check_range(args: argparse.Namespace) -> None:
"""Check if a time range overlaps with any existing clips."""
video = Path(args.video).expanduser().resolve()
data = load_index(video)
# Find clips that overlap with the requested range
overlapping = [
c for c in data["clips"]
if c["start_time"] < args.end and c["end_time"] > args.start
]
overlapping.sort(key=lambda c: c["start_time"])
if not overlapping:
print(json.dumps({"found": False}))
else:
print(json.dumps({"found": True, "clips": overlapping}, ensure_ascii=False))
def cmd_clean(args: argparse.Namespace) -> None:
"""Remove frame and clip records for files that no longer exist on disk."""
video = Path(args.video).expanduser().resolve()
data = load_index(video)
video_dir = video.parent
# Check frames
frames_removed = 0
remaining_frames = []
for f in data["frames"]:
if to_abs(f["path"], video_dir).exists():
remaining_frames.append(f)
else:
frames_removed += 1
data["frames"] = remaining_frames
# Check clips
clips_removed = 0
remaining_clip_ids = set()
remaining_clips = []
for c in data["clips"]:
if to_abs(c["path"], video_dir).exists():
remaining_clips.append(c)
remaining_clip_ids.add(c["id"])
else:
clips_removed += 1
data["clips"] = remaining_clips
# Remove frames whose clip was removed
data["frames"] = [f for f in data["frames"] if f["clip_id"] in remaining_clip_ids]
save_index(video, data)
print(json.dumps({
"status": "ok",
"frames_removed": frames_removed,
"clips_removed": clips_removed,
}))
def main() -> None:
parser = argparse.ArgumentParser(description="Manage video-edit-planner index file.")
sub = parser.add_subparsers(dest="command", required=True)
# init
p_init = sub.add_parser("init", help="Initialize index file")
p_init.add_argument("--video", required=True, help="Path to the video file")
# add-transcription
p_at = sub.add_parser("add-transcription", help="Add a transcription record")
p_at.add_argument("--video", required=True)
p_at.add_argument("--json-path", required=True)
p_at.add_argument("--track-index", required=True, type=int)
p_at.add_argument("--duration", type=float, default=None)
# get-transcription
p_gt = sub.add_parser("get-transcription", help="Get transcription records (all or by track)")
p_gt.add_argument("--video", required=True)
p_gt.add_argument("--track", type=int, default=None, help="Filter by track index (omit to get all)")
# add-clip
p_ac = sub.add_parser("add-clip", help="Add a clip record")
p_ac.add_argument("--video", required=True)
p_ac.add_argument("--start", required=True, type=float, help="Start time in seconds")
p_ac.add_argument("--end", required=True, type=float, help="End time in seconds")
p_ac.add_argument("--path", required=True, help="Path to the clip file")
p_ac.add_argument("--reason", default=None)
# add-frames
p_af = sub.add_parser("add-frames", help="Add frame records from a directory")
p_af.add_argument("--video", required=True)
p_af.add_argument("--clip-id", required=True, type=int)
p_af.add_argument("--frames-dir", required=True)
p_af.add_argument("--method", required=True, choices=["scene", "sample", "both"])
p_af.add_argument("--fps", type=float, default=0.5, help="Sampling fps for timestamp estimation")
p_af.add_argument("--start", type=float, default=0.0, help="Start offset in seconds")
# list
p_list = sub.add_parser("list", help="List all records")
p_list.add_argument("--video", required=True)
# check-range
p_cr = sub.add_parser("check-range", help="Check if a time range has existing clips")
p_cr.add_argument("--video", required=True)
p_cr.add_argument("--start", required=True, type=float)
p_cr.add_argument("--end", required=True, type=float)
# clean
p_clean = sub.add_parser("clean", help="Remove records for files that no longer exist")
p_clean.add_argument("--video", required=True)
args = parser.parse_args()
commands = {
"init": cmd_init,
"add-transcription": cmd_add_transcription,
"get-transcription": cmd_get_transcription,
"add-clip": cmd_add_clip,
"add-frames": cmd_add_frames,
"list": cmd_list,
"check-range": cmd_check_range,
"clean": cmd_clean,
}
commands[args.command](args)
if __name__ == "__main__":
main()