File size: 57,084 Bytes
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
#!/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())