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
This commit is contained in:
2026-07-14 13:12:54 +08:00
parent 39400e2bb1
commit c771bdcfaf
5 changed files with 200 additions and 176 deletions
+2 -2
View File
@@ -17,8 +17,8 @@ Thumbs.db
*.swo
*~
# Index databases (runtime artifacts, not source)
*.vedit.db
# Index files (runtime artifacts, not source)
*.vedit.json
# Extracted clips and frames (runtime artifacts)
*_clips/
+6 -6
View File
@@ -8,7 +8,7 @@ An agent skill that helps plan video edits through iterative dialogue — transc
- **Audio transcription** — bundled [funasr-script](https://github.com/modelscope/FunASR) with Fun-ASR-Nano (default, high quality, Chinese-optimized) and SenseVoice (fast preview) models
- **On-demand frame extraction** — ffmpeg-based clip cutting (`-c copy`) + scene-change detection + uniform sampling, with hardware acceleration (CUDA/NVDEC preferred)
- **Artifact indexing** — SQLite database tracks all transcriptions, clips, and frames to avoid duplicate processing across sessions
- **Artifact indexing** — JSON file tracks all transcriptions, clips, and frames with relative paths to avoid duplicate processing across sessions
- **Vision analysis guidance** — binary-search-style frame sampling strategy; works with any vision-capable model the agent's runtime provides
- **Iterative edit plans** — Markdown-table output (timecodes, segment descriptions, actions, transitions, notes) refined through follow-up questions
- **Agent-agnostic** — no platform-specific tool names; works with any agent framework (Hermes, Claude Code, Codex, etc.)
@@ -81,20 +81,20 @@ video-edit-planner/
│ ├── frames/
│ │ └── extract_frames.py # Clip extraction + frame sampling (ffmpeg wrapper)
│ └── index/
│ └── manage_index.py # SQLite index management (8 subcommands)
│ └── manage_index.py # JSON index management (8 subcommands)
└── references/
└── frame-extraction-guide.md # Vision model token costs, resolution/batch guidance
```
## Index Database
## Index File
All processing artifacts are tracked in an SQLite database (`<video_stem>.vedit.db`) stored next to the video file:
All processing artifacts are tracked in a JSON file (`<video_stem>.vedit.json`) stored next to the video file:
- **transcription** — JSON path, track index, duration
- **transcriptions** — JSON path, track index, duration
- **clips** — start/end time, file path, extraction reason
- **frames** — timestamp, file path, scene score, extraction method
This avoids re-transcribing or re-extracting frames for the same video across sessions.
All paths are stored as **relative paths** (relative to the video directory), so moving the entire directory does not break references. The JSON file is human-readable and editable with any text editor.
## License
+6 -6
View File
@@ -8,7 +8,7 @@
- **音频转录** — 内置 [funasr-script](https://github.com/modelscope/FunASR),默认使用 Fun-ASR-Nano(高质量,中文优化),SenseVoice 可用于快速预览
- **按需抽帧** — 基于 ffmpeg 的片段剪辑(`-c copy`)+ 场景变化检测 + 均匀采样,优先使用硬件加速(CUDA/NVDEC)
- **产物索引** — SQLite 数据库记录所有转录、剪辑片段和帧图片,避免跨会话重复处理
- **产物索引** — JSON 文件记录所有转录、剪辑片段和帧图片,使用相对路径,避免跨会话重复处理
- **视觉分析指引** — 二分查找式帧采样策略;兼容 agent 运行时提供的任何视觉模型
- **迭代式剪辑规划** — Markdown 表格输出(时间码、片段描述、操作建议、转场方式、备注),支持追问细化
- **Agent 无关** — 不包含任何平台特定工具名;兼容任何 agent 框架(Hermes、Claude Code、Codex 等)
@@ -81,20 +81,20 @@ video-edit-planner/
│ ├── frames/
│ │ └── extract_frames.py # 片段提取 + 抽帧(ffmpeg 封装)
│ └── index/
│ └── manage_index.py # SQLite 索引管理(8 个子命令)
│ └── manage_index.py # JSON 索引管理(8 个子命令)
└── references/
└── frame-extraction-guide.md # 视觉模型 token 成本、分辨率/批次指引
```
## 索引数据库
## 索引文件
所有处理产物记录在 SQLite 数据库`<视频文件名>.vedit.db`)中,存放在视频文件同目录:
所有处理产物记录在 JSON 文件`<视频文件名>.vedit.json`)中,存放在视频文件同目录:
- **transcription** — JSON 路径、音轨索引、时长
- **transcriptions** — JSON 路径、音轨索引、时长
- **clips** — 起止时间、文件路径、提取原因
- **frames** — 时间戳、文件路径、场景分数、提取方法
这避免了跨会话对同一视频重复转录或重复抽帧
所有路径存储为**相对路径**(相对于视频目录),移动整个目录不会破坏引用。JSON 文件可读性强,可用任意文本编辑器直接编辑
## 许可证
+42 -31
View File
@@ -102,47 +102,55 @@ uv run --directory SKILL_DIR/scripts/transcription funasr-nano VIDEO_PATH --list
### Step 5 — Manage index file
An SQLite database tracks all processing artifacts to avoid duplicate work. It lives **next to the video file**.
A JSON file tracks all processing artifacts to avoid duplicate work. It lives **next to the video file** and is human-readable and editable.
```
<video_dir>/<video_stem>.vedit.db
<video_dir>/<video_stem>.vedit.json
```
Schema (managed by `scripts/index/manage_index.py`):
**All file paths are stored as relative paths** (relative to the video directory). Moving the entire directory does not break any references. If files are split across directories, paths outside the video directory are stored as absolute paths — the user can edit the JSON directly with a text editor to fix them.
```sql
CREATE TABLE IF NOT EXISTS transcription (
id INTEGER PRIMARY KEY AUTOINCREMENT,
json_path TEXT NOT NULL,
track_index INTEGER NOT NULL,
created_at REAL NOT NULL, -- Unix timestamp
duration_s REAL
);
Structure (managed by `scripts/index/manage_index.py`):
CREATE TABLE IF NOT EXISTS clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_time REAL NOT NULL, -- seconds from video start
end_time REAL NOT NULL,
path TEXT NOT NULL,
created_at REAL NOT NULL, -- Unix timestamp
reason TEXT -- why this clip was extracted
);
CREATE TABLE IF NOT EXISTS frames (
id INTEGER PRIMARY KEY AUTOINCREMENT,
clip_id INTEGER NOT NULL REFERENCES clips(id),
timestamp REAL NOT NULL, -- seconds from video start
path TEXT NOT NULL,
scene_score REAL, -- ffmpeg scene score if available
method TEXT NOT NULL, -- 'scene' | 'sample' | 'both'
created_at REAL NOT NULL -- Unix timestamp
);
```json
{
"transcriptions": [
{
"id": 1,
"json_path": "video_track1_fun-asr-nano.json",
"track_index": 1,
"created_at": 1783997794.78,
"duration_s": 120.5
}
],
"clips": [
{
"id": 1,
"start_time": 30.0,
"end_time": 60.0,
"path": "video_clips/clip_30-60.mkv",
"created_at": 1783997794.85,
"reason": "user asked about this segment"
}
],
"frames": [
{
"id": 1,
"clip_id": 1,
"timestamp": 32.0,
"path": "video_frames/frame_0001.jpg",
"scene_score": 0.45,
"method": "scene",
"created_at": 1783997795.0
}
]
}
```
Use `scripts/index/manage_index.py` for all CRUD operations:
```bash
# Initialize database (idempotent)
# Initialize index file (idempotent)
uv run --directory SKILL_DIR python scripts/index/manage_index.py init --video VIDEO_PATH
# Add transcription record
@@ -165,6 +173,9 @@ uv run --directory SKILL_DIR python scripts/index/manage_index.py list
# Check if a time range has already been extracted
uv run --directory SKILL_DIR python scripts/index/manage_index.py check-range --start S --end E
# Remove records for files that no longer exist on disk
uv run --directory SKILL_DIR python scripts/index/manage_index.py clean
```
**Completion criteria:** index file exists and all produced artifacts are recorded in it.
@@ -349,7 +360,7 @@ Check only the items relevant to the steps actually executed. Not all items appl
- [ ] Video path and audio track index(es) confirmed. *(Step 2 — required when video processing is needed)*
- [ ] User's editing goal understood. *(Step 3 — skip if not relevant to the user's request)*
- [ ] Transcription completed for all requested tracks, or reused from cache. *(Step 4)*
- [ ] Index file initialized at `<video_dir>/<video_stem>.vedit.db`. *(Step 5 — always required when any processing is done)*
- [ ] Index file initialized at `<video_dir>/<video_stem>.vedit.json`. *(Step 5 — always required when any processing is done)*
- [ ] All extracted clips and frames recorded in the index. *(Step 6 — only if frames were extracted)*
- [ ] Vision analysis performed where needed. *(Step 7 — only if frames were extracted)*
- [ ] Edit plan produced as Markdown table with overall recommendation. *(Step 8 — skip if not relevant to the user's request)*
+144 -131
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env python3
"""Manage the video-edit-planner SQLite index.
"""Manage the video-edit-planner JSON index.
The index file lives next to the video as <video_stem>.vedit.db.
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
@@ -12,117 +14,121 @@ Usage:
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 sqlite3
import sys
import time
from pathlib import Path
SCHEMA = """
CREATE TABLE IF NOT EXISTS transcription (
id INTEGER PRIMARY KEY AUTOINCREMENT,
json_path TEXT NOT NULL,
track_index INTEGER NOT NULL,
created_at REAL NOT NULL,
duration_s REAL
);
CREATE TABLE IF NOT EXISTS clips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
start_time REAL NOT NULL,
end_time REAL NOT NULL,
path TEXT NOT NULL,
created_at REAL NOT NULL,
reason TEXT
);
CREATE TABLE IF NOT EXISTS frames (
id INTEGER PRIMARY KEY AUTOINCREMENT,
clip_id INTEGER NOT NULL REFERENCES clips(id),
timestamp REAL NOT NULL,
path TEXT NOT NULL,
scene_score REAL,
method TEXT NOT NULL,
created_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_frames_clip_id ON frames(clip_id);
CREATE INDEX IF NOT EXISTS idx_clips_time ON clips(start_time, end_time);
"""
from typing import Any
def db_path_for_video(video_path: Path) -> Path:
"""Return the index database path for a given video file."""
return video_path.parent / f"{video_path.stem}.vedit.db"
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 connect(video_path: Path) -> sqlite3.Connection:
db = db_path_for_video(video_path)
if not db.exists():
sys.exit(f"Index not found: {db}. Run 'init' first.")
conn = sqlite3.connect(str(db))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
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()
db = db_path_for_video(video)
conn = sqlite3.connect(str(db))
conn.executescript(SCHEMA)
conn.close()
print(json.dumps({"status": "ok", "db_path": str(db)}))
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()
conn = connect(video)
conn.execute(
"INSERT INTO transcription (json_path, track_index, created_at, duration_s) VALUES (?, ?, ?, ?)",
(args.json_path, args.track_index, time.time(), args.duration),
)
conn.commit()
cur = conn.execute("SELECT last_insert_rowid()")
row_id = cur.fetchone()[0]
conn.close()
print(json.dumps({"status": "ok", "id": row_id}))
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()
conn = connect(video)
data = load_index(video)
records = data["transcriptions"]
if args.track is not None:
rows = conn.execute(
"SELECT * FROM transcription WHERE track_index = ? ORDER BY created_at DESC",
(args.track,),
).fetchall()
else:
rows = conn.execute("SELECT * FROM transcription ORDER BY created_at DESC").fetchall()
conn.close()
if not rows:
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
result = [dict(r) for r in rows]
print(json.dumps({"found": True, "records": result}, ensure_ascii=False))
print(json.dumps({"found": True, "records": records}, ensure_ascii=False))
def cmd_add_clip(args: argparse.Namespace) -> None:
video = Path(args.video).expanduser().resolve()
conn = connect(video)
cur = conn.execute(
"INSERT INTO clips (start_time, end_time, path, created_at, reason) VALUES (?, ?, ?, ?, ?)",
(args.start, args.end, args.path, time.time(), args.reason),
)
conn.commit()
clip_id = cur.lastrowid
conn.close()
print(json.dumps({"status": "ok", "clip_id": clip_id}))
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:
@@ -132,14 +138,13 @@ def cmd_add_frames(args: argparse.Namespace) -> None:
if not frames_dir.is_dir():
sys.exit(f"Frames directory not found: {frames_dir}")
conn = connect(video)
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():
# Parse lavfi.scd.score and lavfi.scd.time from showinfo output
score = None
t = None
for part in line.split():
@@ -150,110 +155,118 @@ def cmd_add_frames(args: argparse.Namespace) -> None:
if score is not None and t is not None:
scene_scores[f"{t:.3f}"] = score
# Parse timestamps from filenames: frame_XXXX.jpg
# The agent should pass --fps and --start to compute timestamps
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:
# Try other extensions
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):
# Estimate timestamp: frame number * (1/fps) + start_offset
# This is approximate; the agent can refine with --timestamps
timestamp = start_offset + (i / fps)
# Try to match with scene score
scene_score = scene_scores.get(f"{timestamp:.3f}")
conn.execute(
"INSERT INTO frames (clip_id, timestamp, path, scene_score, method, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(args.clip_id, timestamp, str(frame_path), scene_score, args.method, time.time()),
)
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
conn.commit()
conn.close()
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()
conn = connect(video)
data = load_index(video)
transcriptions = conn.execute("SELECT * FROM transcription ORDER BY created_at DESC").fetchall()
clips = conn.execute("SELECT * FROM clips ORDER BY start_time").fetchall()
result = {"transcriptions": [dict(r) for r in transcriptions], "clips": []}
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 = conn.execute(
"SELECT * FROM frames WHERE clip_id = ? ORDER BY timestamp", (clip["id"],)
).fetchall()
clip_dict["frames"] = [dict(f) for f in frames]
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)
conn.close()
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()
conn = connect(video)
data = load_index(video)
# Find clips that overlap with the requested range
rows = conn.execute(
"""SELECT * FROM clips
WHERE start_time < ? AND end_time > ?
ORDER BY start_time""",
(args.end, args.start),
).fetchall()
conn.close()
if not rows:
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": [dict(r) for r in rows]}, ensure_ascii=False))
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()
conn = connect(video)
data = load_index(video)
video_dir = video.parent
# Check frames
frame_rows = conn.execute("SELECT id, path FROM frames").fetchall()
frames_removed = 0
for row in frame_rows:
if not Path(row["path"]).exists():
conn.execute("DELETE FROM frames WHERE id = ?", (row["id"],))
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
clip_rows = conn.execute("SELECT id, path FROM clips").fetchall()
clips_removed = 0
for row in clip_rows:
if not Path(row["path"]).exists():
conn.execute("DELETE FROM frames WHERE clip_id = ?", (row["id"],))
conn.execute("DELETE FROM clips WHERE id = ?", (row["id"],))
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
conn.commit()
conn.close()
print(json.dumps({"status": "ok", "frames_removed": frames_removed, "clips_removed": clips_removed}))
# 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 database.")
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 database")
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