DataFlowing / collector /scripts /migrate_pooled_storage.py
stouchinglhh
collector: 同步采集工程 v1.2.1(全部 LFS 模式文件指针化,与 Space 现有文件合并)
d646dd7
Raw History Blame Contribute Delete
57.1 kB
#!/usr/bin/env python
"""一次性迁移脚本:旧「每会话一个子目录」布局 → v1.1.0 任务级池化布局。
旧布局(egodata v1.0,每会话一个子目录):
<task>/<task>_NNNNNN/
├── metadata.json / timestamps.json / calibration/*.json
├── videos/<cam>/chunk-0000/<cam>.mp4
├── data/<sensor>/chunk-0000/chunk_000000.parquet(每传感器一文件)
├── depth/<slot>/<slot>.mkv | NNNNNN.png | NNNNNN.bin
└── meta/{info.json, stats.json, tasks.jsonl,
episodes/{chunk_000000.parquet, chunk-000/file-000.parquet}}
新布局(v1.1.0 任务级池化;v1.1.2 起文件前缀 file- → episode-):
<task>/
├── videos/chunk-NNN/<image_key>/episode-NNN.ext # 每 episode 一文件
├── data/chunk-NNN/episode-NNN.parquet # 单文件稀疏列
└── meta/{info.json, stats.json, tasks.jsonl,
episodes/chunk-NNN/episode-NNN.parquet}
迁移内容(每会话):
1. 按旧 metadata.json episode_index/created_at 排序定新全局序号 N(
已存在池化 episode 之后续排)
2. 视频 move: videos/<cam>/chunk-0000/<cam>.mp4 → 池化路径
3. 深度: mkv 直接 move;png16/bin 序列经 numpy+ffmpeg 无损合成
单流 FFV1 gray16le mkv(数据无损;显示层热力图由读取端按需生成)
4. data parquet 列合并: timestamps.json 为骨架(含 wall_time/
hardware_ns)+ 各传感器/IMU 按 frame_index 对齐(同帧多槽行取首行)
+ action/手部占位零 + 旧 status.<did> 列(如有)
5. episodes 分片 append 一行(created_at/video_codec/drop_stats 取自
metadata.json,calibration 取 calibration/*.json 原文)
6. 任务级 info.json(并集合并)/ tasks.jsonl / stats.json(v1.1.1
自含 count 累加器,无 .stats_state 边车)
7. pipeline.db 改写(recording.file_path → 任务目录 + episode_index;
upload_task.session_path 同);改前自动备份
8. keypoints_output/<task>/<旧会话名>/ → <task>/episode_{N:06d}/
9. 旧会话目录先**全量快照复制**到 data/recordings/_migrate_backup_<ts>/
再移动媒体(快照在前是 restore 无损的前提),迁移完成删除残壳
(--no-backup 跳过快照=显式破坏性模式;manifest 记录全部映射)
用法:
venv/bin/python scripts/migrate_pooled_storage.py --dry-run
venv/bin/python scripts/migrate_pooled_storage.py [--backup] [--task 名称]
venv/bin/python scripts/migrate_pooled_storage.py --validate
venv/bin/python scripts/migrate_pooled_storage.py --restore [--task 名称]
限制(一次性工具,已知可接受):
- stats 为累加器语义:--restore 不回退 stats 数值(若任务无剩余
episode 则删除 stats 文件;有剩余则保留并含已迁移数值)
- 旧格式行无视频帧-传感器一一对应元信息:传感器/IMU 按 frame_index
挂到同帧首行(旧读取端同口径)
- 深度合成单流 FFV1(非双流),显示端按需热力图
- 无 ffmpeg 时跳过 png16/bin 深度会话并警告(视频/数据仍迁移)
- 快照复制需要 2× 磁盘临时余量;确认备份无误后再删除备份区
"""
import argparse
import json
import os
import re
import shutil
import sqlite3
import subprocess
import sys
import time
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import numpy as np
from config import settings
from core.helpers import (episode_chunk_file, POOLED_CHUNK_SIZE,
pooled_video_path,
pooled_data_parquet_path, pooled_episodes_path,
_legacy_episodes_shard_path,
pooled_info_path, pooled_stats_path,
pooled_tasks_jsonl_path,
load_stats_acc, merge_stat_block,
acc_to_stats_json, recalc_stats,
list_task_episodes)
from core.egodata_writer import (_episode_rows_table, _read_episode_rows,
_atomic_write_parquet, _LockedFile)
try:
import fcntl as _fcntl
except ImportError:
_fcntl = None
try:
import cv2 # png16 解码
except ImportError:
cv2 = None
MANIFEST_VERSION = 1
MANIFEST_NAME = ".migrate_manifest.json"
RECORDINGS_DIR = os.path.join(settings.DATA_DIR, "recordings")
DB_PATH = os.path.join(settings.DATA_DIR, "pipeline.db")
DEFAULT_SENSOR_DIM = 256
# ── 日志 / 报告 ──────────────────────────────────────────────
class Report:
"""迁移执行报告(dry-run 只打印,实跑落到 backup 根目录)。"""
def __init__(self):
self.sessions = []
self.warnings = []
def add(self, task, session, n, kind, detail, created, db_rows, warns):
self.sessions.append({
"task": task, "session": session, "N": n, "kind": kind,
"detail": detail, "created": created, "db_rows": db_rows,
})
self.warnings.extend(warns)
def print(self, label):
print(f"\n===== 迁移报告 [{label}] =====")
for s in self.sessions:
print(f" [{s['kind']}] {s['task']} :: {s['session']} → "
f"episode {s['N']} ({s['detail']})")
for f in s["created"]:
print(f" + {f}")
for d in s["db_rows"]:
print(f" db {d['table']} id={d['id']}")
if self.warnings:
print(" ⚠ 警告:")
for w in self.warnings:
print(f" - {w}")
if not self.sessions:
print(" (无可迁移/已回滚的会话)")
# ── 会话发现 ────────────────────────────────────────────────
def task_dirs(root, task_filter):
out = []
for name in sorted(os.listdir(root)):
if name.startswith("_") or name.startswith("."):
continue
d = os.path.join(root, name)
if not os.path.isdir(d):
continue
if task_filter and name not in task_filter:
continue
out.append((name, d))
return out
def legacy_sessions(task_dir, task):
"""任务目录下的旧会话子目录(有 metadata.json 且非池化结构目录)。"""
out = []
try:
children = os.listdir(task_dir)
except OSError:
return out
for name in children:
d = os.path.join(task_dir, name)
if not os.path.isdir(d):
continue
if name in ("videos", "data", "meta", "depth", "calibration") \
or name.startswith("chunk-") or name.startswith("_"):
continue
if os.path.isfile(os.path.join(d, "metadata.json")):
out.append(d)
out.sort(key=lambda d: _session_sort_key(d, task))
return out
def _session_sort_key(session_dir, task):
"""排序键:旧 metadata.json 的 episode_index,回退 created_at/名字。"""
try:
with open(os.path.join(session_dir, "metadata.json"),
"r", encoding="utf-8") as f:
m = json.load(f)
except (OSError, json.JSONDecodeError):
m = {}
return (int(m.get("episode_index", 0) or 0),
float(m.get("created_at", 0) or 0),
os.path.basename(session_dir))
def load_json(path, default=None):
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return default
# ── 旧数据读取 ──────────────────────────────────────────────
def read_ts_rows(session_dir):
"""timestamps.json → ([{frame_index, timestamp, wall_time, hardware_ns}],
total_frames);缺失返回 (None, None)。"""
ts = load_json(os.path.join(session_dir, "timestamps.json"))
if not ts or not isinstance(ts.get("timestamps"), list):
return None, None
return ts["timestamps"], int(ts.get("total_frames") or 0)
def read_sensor_parquet(session_dir, sensor):
"""data/<sensor>/chunk-0000/chunk_000000.parquet → {frame_index: row}"""
for rel in (f"data/{sensor}/chunk-0000/chunk_000000.parquet",
f"data/{sensor}/chunk_000000.parquet",
f"data/{sensor}/chunk-000/file-000.parquet"):
p = os.path.join(session_dir, rel)
if os.path.isfile(p):
import pyarrow.parquet as pq
t = pq.read_table(p)
idx = {}
for r in t.to_pylist():
fi = int(r.get("frame_index", 0))
if fi not in idx: # 同帧多槽行取首行
idx[fi] = r
return idx
return None
def find_legacy_media(session_dir, metadata):
"""定位旧视频/深度文件。
返回 ({cam: (src, kind)}, {depth_slot: (src, kind)})
kind ∈ {move, png16, bin}
"""
vids, depths = {}, {}
for cam, info in (metadata.get("cameras") or {}).items():
if not isinstance(info, dict):
continue
ctype = info.get("type", "rgb")
if ctype == "depth":
src = _find_depth_source(session_dir, cam)
if src:
depths[cam] = src
continue
# rgb: videos/<cam>/chunk-0000/<cam>.mp4(兼容无 chunk 层)
for rel in (f"videos/{cam}/chunk-0000/{cam}.mp4",
f"videos/{cam}/{cam}.mp4"):
p = os.path.join(session_dir, rel)
if os.path.isfile(p):
vids[cam] = (p, "move")
break
if cam not in vids:
# 兜底:videos/<cam> 下任意 mp4(槽名可能与 metadata 名不同)
d = os.path.join(session_dir, "videos", cam)
if os.path.isdir(d):
for root, _dirs, files in os.walk(d):
for fn in sorted(files):
if fn.endswith(".mp4"):
vids[cam] = (os.path.join(root, fn), "move")
break
if cam in vids:
break
return vids, depths
def _find_depth_source(session_dir, slot):
mkv = os.path.join(session_dir, "depth", slot, f"{slot}.mkv")
if os.path.isfile(mkv):
return (mkv, "move")
d = os.path.join(session_dir, "depth", slot)
if not os.path.isdir(d):
return None
pngs = sorted(f for f in os.listdir(d)
if f.endswith(".png") and f.split(".")[0].isdigit())
if pngs:
return (os.path.join(d, pngs[0]), "png16")
bins = sorted(f for f in os.listdir(d)
if f.endswith(".bin") and f.split(".")[0].isdigit())
if bins:
return (os.path.join(d, bins[0]), "bin")
return None
def depth_frame_files(session_dir, slot):
"""深度帧文件按数字文件名升序 → [(abs_path, frame_no)]"""
d = os.path.join(session_dir, "depth", slot)
if not os.path.isdir(d):
return []
out = []
for fn in os.listdir(d):
stem = fn.rsplit(".", 1)[0]
if stem.isdigit() and fn.rsplit(".", 1)[1] in ("png", "bin"):
out.append((os.path.join(d, fn), int(stem)))
out.sort(key=lambda x: x[1])
return out
# ── 深度合成(png16 / bin → 单流 FFV1 gray16le mkv) ─────────
def find_ffmpeg(metadata):
"""ffmpeg 二进制:metadata 记录的 imageio_ffmpeg 路径 → PATH → None。"""
vc = metadata.get("video_codec") or {}
p = vc.get("ffmpeg")
if p and os.path.isfile(p):
return p
try:
import imageio_ffmpeg
exe = imageio_ffmpeg.get_ffmpeg_exe()
if exe and os.path.isfile(exe):
return exe
except Exception:
pass
return shutil.which("ffmpeg")
def synthesize_depth_mkv(task_dir, session_dir, slot, metadata, out_path,
warn):
"""png16/bin 序列 → 无损单流 FFV1 gray16le mkv(out_path)。"""
frames = depth_frame_files(session_dir, slot)
if not frames:
warn(f"{slot}: 深度帧目录为空,跳过")
return False
ffmpeg = find_ffmpeg(metadata)
if not ffmpeg:
warn(f"{slot}: 无 ffmpeg,跳过深度合成({len(frames)} 帧未迁移)")
return False
cam = (metadata.get("cameras") or {}).get(slot) or {}
fps = float(metadata.get("fps") or cam.get("fps") or 30.0)
width = int(cam.get("width") or 0)
height = int(cam.get("height") or 0)
tmp_raw = os.path.join(task_dir, f".migrate_tmp_{slot}_N{int(time.time()*1000)%100000}.raw")
try:
# 逐帧解码/读入 memmap,避免整段驻留内存
first = None
count = 0
ext = os.path.splitext(frames[0][0])[1].lower()
if ext == ".png":
if cv2 is None:
warn(f"{slot}: 无 cv2(OpenCV),跳过 png16 深度合成")
return False
for path, _no in frames:
img = cv2.imread(path, cv2.IMREAD_UNCHANGED)
if img is None:
continue
if img.ndim == 3:
img = img[..., 0]
if img.dtype != np.uint16:
warn(f"{slot}: png 非 uint16({img.dtype}),跳过深度合成")
return False
if first is None:
first = img
width, height = img.shape[1], img.shape[0]
count += 1
else: # bin: raw uint16
if width <= 0 or height <= 0:
warn(f"{slot}: bin 序列缺分辨率(metadata 无 width/height),"
"跳过深度合成")
return False
first = np.zeros((height, width), dtype=np.uint16)
count = len(frames)
if first is None or count == 0:
return False
npx = width * height
mm = np.memmap(tmp_raw, dtype=np.uint16, mode="w+",
shape=(count * npx,))
i = 0
if ext == ".png":
for path, _no in frames:
img = cv2.imread(path, cv2.IMREAD_UNCHANGED)
if img is None or img.ndim == 3:
img = img[..., 0] if img is not None and img.ndim == 3 \
else np.zeros((height, width), np.uint16)
mm[i * npx:(i + 1) * npx] = img.reshape(-1)[:npx]
i += 1
else:
for path, _no in frames:
raw = np.fromfile(path, dtype=np.uint16)
mm[i * npx:(i + 1) * npx] = raw[:npx]
i += 1
mm.flush()
del mm
os.makedirs(os.path.dirname(out_path), exist_ok=True)
cmd = [ffmpeg, "-y", "-loglevel", "error",
"-f", "rawvideo", "-pix_fmt", "gray16le",
"-s", f"{width}x{height}", "-framerate", f"{fps}",
"-i", tmp_raw,
"-c:v", "ffv1", "-pix_fmt", "gray16le", out_path]
r = subprocess.run(cmd, capture_output=True, text=True)
if r.returncode != 0:
warn(f"{slot}: ffmpeg 合成失败: {r.stderr.strip()[:300]}")
return False
return True
finally:
try:
os.remove(tmp_raw)
except OSError:
pass
# ── data parquet 列合并 ─────────────────────────────────────
def build_merged_table(meta, ts_rows, sensor_idx, imu_idx):
"""按新格式契约构建本 episode 的 data 表。
ts_rows 为骨架(一行一帧槽位行);sensor_idx/imu_idx 为
{frame_index: 旧行}(同帧多槽行取首行)。
"""
import pyarrow as pa
n_ep = int(meta.get("episode_index", 0)) # 由调用方覆盖为新 N
sensor_dim = int((meta.get("sensor_dim") or DEFAULT_SENSOR_DIM)
or DEFAULT_SENSOR_DIM)
hand_dim = int(settings.HAND_POSE_DIM or 63)
sensors = sorted(sensor_idx.keys())
cols = {}
cols["episode_index"] = pa.array([n_ep] * len(ts_rows), pa.int64())
cols["frame_index"] = pa.array([int(r.get("frame_index", 0))
for r in ts_rows], pa.int64())
cols["timestamp"] = pa.array([float(r.get("timestamp", 0.0))
for r in ts_rows], pa.float32())
cols["task_index"] = pa.array([0] * len(ts_rows), pa.int64())
cols["wall_time"] = pa.array([float(r.get("wall_time", 0.0))
for r in ts_rows], pa.float64())
cols["hardware_ns"] = pa.array([int(r.get("hardware_ns", 0) or 0)
for r in ts_rows], pa.int64())
cols["action"] = pa.array([[0.0]] * len(ts_rows),
pa.list_(pa.float32(), 1))
for sn in sensors:
idx = sensor_idx[sn]
vals = []
for r in ts_rows:
fi = int(r.get("frame_index", 0))
row = idx.get(fi)
v = (row or {}).get(f"observation.{sn}")
if v is None:
v = [0.0] * sensor_dim
v = [float(x) for x in v]
if len(v) < sensor_dim:
v = v + [0.0] * (sensor_dim - len(v))
vals.append(v[:sensor_dim])
cols[f"observation.{sn}"] = pa.array(
vals, pa.list_(pa.float32(), sensor_dim))
if imu_idx is not None:
# IMU 只挂在每帧首行(旧格式=stereo_left 行,左右目共享样本)
ts_list, imu_list = [], []
seen = set()
for r in ts_rows:
fi = int(r.get("frame_index", 0))
row = imu_idx.get(fi)
if fi in seen or row is None:
ts_list.append([])
imu_list.append([])
continue
seen.add(fi)
ts_list.append([int(x) for x in (row.get("imu_ts_ns") or [])])
imu_list.append([[float(x) for x in s]
for s in (row.get("observation.imu") or [])])
cols["imu_ts_ns"] = pa.array(ts_list, pa.list_(pa.int64()))
cols["observation.imu"] = pa.array(
imu_list, pa.list_(pa.list_(pa.float32(), 6)))
for pose in (settings.HAND_POSE_LEFT, settings.HAND_POSE_RIGHT):
cols[f"observation.{pose}"] = pa.array(
[[0.0] * hand_dim] * len(ts_rows),
pa.list_(pa.float32(), hand_dim))
# status.<did>:旧 parquet 携带过设备状态列则保留(同帧取首行)
dids = set()
for idx in sensor_idx.values():
for row in (idx or {}).values():
for key in (row or {}).keys():
if isinstance(key, str) and key.startswith("status."):
dids.add(key[len("status."):])
for did in sorted(dids):
vals = []
for r in ts_rows:
fi = int(r.get("frame_index", 0))
row = next((idx.get(fi) for idx in sensor_idx.values()
if f"status.{did}" in (idx.get(fi) or {})), None)
vals.append(str((row or {}).get(f"status.{did}", "connected")))
cols[f"status.{did}"] = pa.array(vals, pa.string())
return pa.table(cols), sensors
# ── 统计(stats.json 自含累加器,v1.1.1) ──────────────────
def episode_stat_blocks(table, sensors, has_imu):
"""本 episode 各列的统计块 {key: {count,sum,sum_sq,min,max}}。"""
blocks = {}
cols = {c: table.column(c).to_pylist() for c in table.column_names}
for sn in sensors:
name = f"observation.{sn}"
vals = [np.asarray(v, dtype=np.float64) for v in cols[name]
if v and any(x != 0 for x in v)]
if not vals:
continue
arr = np.stack(vals)
blocks[name] = {
"count": int(arr.shape[0]),
"sum": arr.sum(axis=0).tolist(),
"sum_sq": (arr * arr).sum(axis=0).tolist(),
"min": arr.min(axis=0).tolist(),
"max": arr.max(axis=0).tolist(),
}
if has_imu:
samples = [np.asarray(s, dtype=np.float64)
for v in cols["observation.imu"] for s in v]
if samples:
arr = np.stack(samples)
blocks["observation.imu"] = {
"count": int(arr.shape[0]),
"sum": arr.sum(axis=0).tolist(),
"sum_sq": (arr * arr).sum(axis=0).tolist(),
"min": arr.min(axis=0).tolist(),
"max": arr.max(axis=0).tolist(),
}
return blocks
def merge_state_and_write(task_dir, blocks):
"""把各 episode 统计块合并进 stats.json(v1.1.1 自含累加器,同写入器)。"""
acc, need_recalc = load_stats_acc(task_dir)
if need_recalc:
acc = recalc_stats(task_dir)
for key, blk in blocks.items():
dim = len(blk["sum"])
merge_stat_block(acc, key, blk, dim)
_atomic_write_json(pooled_stats_path(task_dir), acc_to_stats_json(acc))
def _atomic_write_json(path, obj):
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(obj, f, indent=2, ensure_ascii=False)
os.replace(tmp, path)
# ── info.json 合成(任务级并集合并,同写入器语义) ──────────
def build_info(task_dir, task, sessions_meta):
"""sessions_meta: [(metadata, sensors, has_imu, calib_dict)] 按 N 升序。"""
path = pooled_info_path(task_dir)
base = load_json(path, {})
if not isinstance(base, dict):
base = {}
merged_features = dict(base.get("features") or {})
merged_cameras = dict(base.get("cameras") or {})
merged_exts = dict(base.get("video_extensions") or {})
merged_calib = dict(base.get("calibration") or {})
sensors_union, has_imu_any = set(), False
last = None
for meta, sensors, has_imu, calib in sessions_meta:
last = meta
sensors_union |= set(sensors)
has_imu_any = has_imu_any or has_imu
for sn in sorted(sensors):
merged_features[f"observation.{sn}"] = {
"dtype": "float32", "shape": [16, 16]}
if has_imu:
merged_features["observation.imu"] = {
"dtype": "float32", "shape": [6]}
merged_features["action"] = {"dtype": "float32", "shape": [1]}
for cam, info in (meta.get("cameras") or {}).items():
if not isinstance(info, dict):
continue
if info.get("type") == "depth":
merged_exts[cam] = "mkv"
continue
entry = {"height": int(info.get("height", 0)),
"width": int(info.get("width", 0))}
cam_fps = float(info.get("fps") or meta.get("fps") or 0.0)
if cam_fps and cam_fps != float(meta.get("fps") or 0.0):
entry["fps"] = cam_fps
merged_cameras[cam] = entry
merged_exts.update({cam: "mp4" for cam in merged_cameras})
merged_calib.update(calib or {})
devices, device_names = [], {}
if last:
devices = [{"key": d.get("key", ""), "kind": d.get("kind", ""),
"name": d.get("name", ""),
"slots": list(d.get("slots") or [])}
for d in (last.get("devices") or [])]
for d in (last.get("devices") or []):
if d.get("name"):
for slot in (d.get("slots") or []):
device_names[slot] = d["name"]
info = dict(base)
info.update({
"format": "pooled_episodes_v1",
"chunks_size": POOLED_CHUNK_SIZE,
"data_path": "data/chunk-{c:03d}/episode-{f:03d}.parquet",
"video_path": "videos/chunk-{c:03d}/{image_key}/episode-{f:03d}.{ext}",
"episodes_path": "meta/episodes/chunk-{c:03d}/episode-{f:03d}.parquet",
"codebase_version": (last or {}).get("codebase_version", "v3.0"),
"app_version": f"{settings.APP_VERSION}-migrated",
"fps": float((last or {}).get("fps") or 30.0),
"video": len(merged_cameras) > 0,
"task_name": task,
"features": merged_features,
"cameras": merged_cameras,
"devices": devices,
"device_names": device_names,
"sensors": sorted(sensors_union),
"sensor_dim": int((last or {}).get("sensor_dim")
or DEFAULT_SENSOR_DIM),
"created_at": max([float((m or {}).get("created_at") or 0.0)
for m, *_ in sessions_meta] or [time.time()]),
"calibration": merged_calib,
"total_episodes": len(list_task_episodes(task_dir)),
"video_extensions": merged_exts,
})
return info
# ── episodes 分片 append ───────────────────────────────────
def _task_lock(task_dir):
lock_path = os.path.join(task_dir, "meta", "episodes", ".lock")
os.makedirs(os.path.dirname(lock_path), exist_ok=True)
fh = open(lock_path, "a+")
if _fcntl is not None:
_fcntl.flock(fh.fileno(), _fcntl.LOCK_EX)
return _LockedFile(fh)
def append_episode_row(task_dir, n, row):
"""每段一个文件、单行原子写(与 data/videos 同编号);旧分片里
的同号行一并删除,避免双份。"""
path = pooled_episodes_path(task_dir, n)
os.makedirs(os.path.dirname(path), exist_ok=True)
with _task_lock(task_dir):
_atomic_write_parquet(_episode_rows_table([row]), path)
cidx, _ = episode_chunk_file(n)
legacy = _legacy_episodes_shard_path(task_dir, cidx)
if legacy != path and os.path.isfile(legacy):
rows = _read_episode_rows(legacy)
kept = [r for r in rows if int(r.get("episode_index", 0)) != n]
if len(kept) != len(rows):
if kept:
_atomic_write_parquet(_episode_rows_table(kept), legacy)
else:
os.remove(legacy)
def remove_episode_row(task_dir, n):
"""移除 episode N 的 episodes 元数据(每段文件 → os.remove;
旧分片回退 → 读-改-写删行)。N=1 时分片与每段文件同路径,
先按内容判每段文件才删,避免误删整个旧分片。"""
path = pooled_episodes_path(task_dir, n)
cidx, _ = episode_chunk_file(n)
legacy = _legacy_episodes_shard_path(task_dir, cidx)
with _task_lock(task_dir):
removed_path = False
if os.path.isfile(path):
rows = _read_episode_rows(path)
if len(rows) <= 1 and (not rows or
int(rows[0].get("episode_index", 0)) == n):
os.remove(path) # 每段文件(或旧分片只剩该行)
removed_path = True
if os.path.isfile(legacy) and not (removed_path and legacy == path):
rows = _read_episode_rows(legacy)
kept = [r for r in rows if int(r.get("episode_index", 0)) != n]
if len(kept) != len(rows):
if kept:
_atomic_write_parquet(_episode_rows_table(kept), legacy)
else:
os.remove(legacy) # 分片空 → 删除文件本身
# ── DB 改写 ────────────────────────────────────────────────
def db_rows_for_path(db_path, col, old_path):
"""返回 {table: [(id, 现值)]}——只匹配旧会话目录绝对路径。"""
if not os.path.isfile(db_path):
return [], []
con = sqlite3.connect(db_path)
try:
rec = con.execute(
"SELECT id, file_path FROM recording "
"WHERE file_path=? AND episode_index=0", (old_path,)).fetchall()
up = con.execute(
"SELECT id, session_path FROM upload_task "
"WHERE session_path=?", (old_path,)).fetchall()
except sqlite3.OperationalError:
rec, up = [], []
finally:
con.close()
return [{"id": r[0], "table": "recording", "old": r[1]}
for r in rec], [{"id": r[0], "table": "upload_task", "old": r[1]}
for r in up]
def db_update_episode(db_path, rows, task_dir, n, task):
con = sqlite3.connect(db_path)
try:
for r in rows:
if r["table"] == "recording":
con.execute("UPDATE recording SET file_path=?, "
"episode_index=? WHERE id=?",
(task_dir, n, r["id"]))
else:
con.execute("UPDATE upload_task SET session_path=?, "
"session_name=?, episode_index=? WHERE id=?",
(task_dir, task, n, r["id"]))
con.commit()
finally:
con.close()
def db_restore_episode(db_path, rows):
con = sqlite3.connect(db_path)
try:
for r in rows:
if r["table"] == "recording":
con.execute("UPDATE recording SET file_path=?, "
"episode_index=0 WHERE id=?",
(r["old"], r["id"]))
else:
con.execute("UPDATE upload_task SET session_path=?, "
"episode_index=0 WHERE id=?",
(r["old"], r["id"]))
con.commit()
finally:
con.close()
# ── 单个会话迁移 ───────────────────────────────────────────
def migrate_session(task_dir, task, session_dir, n, backup_root, report,
dry_run, backup, db_path):
warns = []
meta = load_json(os.path.join(session_dir, "metadata.json"), {})
if not meta:
return "skip", warns
ts_rows, total_frames = read_ts_rows(session_dir)
if ts_rows is None or not ts_rows:
# 无 timestamps.json:从传感器 parquet 并集重建骨架(wall_time 近似)
import pyarrow.parquet as pq
skeleton = {}
for sub in os.listdir(os.path.join(session_dir, "data")):
idx = read_sensor_parquet(session_dir, sub)
for fi, r in (idx or {}).items():
skeleton.setdefault(fi, {
"frame_index": fi,
"timestamp": float(r.get("timestamp", 0.0)),
"wall_time": float(meta.get("created_at", time.time()))
+ float(r.get("timestamp", 0.0)),
"hardware_ns": int(r.get("hardware_ns", 0) or 0),
})
ts_rows = [skeleton[k] for k in sorted(skeleton)]
warns.append("无 timestamps.json,骨架由 data parquet 重建"
"(wall_time 近似)")
if not ts_rows:
return "skip", ["无 timestamps.json 且无 data parquet,跳过"]
vids, depths = find_legacy_media(session_dir, meta)
# 防撞护栏:任何计划目标已存在 → 整会话跳过,绝不覆盖池化已有文件
# (部分迁移/并发重跑时 N 可能撞号,文件级检查是最强不变量)
existing = []
for cam in sorted(vids):
if os.path.exists(pooled_video_path(task_dir, cam, n, ext="mp4")):
existing.append(os.path.relpath(
pooled_video_path(task_dir, cam, n, ext="mp4"), task_dir))
for slot in sorted(depths):
if os.path.exists(pooled_video_path(task_dir, slot, n, ext="mkv")):
existing.append(os.path.relpath(
pooled_video_path(task_dir, slot, n, ext="mkv"), task_dir))
if os.path.exists(pooled_data_parquet_path(task_dir, n)):
existing.append(os.path.relpath(
pooled_data_parquet_path(task_dir, n), task_dir))
# episodes 行撞号(restore 半途失败残留行 → 追加会造重复行;
# list_task_episodes 只扫 data/videos 文件,看不到 episodes 行)
# 每段文件与旧分片两条路径都要查(N=1 时两者同路径,set 去重)
import pyarrow.parquet as pq
cidx, _ = episode_chunk_file(n)
for cand in {pooled_episodes_path(task_dir, n),
_legacy_episodes_shard_path(task_dir, cidx)}:
if os.path.isfile(cand):
try:
if any(r["episode_index"] == n for r in pq.read_table(
cand, columns=["episode_index"]).to_pylist()):
existing.append(f"meta/episodes 行 {n}")
except Exception:
pass
if existing:
return "skip", [f"防撞护栏:池化目标已存在,跳过本会话绝不覆盖:"
f"{existing[:4]}"]
# ① 完整快照先行(媒体 move 出目录之前),否则 restore 会丢数据
backup_path = os.path.join(backup_root, task,
os.path.basename(session_dir))
if not dry_run and backup:
os.makedirs(os.path.dirname(backup_path), exist_ok=True)
try:
shutil.copytree(session_dir, backup_path)
except OSError as e:
return "skip", [f"快照失败({e}),本会话不动"]
if not os.path.isfile(os.path.join(backup_path, "metadata.json")):
shutil.rmtree(backup_path, ignore_errors=True)
return "skip", ["快照缺 metadata.json(校验失败),本会话不动"]
sensors_found = {}
imu_idx = None
data_dir = os.path.join(session_dir, "data")
if os.path.isdir(data_dir):
for sub in sorted(os.listdir(data_dir)):
if sub == "imu":
imu_idx = read_sensor_parquet(session_dir, "imu")
continue
idx = read_sensor_parquet(session_dir, sub)
if idx:
# 全零传感器视为从未出现(旧格式零填充=缺席)
present = False
for r in idx.values():
v = r.get(f"observation.{sub}")
if v and any(float(x) != 0 for x in v):
present = True
break
if present:
sensors_found[sub] = idx
else:
warns.append(f"传感器 {sub} 全零,视为缺席不写列")
created = []
def plan_create(rel):
return os.path.join(task_dir, rel)
# 视频 move
for cam, (src, kind) in sorted(vids.items()):
dst = pooled_video_path(task_dir, cam, n, ext="mp4")
created.append(dst)
if not dry_run:
os.makedirs(os.path.dirname(dst), exist_ok=True)
_move_file(src, dst)
for cam, info in (meta.get("cameras") or {}).items():
if (isinstance(info, dict)
and info.get("type", "rgb") != "depth"
and cam not in vids):
warns.append(f"摄像头 {cam} 无视频文件(旧目录缺 mp4)")
# 深度 move / 合成
depth_done = {}
for slot, (src, kind) in sorted(depths.items()):
dst = pooled_video_path(task_dir, slot, n, ext="mkv")
created.append(dst)
if kind == "move":
if not dry_run:
os.makedirs(os.path.dirname(dst), exist_ok=True)
_move_file(src, dst)
depth_done[slot] = "mkv"
else: # png16 / bin
if dry_run:
depth_done[slot] = f"{kind}→mkv"
continue
ok = synthesize_depth_mkv(task_dir, session_dir, slot, meta,
dst, warns.append)
depth_done[slot] = "mkv" if ok else "skipped"
# data parquet
import pyarrow as pa
table, sensors = build_merged_table(meta, ts_rows, sensors_found,
imu_idx)
table = table.set_column(
0, "episode_index",
pa.array([n] * table.num_rows, pa.int64()))
data_path = pooled_data_parquet_path(task_dir, n)
created.append(data_path)
if not dry_run:
os.makedirs(os.path.dirname(data_path), exist_ok=True)
_atomic_write_parquet(table, data_path)
# episodes 行
calib = {}
cal_dir = os.path.join(session_dir, "calibration")
if os.path.isdir(cal_dir):
for fn in sorted(os.listdir(cal_dir)):
if fn.endswith(".json"):
c = load_json(os.path.join(cal_dir, fn))
if isinstance(c, dict):
calib[fn[:-5]] = c
video_codec = dict(meta.get("video_codec") or {})
video_codec.pop("ffmpeg", None) # 机器相关绝对路径不入库
fps = float(meta.get("fps") or 30.0)
ts_vals = [float(r.get("timestamp", 0.0)) for r in ts_rows]
ep_row = {
"episode_index": n,
"task_index": 0,
"start_frame_index": int(min(r["frame_index"] for r in ts_rows)),
"end_frame_index": int(max(r["frame_index"] for r in ts_rows)),
"length": int(total_frames
or len({int(r["frame_index"]) for r in ts_rows})),
"created_at": float(meta.get("created_at") or time.time()),
# 真实墙钟时长(多槽行共享时间轴,不随行数翻倍)
"duration_sec": float(max(0.0, max(ts_vals) - min(ts_vals))
+ 1.0 / fps),
"drop_stats": json.dumps(meta.get("drop_stats") or {},
ensure_ascii=False),
"video_codec": json.dumps(video_codec, ensure_ascii=False),
"calibration": json.dumps(calib, ensure_ascii=False),
}
if not dry_run:
append_episode_row(task_dir, n, ep_row)
# DB 改写(先于目录移动,崩溃后重跑可从 manifest 续)
db_rec, db_up = db_rows_for_path(db_path, "file_path", session_dir)
if not dry_run and (db_rec or db_up):
db_update_episode(db_path, db_rec + db_up, task_dir, n, task)
# keypoints_output 重键
kp_moves = []
kp_base = os.path.join(settings.DATA_DIR, "keypoints_output", task)
kp_old = os.path.join(kp_base, os.path.basename(session_dir))
kp_new = os.path.join(kp_base, f"episode_{n:06d}")
if os.path.isdir(kp_old):
kp_moves.append((kp_old, kp_new))
if not dry_run:
os.makedirs(os.path.dirname(kp_new), exist_ok=True)
_move_file(kp_old, kp_new)
# ② 媒体已 move 入池化(快照在前,--no-backup 时无快照);删除残壳
# 防重跑重复发现(--no-backup 属显式破坏性模式)
if not dry_run:
shutil.rmtree(session_dir, ignore_errors=True)
report.add(task, os.path.basename(session_dir), n,
"migrated" if not dry_run else "planned",
f"视频 {len(vids)} 路 / 深度 {list(depth_done.values())} / "
f"数据 {table.num_rows} 行 x {len(sensors)} 传感器",
created, db_rec + db_up, warns)
return {
"session": os.path.basename(session_dir),
"N": n,
"backup_path": backup_path,
"created_files": created,
"db_rows": db_rec + db_up,
"keypoints": [{"from": a, "to": b} for a, b in kp_moves],
"metadata": meta,
"sensors": sensors,
"has_imu": imu_idx is not None
and any((r.get("observation.imu") or []) for r in imu_idx.values()),
"calib": calib,
"warnings": warns,
}, warns
def _move_file(src, dst):
try:
os.rename(src, dst)
except OSError:
shutil.move(src, dst)
# ── 任务级收尾 ─────────────────────────────────────────────
def finalize_task(task_dir, task, session_entries, dry_run):
"""info.json / tasks.jsonl / stats 合成(实跑时)。"""
metas = [(e["metadata"], e["sensors"], e["has_imu"], e["calib"])
for e in session_entries if e.get("metadata")]
info = build_info(task_dir, task, metas)
blocks = {}
if not dry_run:
for e in session_entries:
import pyarrow.parquet as pq
p = pooled_data_parquet_path(task_dir, e["N"])
if os.path.isfile(p):
t = pq.read_table(p)
blocks.update(episode_stat_blocks(
t, e["sensors"], e["has_imu"]))
_atomic_write_json(pooled_info_path(task_dir), info)
if not os.path.isfile(pooled_tasks_jsonl_path(task_dir)):
with open(pooled_tasks_jsonl_path(task_dir), "w",
encoding="utf-8") as f:
json.dump({"task_index": 0, "task": task}, f,
ensure_ascii=False)
f.write("\n")
merge_state_and_write(task_dir, blocks)
return info
# ── manifest ───────────────────────────────────────────────
def manifest_path(task_dir):
return os.path.join(task_dir, "meta", MANIFEST_NAME)
def load_manifest(task_dir):
m = load_json(manifest_path(task_dir), {})
if not isinstance(m, dict):
m = {}
return m
def save_manifest(task_dir, manifest):
_atomic_write_json(manifest_path(task_dir), manifest)
# ── 回滚 ───────────────────────────────────────────────────
def restore_task(task_dir, task, manifest, dry_run, report):
"""按 manifest 回滚一个任务的全部迁移。"""
entries = manifest.get("sessions") or []
for e in reversed(entries):
n = int(e["N"])
for f in e.get("created_files") or []:
if os.path.isfile(f):
if not dry_run:
os.remove(f)
remove_episode_row(task_dir, n) if not dry_run else None
if not dry_run and (e.get("db_rows")):
db_restore_episode(DB_PATH, e["db_rows"])
for kp in e.get("keypoints") or []:
if os.path.isdir(kp["to"]):
if not dry_run:
os.makedirs(os.path.dirname(kp["from"]),
exist_ok=True)
_move_file(kp["to"], kp["from"])
bp = e.get("backup_path")
if bp and os.path.isdir(bp):
if not dry_run:
os.makedirs(task_dir, exist_ok=True)
_move_file(bp, os.path.join(
task_dir, os.path.basename(bp)))
report.add(task, e.get("session", "?"), n,
"restored" if not dry_run else "planned-restore",
f"backup={bp}", [], [], e.get("warnings") or [])
# 备份根目录若已清空则一并回收
if not dry_run:
for entry in entries:
bp = entry.get("backup_path")
if bp:
root = os.path.dirname(os.path.dirname(bp))
if os.path.isdir(root) and not os.listdir(root):
_rmdir_empty(root)
# 任务清空则回收 info/stats/tasks;有剩余 episode 则保留(stats 不回退)
if not dry_run and not list_task_episodes(task_dir):
for p in (pooled_info_path(task_dir), pooled_stats_path(task_dir),
pooled_tasks_jsonl_path(task_dir)):
try:
os.remove(p)
except OSError:
pass
# 池化结构空目录自底向上回收(不动已恢复的旧会话目录)
if not dry_run:
for sub in ("videos", "data"):
for root, dirs, _files in os.walk(
os.path.join(task_dir, sub), topdown=False):
_rmdir_empty(root)
ep_dir = os.path.join(task_dir, "meta", "episodes")
for root, _dirs, _files in os.walk(ep_dir, topdown=False):
# 注意:不能用 walk 的 dirs 判断——那是下探时快照,
# 子目录在自底向上过程中已被删,快照仍非空会挡住清理
leftovers = [e for e in os.listdir(root) if e != ".lock"]
if not leftovers:
try:
os.remove(os.path.join(root, ".lock"))
except OSError:
pass
_rmdir_empty(root)
_rmdir_empty(os.path.join(task_dir, "meta"))
if not dry_run:
try:
os.remove(manifest_path(task_dir))
except OSError:
pass
_rmdir_empty(os.path.join(task_dir, "meta"))
def _rmdir_empty(path):
try:
os.rmdir(path)
except OSError:
pass
# ── 校验 ───────────────────────────────────────────────────
def _episode_video_files(task_dir, n):
"""episode N 的全部视频文件路径列表(任意 key/扩展名)。"""
_, fidx = episode_chunk_file(n)
out = []
vroot = os.path.join(task_dir, "videos")
if os.path.isdir(vroot):
for cname in os.listdir(vroot):
cdir = os.path.join(vroot, cname)
if not os.path.isdir(cdir):
continue
for key in os.listdir(cdir):
kd = os.path.join(cdir, key)
if not os.path.isdir(kd):
continue
for fn in os.listdir(kd):
# v1.1.2 起 episode- 前缀;file- 前缀为更早数据(容忍)
if (fn.startswith(f"episode-{fidx:03d}.")
or fn.startswith(f"file-{fidx:03d}.")):
out.append(os.path.join(kd, fn))
return out
def validate_task(task_dir, task):
"""读取端自检:episodes 行 ↔ data parquet ↔ 视频文件组 ↔ info.json。"""
print(f"── 校验 {task} ──")
import pyarrow.parquet as pq
ok = True
info = load_json(pooled_info_path(task_dir), {})
if info.get("format") != "pooled_episodes_v1":
print(" ✗ info.json 缺失或非池化格式")
return False
episodes = []
dupes = set()
ep_dir = os.path.join(task_dir, "meta", "episodes")
if not os.path.isdir(ep_dir):
print(" ✗ meta/episodes 缺失")
return False
for c in sorted(os.listdir(ep_dir)):
if not c.startswith("chunk-"):
continue
d = os.path.join(ep_dir, c)
for f in sorted(os.listdir(d)):
if not f.endswith(".parquet"):
continue
p = os.path.join(d, f)
rows = pq.read_table(p).to_pylist()
# 每段文件不变量:单行且 episode-{f}(旧数据 file-{f})与
# episode_index 同编号
if len(rows) == 1:
n = int(rows[0]["episode_index"])
fm = re.match(r"^(?:episode|file)-(\d+)\.parquet$", f)
fidx = int(fm.group(1)) if fm else -1
if fidx != (n - 1) % POOLED_CHUNK_SIZE:
print(f" ✗ {os.path.relpath(p, task_dir)}: "
f"episode-{fidx:03d} 与 episode {n} 编号不符")
ok = False
for r in rows:
n = int(r["episode_index"])
if any(int(x["episode_index"]) == n for x in episodes):
dupes.add(n)
episodes.append(r)
if dupes:
print(f" ✗ episode 行重复(新旧布局并存): {sorted(dupes)}")
ok = False
print(f" episodes 行: {len(episodes)}")
for r in episodes:
n = int(r["episode_index"])
data = pooled_data_parquet_path(task_dir, n)
if not os.path.isfile(data):
print(f" ✗ episode {n}: 缺 data parquet")
ok = False
continue
t = pq.read_table(data)
cols = set(t.column_names)
need = {"episode_index", "frame_index", "timestamp", "task_index",
"wall_time", "hardware_ns", "action"}
miss = need - cols
if miss:
print(f" ✗ episode {n}: 缺列 {sorted(miss)}")
ok = False
else:
print(f" ✓ episode {n}: {t.num_rows} 行, 列 "
f"{sorted(c for c in cols if c.startswith('observation') or c.startswith('status'))}")
# 视频:episode 自己的文件组(key 可跨会话改名,不能要求
# 每个 key 每个 episode 都有文件)
if not _episode_video_files(task_dir, n):
print(f" ✗ episode {n}: 无任何视频文件")
ok = False
# 每个声明的 key 至少在某个 episode 有文件(任务级并集口径)
for key, ext in (info.get("video_extensions") or {}).items():
if not any(os.path.isfile(pooled_video_path(
task_dir, key, int(r["episode_index"]), ext=ext))
for r in episodes):
print(f" ✗ 视频 key {key} ({ext}) 全任务无文件")
ok = False
if os.path.isfile(pooled_stats_path(task_dir)):
print(f" ✓ stats.json 存在: {sorted(load_json(pooled_stats_path(task_dir), {}))}")
return ok
def split_episodes(task_dir, task, dry_run):
"""旧分片(每 chunk 一个多行文件)→ 每段一个文件;幂等。
- 多行文件按 episode_index 拆成每段单行文件(每段文件已存在
则跳过,不覆盖——每段文件为准)。
- v1.1.2 起每段文件前缀 episode-,与旧分片名 file-{chunk} 不再
重名(v1.1.1 及更早两者同路径,写回源路径特判已无必要但保留)。
- 拆完删除旧分片;单行文件不动。
- 拆分过则刷新 info.json episodes_path 模板(旧任务写的是旧模板)。
"""
ep_dir = os.path.join(task_dir, "meta", "episodes")
if not os.path.isdir(ep_dir):
print(f" {task}: 无 meta/episodes,跳过")
return
changed = False
for c in sorted(os.listdir(ep_dir)):
if not c.startswith("chunk-"):
continue
d = os.path.join(ep_dir, c)
for fn in sorted(os.listdir(d)):
if not fn.endswith(".parquet"):
continue
p = os.path.join(d, fn)
rows = _read_episode_rows(p)
if len(rows) <= 1:
continue # 每段新布局或只剩一行,不动
dests = {}
for r in rows:
n = int(r.get("episode_index", 0))
if n:
dests[pooled_episodes_path(task_dir, n)] = r
wrote = 0
for dst, r in dests.items():
if dst == p:
continue # 源路径自身最后写
if os.path.isfile(dst):
continue # 每段文件已存在(幂等,不覆盖)
if dry_run:
wrote += 1
continue
os.makedirs(os.path.dirname(dst), exist_ok=True)
_atomic_write_parquet(_episode_rows_table([r]), dst)
wrote += 1
if dry_run:
print(f" [plan] {task}: {os.path.relpath(p, task_dir)} "
f"{len(rows)} 行 → {len(dests)} 个每段文件")
continue
if p in dests:
# chunk-0:N=1 的行写回源路径(分片变成每段文件)
_atomic_write_parquet(_episode_rows_table([dests[p]]), p)
else:
os.remove(p)
changed = True
print(f" {task}: {os.path.relpath(p, task_dir)} {len(rows)} 行 "
f"→ {len(dests)} 个每段文件")
if changed and not dry_run:
info_path = pooled_info_path(task_dir)
info = load_json(info_path, {})
if isinstance(info, dict):
info["episodes_path"] = ("meta/episodes/chunk-{c:03d}/"
"episode-{f:03d}.parquet")
_atomic_write_json(info_path, info)
if dry_run:
print(f" [plan] {task}: 以上为计划,未落盘")
# ── 主流程 ─────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(description="旧会话 → v1.1.0 任务池化布局")
ap.add_argument("--task", action="append", default=[],
help="只处理指定任务目录名(可多次)")
ap.add_argument("--dry-run", action="store_true",
help="只打印迁移计划,不落盘")
ap.add_argument("--backup", action="store_true", default=True,
help="旧会话目录移入备份区(默认开)")
ap.add_argument("--no-backup", dest="backup", action="store_false",
help="旧会话目录不备份(不推荐)")
ap.add_argument("--force", action="store_true",
help="任务已有 manifest 时仍处理剩余旧会话")
ap.add_argument("--restore", action="store_true",
help="按 manifest 回滚迁移")
ap.add_argument("--validate", action="store_true",
help="读取端自检(迁移后校验报告)")
ap.add_argument("--split-episodes", action="store_true",
help="旧 episodes 分片(每 chunk 多行)拆成每段一文件(幂等)")
ap.add_argument("--recordings-root", default=None,
help="测试用:覆盖录制根目录(默认 data/recordings)")
ap.add_argument("--db-path", default=None,
help="测试用:覆盖 pipeline.db 路径")
args = ap.parse_args()
global RECORDINGS_DIR, DB_PATH
if args.recordings_root:
RECORDINGS_DIR = args.recordings_root
if args.db_path:
DB_PATH = args.db_path
report = Report()
run_ts = time.strftime("%Y%m%d_%H%M%S")
backup_root = os.path.join(RECORDINGS_DIR,
f"_migrate_backup_{run_ts}")
db_backup_path = os.path.join(
settings.DATA_DIR, f"pipeline.db.migrate_backup_{run_ts}")
db_needs_backup = False
if args.restore:
for task, task_dir in task_dirs(RECORDINGS_DIR, args.task):
manifest = load_manifest(task_dir)
if manifest.get("sessions"):
restore_task(task_dir, task, manifest, args.dry_run, report)
report.print("restore")
return 0
if args.validate:
all_ok = True
for task, task_dir in task_dirs(RECORDINGS_DIR, args.task):
if os.path.isdir(os.path.join(task_dir, "meta", "episodes")):
all_ok = validate_task(task_dir, task) and all_ok
print()
print("PASS: 校验全部通过" if all_ok
else "FAIL: 存在校验失败项")
return 0 if all_ok else 1
if args.split_episodes:
for task, task_dir in task_dirs(RECORDINGS_DIR, args.task):
split_episodes(task_dir, task, args.dry_run)
print()
print("PASS: 旧分片拆分完成(幂等,可重复跑)")
return 0
for task, task_dir in task_dirs(RECORDINGS_DIR, args.task):
sessions = legacy_sessions(task_dir, task)
if not sessions:
continue
manifest = load_manifest(task_dir)
if manifest.get("sessions") and not args.force:
report.warnings.append(
f"{task}: 已有 manifest(迁移过),--force 才会重扫")
continue
done = {os.path.basename(e["backup_path"])
for e in (manifest.get("sessions") or [])
if e.get("backup_path")}
todo = [s for s in sessions
if os.path.basename(s) not in done]
if not todo:
continue
n_next = max(list_task_episodes(task_dir) or [0]) + 1
entries = manifest.get("sessions") or []
for session_dir in todo:
db_rec, db_up = db_rows_for_path(
DB_PATH, "file_path", session_dir)
if db_rec or db_up:
db_needs_backup = True
if not args.dry_run and not os.path.isfile(db_backup_path):
shutil.copy2(DB_PATH, db_backup_path)
print(f"pipeline.db 已备份: {db_backup_path}")
entry, warns = migrate_session(
task_dir, task, session_dir, n_next, backup_root, report,
args.dry_run, args.backup, DB_PATH)
if entry == "skip":
report.warnings.extend(warns)
continue
entries.append(entry)
n_next += 1
finalize_task(task_dir, task, entries, args.dry_run)
manifest = {
"version": MANIFEST_VERSION,
"run_ts": run_ts,
"db_backup": db_backup_path if db_needs_backup else None,
"sessions": entries,
}
if not args.dry_run:
save_manifest(task_dir, manifest)
report.print("dry-run" if args.dry_run else "migrate")
return 0
if __name__ == "__main__":
sys.exit(main())