File size: 47,717 Bytes
d646dd7
 
 
 
 
 
 
95f5290
 
d646dd7
 
 
 
 
 
 
 
849e0ae
 
d646dd7
 
 
95f5290
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb59211
 
 
95f5290
 
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95f5290
 
 
 
 
 
d646dd7
 
 
 
 
 
 
 
 
 
 
95f5290
 
 
 
 
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95f5290
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d646dd7
95f5290
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
edae745
 
 
 
 
 
 
 
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95f5290
 
 
 
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
849e0ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d646dd7
849e0ae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb59211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
95f5290
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d646dd7
 
95f5290
 
 
 
 
 
 
 
 
d646dd7
 
 
 
 
 
 
 
bb59211
 
 
95f5290
d646dd7
95f5290
d646dd7
 
 
95f5290
 
 
 
 
d646dd7
95f5290
 
 
d646dd7
bb59211
 
 
 
95f5290
d646dd7
95f5290
d646dd7
95f5290
bb59211
 
d646dd7
 
 
95f5290
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95f5290
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d646dd7
 
 
 
 
 
 
 
 
 
95f5290
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95f5290
 
d646dd7
 
 
 
 
 
 
 
95f5290
d646dd7
 
 
 
 
 
 
 
95f5290
 
 
 
 
d646dd7
95f5290
 
 
 
 
 
 
d646dd7
 
 
 
95f5290
d646dd7
95f5290
 
 
 
 
 
d646dd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb59211
 
 
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
"""Export APIs — 本地文件打包(无数据库)。

导出任务存 data/state/export_tasks/<job_id>.json;打包 = 批次目录 zip。
"""

from __future__ import annotations

import hashlib
import shutil
import tempfile
import zipfile
import re
import json
from datetime import datetime, timezone
from pathlib import Path
from uuid import uuid4

from fastapi import (APIRouter, BackgroundTasks, Depends, HTTPException, Query,
                     Request)
from fastapi.responses import FileResponse
from starlette.background import BackgroundTask

from app.config import settings
from app.localstore import (
    STATE_ROOT, scan_sessions, get_episode, list_deleted_episodes, list_runs,
    get_workflow, list_projects, _read_json, _write_json, _remove_json,
)
from app.security import verify_api_key
from app.workflow_types import HAND_PROCESS_TYPES, RGBD_3D_TYPES, canonical_node_type
from app.project_dataset import (
    episode_chunk_for_index,
    episode_files,
    episode_row,
    is_project_dataset,
)

router = APIRouter(prefix="/api/v1/export", tags=["export"])

EXPORTS_DIR = STATE_ROOT / "exports"
EXPORT_TASKS_DIR = STATE_ROOT / "export_tasks"
# 可训练的数据集 —— 与 EXPORTS_DIR 分开:那边是按 run_id 命名的**处理产物**
# (每条一集),这边是**能拿去训练的成品**,训练页只列这个目录。
DATASETS_DIR = STATE_ROOT / "datasets"
# 导出页/批量导出的构建目录与 zip 放本地盘:同一份视频数据不再在
# NAS(SSHFS ~28MB/s)上读写三遍,只保留一次源文件读取。
LOCAL_EXPORTS_DIR = settings.upload_staging_root / "exports"


def _utcnow() -> str:
    return datetime.now(timezone.utc).isoformat()


def _tmp_root() -> Path:
    path = STATE_ROOT / "tmp"
    path.mkdir(parents=True, exist_ok=True)
    return path


def _safe_download_stem(value: object, fallback: str = "egodata") -> str:
    """Keep project names readable in downloaded filenames.

    The old exporter used an ASCII-only whitelist, which silently removed
    Chinese project names and produced names such as ``D435-__3D_AI.zip``.
    Preserve Unicode while still preventing path separators and control
    characters from escaping the filename boundary.
    """
    name = str(value or "").strip()
    name = re.sub(r"[\x00-\x1f\x7f]+", "_", name)
    name = name.replace("/", "_").replace("\\", "_")
    name = re.sub(r"\s+", " ", name).strip(" .")
    return name or fallback


def _zip_batches(batch_dirs: list[Path], dest: Path) -> None:
    with zipfile.ZipFile(dest, "w", allowZip64=True) as archive:
        for batch in batch_dirs:
            for path in batch.rglob("*"):
                if not path.is_file():
                    continue
                rel = path.relative_to(batch)
                if not rel.parts or rel.parts[0] not in {"data", "meta", "videos"}:
                    continue
                arcname = Path(batch.name) / rel
                compression = zipfile.ZIP_STORED if path.suffix.lower() in {".mp4", ".parquet", ".npz"} else zipfile.ZIP_DEFLATED
                archive.write(path, str(arcname), compress_type=compression)


def _zip_directory(root: Path, dest: Path) -> None:
    with zipfile.ZipFile(dest, "w", allowZip64=True) as archive:
        for path in root.rglob("*"):
            if path.is_file():
                archive.write(path, str(path.relative_to(root)),
                              compress_type=zipfile.ZIP_STORED if path.suffix.lower() in {".mp4", ".parquet"} else zipfile.ZIP_DEFLATED)


# 旧版本导出代码遗留的非标准边车文件:标注已在 parquet 列里
# (annotation/annotation_index/annotation_scope),设备信息在 info.json
# (robot_type)。打包时剥离,不随 zip 分发。
_SIDECAR_EXCLUDE = {"meta/annotations.jsonl", "meta/devices.json"}


def _zip_lerobot(root: Path, dest: Path) -> None:
    """打包 LeRobot 标准结构(data/ + meta/ + videos/),不携带处理副产品
    (hand_3d/skeleton/calibration 等)。连接驱动导出带视频 feature 的
    数据集必须带上 videos/,否则查看器/官方加载器在归档里找不到视频
    文件(File not found in archive)。"""
    with zipfile.ZipFile(dest, "w", allowZip64=True) as archive:
        for sub in ("data", "meta", "videos"):
            sub_dir = root / sub
            if not sub_dir.is_dir():
                continue
            for path in sub_dir.rglob("*"):
                if not path.is_file():
                    continue
                if str(path.relative_to(root)) in _SIDECAR_EXCLUDE:
                    continue
                archive.write(path, str(path.relative_to(root)),
                              compress_type=zipfile.ZIP_STORED if path.suffix.lower() in {".mp4", ".parquet"} else zipfile.ZIP_DEFLATED)


def _cleanup_later(path: str) -> None:
    try:
        Path(path).unlink(missing_ok=True)
    except OSError:
        pass


def _episode_dir(episode_id: str) -> Path:
    ep = get_episode(episode_id)
    if ep is None:
        raise HTTPException(status_code=404, detail=f"Episode not found: {episode_id}")
    return Path(ep["path"])


def _require_exportable_episode(episode_id: str) -> dict:
    """Keep Reviewing batches out of every download path, not only the UI."""
    ep = get_episode(episode_id)
    if ep is None:
        raise HTTPException(status_code=404, detail=f"Episode not found: {episode_id}")
    if ep.get("status") not in ("reviewed", "approved"):
        raise HTTPException(status_code=409, detail="Episode is still in Reviewing")
    return ep


def _build_zip(batches: list[Path], prefix: str, filename: str) -> FileResponse:
    tmp = tempfile.NamedTemporaryFile(prefix=prefix, suffix=".zip",
                                      dir=str(_tmp_root()), delete=False)
    tmp.close()
    try:
        _zip_batches(batches, Path(tmp.name))
    except Exception:
        _cleanup_later(tmp.name)
        raise HTTPException(status_code=500, detail="Failed to package episodes")
    return FileResponse(tmp.name, media_type="application/zip", filename=filename,
                        background=BackgroundTask(_cleanup_later, tmp.name))


def _zip_episode_selection(episode_ids: list[str], dest: Path) -> None:
    """Zip exactly the requested episodes from canonical project datasets.

    A project root can now contain hundreds of episodes.  Packaging the root
    directly would silently export every episode when the user requested one,
    and would also include unrelated episodes in a batch download.
    """
    groups: dict[Path, list[tuple[str, dict, dict]]] = {}
    for episode_id in episode_ids:
        ep = get_episode(str(episode_id))
        if ep is None:
            continue
        root = Path(ep["path"])
        if not is_project_dataset(root):
            continue
        row = episode_row(root, str(episode_id))
        if row is None:
            continue
        index = int(row.get("episode_index"))
        groups.setdefault(root, []).append(
            (str(episode_id), row, episode_files(root, index)))

    with zipfile.ZipFile(dest, "w", allowZip64=True) as archive:
        written: set[str] = set()

        def add_file(path: Path, arcname: str) -> None:
            if not path.is_file() or arcname in written:
                return
            compression = (zipfile.ZIP_STORED
                           if path.suffix.lower() in {".mp4", ".parquet", ".npz"}
                           else zipfile.ZIP_DEFLATED)
            archive.write(path, arcname, compress_type=compression)
            written.add(arcname)

        for root, entries in groups.items():
            selected_rows = [row for _episode_id, row, _files in entries]
            selected_indexes = {int(row.get("episode_index")) for row in selected_rows}
            for _episode_id, _row, files in entries:
                for path in files.get("data", []):
                    add_file(path, str(path.relative_to(root)))
                for _source, path in files.get("videos", []):
                    add_file(path, str(path.relative_to(root)))
                for path in files.get("meta", []):
                    meta_path = path if path.is_absolute() else root / path
                    rel = str(meta_path.relative_to(root))
                    if (rel not in {"meta/info.json", "meta/stats.json",
                                    "meta/tasks.json"}
                            and not rel.startswith("meta/episodes/")):
                        add_file(meta_path, rel)

            info = root / "meta" / "info.json"
            tasks = root / "meta" / "tasks.json"
            stats = root / "meta" / "stats.json"
            add_file(info, "meta/info.json")
            add_file(tasks, "meta/tasks.json")
            if selected_rows:
                import pyarrow as pa
                import pyarrow.parquet as pq
                rows_by_chunk: dict[int, list[dict]] = {}
                for row in selected_rows:
                    index = int(row.get("episode_index", 0))
                    rows_by_chunk.setdefault(episode_chunk_for_index(index), []).append(row)
                for chunk_index, chunk_rows in sorted(rows_by_chunk.items()):
                    for row in chunk_rows:
                        index = int(row.get("episode_index", 0))
                        sink = pa.BufferOutputStream()
                        pq.write_table(pa.Table.from_pylist([row]), sink)
                        archive.writestr(
                            f"meta/episodes/chunk-{chunk_index:03d}/"
                            f"episode_{index:06d}.parquet",
                            sink.getvalue().to_pybytes(),
                        )
            add_file(stats, "meta/stats.json")

        # Keep compatibility for pre-migration sessions.


def _build_episode_zip(episode_ids: list[str], prefix: str,
                       filename: str) -> FileResponse:
    tmp = tempfile.NamedTemporaryFile(prefix=prefix, suffix=".zip",
                                      dir=str(_tmp_root()), delete=False)
    tmp.close()
    try:
        _zip_episode_selection([str(value) for value in episode_ids], Path(tmp.name))
    except Exception:
        _cleanup_later(tmp.name)
        raise HTTPException(status_code=500, detail="Failed to package episodes")
    return FileResponse(tmp.name, media_type="application/zip", filename=filename,
                        background=BackgroundTask(_cleanup_later, tmp.name))


# ── 按工作流自动匹配导出格式 ─────────────────────────

def _episode_export_target(episode_id: str) -> dict | None:
    """Return the latest published export product for one episode.

    Export products live in the system-level export cache.  The project
    dataset itself remains only ``data/``, ``meta/`` and ``videos/``.
    """
    ep = get_episode(episode_id)
    if ep is None:
        return None
    runs = [r for r in list_runs() if r.get("episode_id") == episode_id]
    runs.sort(key=lambda r: r.get("created_at") or "", reverse=True)
    for run in runs:
        if run.get("status") != "completed":
            continue
        export_node = next(
            (n for n in (run.get("graph") or {}).get("nodes", [])
             if str((n.get("data") or {}).get("nodeType") or "").lower()
                in ("lerobot_export", "hdf5_export")),
            None,
        )
        if export_node is None:
            continue
        node_type = str((export_node.get("data") or {}).get("nodeType") or "").lower()
        cfg = (export_node.get("data") or {}).get("config") or {}

        # New runs publish export products to the system-level export cache
        # and store the published path in the run snapshot.  The source
        # project still contains only data/meta/videos.
        run_outputs = run.get("outputs") or {}
        published = run_outputs.get("artifacts", run_outputs)
        if isinstance(published, dict):
            for handles in published.values():
                if not isinstance(handles, dict):
                    continue
                for ref in handles.values():
                    if not isinstance(ref, dict):
                        continue
                    candidate = Path(str(ref.get("path") or ""))
                    if node_type == "hdf5_export":
                        if candidate.is_file() and candidate.suffix.lower() in {".h5", ".hdf5"}:
                            return {"kind": "hdf5", "version": None,
                                    "product": candidate, "root": candidate.parent}
                    elif (candidate / "meta" / "info.json").is_file():
                        # 版本以产物自身的 codebase_version 为准(实际
                        # 构建版本),不能信任工作流配置 —— 旧产物可能是
                        # v2.1 代码按配置 v3.0 构建前的遗留,文件名/徽标
                        # 会与实际布局不符。
                        product_version = str(cfg.get("version") or "v3.0")
                        try:
                            import json as _json
                            info = _json.loads(
                                (candidate / "meta" / "info.json")
                                .read_text(encoding="utf-8"))
                            built = str(info.get("codebase_version") or "")
                            if built.lower().startswith("v2"):
                                product_version = "v2.1"
                            elif built.lower().startswith("v3"):
                                product_version = "v3.0"
                        except (OSError, ValueError):
                            pass
                        return {"kind": "lerobot",
                                "version": product_version,
                                "product": candidate, "root": candidate}

    return None


def _workflow_export_version(episode_id: str) -> str | None:
    """Resolve the LeRobot version from the episode's bound workflow.

    Review-page exports intentionally do not have a separate format selector:
    the workflow's ``lerobot_export.version`` is the source of truth.  A
    completed run is used only as a fallback for older projects whose current
    binding no longer contains the export node.
    """
    episode = get_episode(str(episode_id))
    if episode is None:
        return None
    project = next((item for item in list_projects()
                    if item.get("name") == episode.get("project")), None)
    workflow_ids: list[str] = []
    if project:
        raw_ids = project.get("workflow_ids") or []
        if not isinstance(raw_ids, list):
            raw_ids = [raw_ids]
        if project.get("workflow_id"):
            raw_ids = [project.get("workflow_id"), *raw_ids]
        workflow_ids = [str(value) for value in raw_ids if str(value).strip()]
    for workflow_id in dict.fromkeys(workflow_ids):
        workflow = get_workflow(workflow_id)
        if not workflow:
            continue
        for node in (workflow.get("graph") or {}).get("nodes") or []:
            data = node.get("data") or {}
            if str(data.get("nodeType") or "").lower() != "lerobot_export":
                continue
            config = dict(data.get("config") or {})
            config.update((workflow.get("node_configs") or {}).get(
                node.get("id"), {}))
            version = str(config.get("version") or "v3.0").strip().lower()
            if version.startswith("v2"):
                return "v2.1"
            if version.startswith("v3"):
                return "v3.0"

    # Compatibility fallback: an older completed run still records the exact
    # export-node configuration used for that episode.
    target = _episode_export_target(str(episode_id))
    if target and target.get("kind") == "lerobot":
        version = str(target.get("version") or "").strip().lower()
        if version.startswith("v2"):
            return "v2.1"
        if version.startswith("v3"):
            return "v3.0"
    return None


@router.get("/episode-format/{episode_id}")
def episode_export_format(episode_id: str):
    """前端按钮徽标:该批次下载时实际返回的格式。

    同步端点(FastAPI 丢线程池执行)。以前写成 ``async def`` 却调
    :func:`_episode_export_target` —— 那里读历史 run 快照 + 逐个 ``is_file()``
    全是远程阻塞 I/O,在事件循环上跑会把同一次开页的其它请求一起冻住:
    实测该接口冷调用 4.4s,期间 hand-3d / slam / tactile 全部排队,这正是
    "打开一条数据时而 2 秒时而 7 秒"的来源。
    """
    if get_episode(episode_id) is None:
        raise HTTPException(status_code=404, detail="Episode not found")
    target = _episode_export_target(episode_id)
    if target is None:
        return {"episode_id": episode_id, "format": "raw", "version": None,
                "label": "Raw", "available": False}
    if target["kind"] == "hdf5":
        fmt, label = "hdf5", "HDF5"
    else:
        version = target["version"] or "v3.0"
        fmt = "lerobot_v2" if version.lower().startswith("v2") else "lerobot_v3"
        label = f"LeRobot {version}"
    return {"episode_id": episode_id, "format": fmt,
            "version": target["version"], "label": label, "available": True}


@router.get("/download-episode/{episode_id}")
async def download_single_episode(episode_id: str):
    """下载 = 按工作流导出格式返回最终数据集;无产物回退原始批次 zip。"""
    episode = _require_exportable_episode(episode_id)
    project_name = _safe_download_stem(episode.get("project"), episode_id)
    target = _episode_export_target(episode_id)
    if target is not None:
        if target["kind"] == "hdf5":
            return FileResponse(str(target["product"]),
                                media_type="application/x-hdf5",
                                filename=f"{project_name}.h5")
        tmp = tempfile.NamedTemporaryFile(prefix=f"egodata-{episode_id}-",
                                          suffix=".zip", dir=_tmp_root(),
                                          delete=False)
        tmp.close()
        _zip_lerobot(target["root"], Path(tmp.name))
        version = target["version"] or "v3.0"
        return FileResponse(
            tmp.name, media_type="application/zip",
            filename=f"{project_name}-{version}.zip",
            background=BackgroundTask(_cleanup_later, tmp.name))
    return _build_episode_zip([episode_id], f"egodata-{episode_id}-", f"{project_name}.zip")


@router.get("/download-reviewed")
async def download_reviewed_sessions():
    eps = [e for e in scan_sessions() if e.get("status") in ("reviewed", "approved")]
    deleted = {d["id"] for d in list_deleted_episodes()}
    eps = [e for e in eps if e["id"] not in deleted]
    if not eps:
        raise HTTPException(status_code=404, detail="No reviewed episodes")
    return _build_episode_zip([str(e["id"]) for e in eps], "egodata-reviewed-", "reviewed.zip")


async def _episode_ids_from_request(request: Request) -> list[str]:
    """从 JSON body 或表单字段取 episode_ids(两种入参都支持)。

    表单路径的值是逗号分隔的单字段 —— 浏览器原生 form POST 只能提交
    表单编码,而它带来的流式下载正是我们想要的。
    """
    values: list[str] = []
    content_type = request.headers.get("content-type", "")
    try:
        if "application/json" in content_type:
            body = await request.json()
            raw = (body or {}).get("episode_ids") or []
            values = raw if isinstance(raw, list) else [raw]
        else:
            form = await request.form()
            raw = form.get("episode_ids") or ""
            values = str(raw).split(",")
    except Exception:
        return []
    return [str(value).strip() for value in values if str(value).strip()]


@router.post("/batch-download")
async def batch_download_sessions(request: Request):
    """打包下载所选批次 —— 从 canon 数据集里裁出选中的集,不重建。

    产出仍是**单个** LeRobot 标准结构(data/ + meta/ + videos/),与单集
    ``download-episode`` 走的是同一个 ``_zip_episode_selection``,区别只在
    传入的 id 数量。与「导出数据集」(走 /start 的整库重建,约 30 秒/集)
    的差异不在目录结构,而在内容:这里文件原样拷贝、episode 编号保留原值
    (故可能不连续)、meta 整份拷贝自源数据集;重建则重新编号 0..N-1 并
    按选中集重算 stats。

    秒级返回,前端用两个按钮区分快慢。

    入参两种都收:JSON ``{"episode_ids": [...]}`` 供程序调用;表单字段
    ``episode_ids``(逗号分隔)供浏览器原生表单提交 —— 后者让浏览器自己
    流式下载,不必把整个 zip 缓冲进内存再由 JS 触发。

    鉴权:与 download-episode / download/{job_id} / download-reviewed 一致,
    只靠 AuthMiddleware 的登录 cookie。此前额外的 Depends(verify_api_key)
    会让浏览器 401(它拿不到 X-API-Key 头),而这三个兄弟端点都没有该依赖。
    """
    ids = await _episode_ids_from_request(request)
    if not ids:
        raise HTTPException(status_code=400, detail="No episode_ids provided")
    episode_ids: list[str] = []
    project_names: set[str] = set()
    for ep_id in ids:
        episode = _require_exportable_episode(str(ep_id))
        episode_ids.append(str(ep_id))
        project = _safe_download_stem(episode.get("project"), "")
        if project:
            project_names.add(project)
    if not episode_ids:
        raise HTTPException(status_code=404, detail="No valid episodes")
    filename = f"{next(iter(project_names))}.zip" if len(project_names) == 1 else "egodata-batch.zip"
    return _build_episode_zip(episode_ids, "egodata-batch-", filename)


# ── 导出任务(JSON,后台打包)─────────────────────────

def _persist_trainable_dataset(export_root: Path, dataset_name: str) -> Path | None:
    """把导出好的数据集落到 ``state/datasets/<名字>/``,让它能当训练输入。

    原来是打包完直接 ``rmtree``(省本地空间)—— 结果导出的成果只剩一个 zip,
    磁盘上什么都没有,训练页根本无从列起。

    **交换式替换**,不是"先删旧的再搬新的":先把新数据集搬进 ``.staging``,
    再把旧的挪到 ``.old``,然后 rename 上位,最后删 ``.old``。任何一步失败都不会
    让目标位置处于"半个数据集"的状态 —— 直接 rmtree 再 move 的话,搬到一半
    失败就两头落空。

    返回落位后的路径;``export_root`` 里没有 ``meta/info.json``(不是数据集)时
    返回 None,调用方照旧删掉临时目录。
    """
    if not (export_root / "meta" / "info.json").is_file():
        return None

    DATASETS_DIR.mkdir(parents=True, exist_ok=True)
    target = DATASETS_DIR / dataset_name
    staging = DATASETS_DIR / f".{dataset_name}.staging"
    previous = DATASETS_DIR / f".{dataset_name}.old"

    shutil.rmtree(staging, ignore_errors=True)
    shutil.move(str(export_root), str(staging))
    shutil.rmtree(previous, ignore_errors=True)
    if target.exists():
        target.rename(previous)
    staging.rename(target)          # 同盘 rename,原子
    shutil.rmtree(previous, ignore_errors=True)
    return target


def _load_task(job_id: str) -> dict | None:
    return _read_json(EXPORT_TASKS_DIR / f"{job_id}.json", None)


def _save_task(task: dict) -> None:
    _write_json(EXPORT_TASKS_DIR / f"{task['id']}.json", task)


def _list_tasks() -> list[dict]:
    if not EXPORT_TASKS_DIR.is_dir():
        return []
    return [t for t in (_read_json(f, {}) for f in sorted(EXPORT_TASKS_DIR.glob("*.json"))) if t]


# running 任务超过该时长仍未结束 → 线程大概率已异常死亡,标记 failed
# (正常 3 批次导出实测 ~3 分钟;30 分钟是宽裕兜底)
_EXPORT_RUNNING_TIMEOUT_SECONDS = 30 * 60


def _mark_stale_running_failed(task: dict) -> bool:
    """把卡死在 running 的超时任务标记为 failed(返回是否发生了修改)。"""
    if task.get("status") != "running":
        return False
    try:
        created = datetime.fromisoformat(
            str(task.get("created_at") or "").replace("Z", "+00:00"))
        elapsed = (datetime.now(timezone.utc) - created).total_seconds()
        if elapsed > _EXPORT_RUNNING_TIMEOUT_SECONDS:
            task["status"] = "failed"
            task["error"] = ("Export timed out (running for over "
                             "30 minutes without finishing)")
            task["finished_at"] = _utcnow()
            _save_task(task)
            return True
    except (ValueError, TypeError):
        pass
    return False


def _episode_set_fingerprint(episode_ids: list[str]) -> str:
    """所选批次源文件的指纹(size+mtime):数据变过才需要重新构建。"""
    digest = hashlib.sha256()
    for episode_id in episode_ids:
        ep = get_episode(str(episode_id))
        if ep is None:
            continue
        root = Path(ep["path"])
        if not is_project_dataset(root):
            continue
        try:
            row = episode_row(root, str(episode_id))
        except Exception:
            continue
        if row is None:
            continue
        index = int(row.get("episode_index"))
        files = episode_files(root, index)
        candidates = [*files.get("data", []),
                      *(path for _, path in files.get("videos", []))]
        for path in candidates:
            try:
                st = Path(path).stat()
                digest.update(
                    f"{Path(path).name}|{st.st_size}|{st.st_mtime_ns}"
                    .encode("utf-8"))
            except OSError:
                continue
    return digest.hexdigest()


def _run_export_task(job_id: str, episode_ids: list[str], export_format: str,
                     dataset_name: str, split_ratio: float) -> None:
    def _set_progress(progress: float, **extra) -> None:
        """后台任务阶段进度(前端按钮实时显示,不再静默等待)。"""
        task = _load_task(job_id)
        if not task:
            return
        task["progress"] = max(0.0, min(1.0, float(progress)))
        task.update(extra)
        _save_task(task)

    try:
        blocked = [
            str(episode_id) for episode_id in episode_ids
            if (get_episode(str(episode_id)) or {}).get("status")
            not in ("reviewed", "approved")
        ]
        if blocked:
            raise RuntimeError("Some episodes are still in Reviewing")
        # 构建与 zip 都在本地盘进行(NAS 只读一次源文件)。产物两样都留:
        # zip 供下载,数据集目录(LeRobot 分支)供训练页选用 —— 后者走
        # _persist_trainable_dataset 落到 state/datasets/,不再是"打完包就删"。
        dest = LOCAL_EXPORTS_DIR / f"{job_id}.zip"
        dest.parent.mkdir(parents=True, exist_ok=True)
        export_root = LOCAL_EXPORTS_DIR / f"{dataset_name}-{job_id[:8]}"
        if export_format in {"lerobot_v2", "lerobot_v3"}:
            from app.lerobot_export import build_lerobot_dataset
            version = "v2.1" if export_format == "lerobot_v2" else "v3.0"

            def _on_episode(done: int, total_episodes: int) -> None:
                _set_progress(0.05 + 0.65 * (done / max(1, total_episodes)))

            _set_progress(0.05)
            build_lerobot_dataset(dataset_name, episode_ids, export_root,
                                  split_ratio, version=version,
                                  progress_callback=_on_episode)
            _set_progress(0.75)
            _zip_directory(export_root, dest)
            # 打包后**留下**数据集,不要删 —— 训练页的数据集列表就是从这里读的
            # (state/datasets/)。只有它压根不是数据集时才清掉临时目录。
            if _persist_trainable_dataset(export_root, dataset_name) is None:
                shutil.rmtree(export_root, ignore_errors=True)
            _set_progress(0.95)
        else:
            _set_progress(0.2)
            _zip_episode_selection([str(e) for e in episode_ids], dest)
            _set_progress(0.95)
            # 非 LeRobot 分支产出的只是一个选集的 zip,没有数据集目录可留。
            shutil.rmtree(export_root, ignore_errors=True)
        task = _load_task(job_id)
        if task:
            task["status"] = "completed"
            task["progress"] = 1.0
            task["output_dir"] = str(dest)
            task["finished_at"] = _utcnow()
            _save_task(task)
    except Exception as e:
        task = _load_task(job_id)
        if task:
            task["status"] = "failed"
            task["error"] = str(e)
            task["finished_at"] = _utcnow()
            _save_task(task)


@router.post("/start", status_code=202)
async def export_start(body: dict, background_tasks: BackgroundTasks,
                       _: str = Depends(verify_api_key)):
    requested_ids = body.get("episode_ids")
    if requested_ids:
        if not isinstance(requested_ids, list):
            raise HTTPException(status_code=400, detail="episode_ids must be a list")
        episode_ids = [str(episode_id) for episode_id in requested_ids]
        blocked = [
            episode_id for episode_id in episode_ids
            if (get_episode(episode_id) or {}).get("status")
            not in ("reviewed", "approved")
        ]
        if blocked:
            raise HTTPException(status_code=409,
                                detail="Some episodes are still in Reviewing")
    else:
        episode_ids = [
            e["id"] for e in scan_sessions()
            if e.get("status") in ("reviewed", "approved")
        ]
    if not episode_ids:
        raise HTTPException(status_code=404, detail="No reviewed episodes")
    requested_name = str(body.get("dataset_name") or "").strip()
    selected_projects = {
        _safe_download_stem((get_episode(episode_id) or {}).get("project"), "")
        for episode_id in episode_ids
    }
    selected_projects.discard("")
    # The review page does not choose a separate dataset name.  A single
    # project batch should download with that project's full Unicode name;
    # mixed-project selections keep a neutral batch name.
    if not requested_name or requested_name in {"egodata-batch", "egodata-export", "dataset"}:
        requested_name = (next(iter(selected_projects))
                          if len(selected_projects) == 1 else "egodata-batch")
    dataset_name = _safe_download_stem(requested_name, "egodata-export")
    requested_format = str(body.get("export_format") or "").strip().lower()
    if requested_format:
        export_format = requested_format
    else:
        # Review-page exports have no independent format selector. Resolve
        # each selected episode from its project's workflow export node.
        workflow_versions = {
            _workflow_export_version(episode_id)
            for episode_id in episode_ids
        }
        workflow_versions.discard(None)
        if len(workflow_versions) > 1:
            raise HTTPException(
                status_code=409,
                detail=("Selected episodes use different workflow export "
                        "versions; select episodes from one workflow/project."),
            )
        resolved_version = next(iter(workflow_versions), "v3.0")
        export_format = ("lerobot_v2" if resolved_version.startswith("v2")
                         else "lerobot_v3")
    if export_format not in {"lerobot_v2", "lerobot_v3"}:
        raise HTTPException(status_code=400,
                            detail="Unsupported export format")
    # 防重复提交:同一批次集合已有 running 任务时直接拒绝,避免并行
    # 任务互相抢 NAS 带宽(此前实测两次点击各跑一遍,时间翻倍)。
    requested = set(episode_ids)
    for existing in _list_tasks():
        if existing.get("status") != "running":
            continue
        if set(existing.get("episode_ids") or []) == requested:
            raise HTTPException(
                status_code=409,
                detail="An export for the same episodes is already running",
            )
    # 复用已完成结果:同批次(顺序一致)+ 同格式 + 源文件未变 → 直接
    # 返回既有任务,前端轮询立即 completed 并触发下载(秒级)。
    fingerprint = _episode_set_fingerprint(episode_ids)
    for existing in _list_tasks():
        if existing.get("status") != "completed":
            continue
        if (existing.get("episode_ids") or []) != episode_ids:
            continue
        if existing.get("export_format") != export_format:
            continue
        if existing.get("source_fingerprint") != fingerprint:
            continue
        output = Path(str(existing.get("output_dir") or ""))
        if output.is_file():
            return existing
    job = {
        "id": str(uuid4()),
        "dataset_name": dataset_name,
        "status": "running",
        "episode_ids": episode_ids,
        "split_ratio": body.get("split_ratio", 0.9),
        "progress": 0.0,
        "export_format": export_format,
        "error": None,
        "output_dir": None,
        "source_fingerprint": fingerprint,
        "created_at": _utcnow(),
        "finished_at": None,
    }
    _save_task(job)
    background_tasks.add_task(
        _run_export_task,
        job["id"],
        episode_ids,
        job["export_format"],
        job["dataset_name"],
        float(job["split_ratio"] or 0.9),
    )
    return job


@router.get("/list")
async def export_list(limit: int = Query(20, ge=1, le=100), offset: int = 0):
    tasks = sorted(_list_tasks(), key=lambda t: t.get("created_at") or "", reverse=True)
    for task in tasks:
        _mark_stale_running_failed(task)
    return tasks[offset:offset + limit]


@router.get("/{job_id}")
async def export_status(job_id: str):
    task = _load_task(job_id)
    if task is None:
        raise HTTPException(status_code=404, detail="Export job not found")
    _mark_stale_running_failed(task)
    return task


@router.get("/download/{job_id}")
async def download_export_job(job_id: str):
    task = _load_task(job_id)
    if task is None:
        raise HTTPException(status_code=404, detail="Export job not found")
    # 新任务 zip 在本地盘(output_dir);历史任务回退 NAS 路径。
    dest = Path(str(task.get("output_dir") or ""))
    if not dest.is_file():
        dest = EXPORTS_DIR / f"{job_id}.zip"
    if not dest.is_file():
        raise HTTPException(status_code=404, detail="Export output not ready")
    # 文件名带格式版本后缀,下载后一眼可辨 v2.1 / v3.0
    export_format = str(task.get("export_format") or "")
    suffix = ("-v2.1" if export_format == "lerobot_v2"
              else "-v3.0" if export_format == "lerobot_v3" else "")
    return FileResponse(
        dest, media_type="application/zip",
        filename=f"{task.get('dataset_name', 'dataset')}{suffix}.zip")


@router.delete("/{job_id}")
async def delete_export_job(job_id: str, _: str = Depends(verify_api_key)):
    task = _load_task(job_id)
    _remove_json(EXPORT_TASKS_DIR / f"{job_id}.json")
    for path in (Path(str((task or {}).get("output_dir") or "")),
                 EXPORTS_DIR / f"{job_id}.zip"):
        try:
            path.unlink(missing_ok=True)
        except Exception:
            pass
    return {"message": "Export job deleted"}


# ── Re-export:只重建导出,不重跑检测 ─────────────────────────
# 工作流改导出节点(配置/连线)后,本接口按"最新 completed run 的 canonical
# episode 数据 + 当前工作流的导出配置/连线"重建系统级导出缓存,并回写 run 快照
# 的导出节点配置
# (下载徽标/格式检测按快照驱动,回写后自动显示新格式)。

import asyncio

from app.localstore import save_run

_RE_TASKS: dict[str, dict] = {}
_RE_TASKS_DIR = STATE_ROOT / "re_export_tasks"

_PASSTHROUGH_TYPES = {
    # data_quality 由 ai_quality_review + data_cleaning 合并而来;此处的 t 已过
    # canonical_node_type(),旧 slug 不会出现。
    "annotation", "human_review", "ai_annotation", "data_quality",
}
_CAMERA_TYPES = {"rgb_camera", "fisheye_camera", "rgbd_camera", "stereo_camera",
                 "stereo_rgbd_camera", "mono_camera"}


def _re_set_task(task_id: str, **kw) -> None:
    t = _RE_TASKS.get(task_id)
    if t is None:
        return
    t.update(kw)
    try:
        ep = t.get("episode_id")
        if ep:
            _RE_TASKS_DIR.mkdir(parents=True, exist_ok=True)
            _write_json(_RE_TASKS_DIR / f"{ep}.json", t)
    except OSError:
        pass


def purge_episode_re_export_tasks(episode_id: str) -> None:
    """Cancel re-export jobs for a permanently deleted episode.

    Pop first so a still-running job's final _re_set_task call no-ops and
    cannot recreate the on-disk mirror; then remove the mirror file.
    """
    episode_id = str(episode_id)
    for task_id, task in list(_RE_TASKS.items()):
        if str(task.get("episode_id")) == episode_id:
            _RE_TASKS.pop(task_id, None)
    try:
        (_RE_TASKS_DIR / f"{episode_id}.json").unlink(missing_ok=True)
    except OSError:
        pass


def _export_spec_from_graph(graph: dict, node_configs: dict) -> dict | None:
    """从工作流图静态推导导出节点的输入范围(与 DAG 执行同语义):
    - video_keys:相机输入节点沿纯透传链(annotation/human_review/
      ai_annotation)连到导出节点 → 其 source_keys。处理节点
      (mediapipe 等)不透传视频,链式场景视频不进数据集(需求一致)
    - kinds:mediapipe_hand + RGB-only processors → hand_keypoints;
      RGB-D processors → hand_3d (+hand_3d#right for bare-hand multi-view);
      透传节点继续回溯
    图里没有 lerobot_export 节点返回 None。
    """
    nodes = {n.get("id"): n for n in graph.get("nodes") or []}
    edges = graph.get("edges") or []
    export_id = next((nid for nid, n in nodes.items()
                      if (n.get("data") or {}).get("nodeType") == "lerobot_export"),
                     None)
    if export_id is None:
        return None
    video_keys: list[str] = []
    kinds: set[str] = set()
    stack = [e.get("source") for e in edges if e.get("target") == export_id]
    visited: set[str] = set()
    while stack:
        nid = stack.pop()
        if not nid or nid in visited:
            continue
        visited.add(nid)
        node = nodes.get(nid)
        if node is None:
            continue
        t = canonical_node_type((node.get("data") or {}).get("nodeType") or "")
        cfg = dict((node.get("data") or {}).get("config") or {})
        cfg.update((node_configs or {}).get(nid, {}))
        if t in _CAMERA_TYPES:
            for k in (cfg.get("source_keys") or cfg.get("source_key") or "").split(","):
                k = k.strip()
                if k and k not in video_keys:
                    video_keys.append(k)
        elif t == "mediapipe_hand" or t in HAND_PROCESS_TYPES - RGBD_3D_TYPES:
            kinds.add("hand_keypoints")
        if t in RGBD_3D_TYPES:
            kinds.add("hand_3d")
            if t == "rgbd_to_3d_bare_hand":
                kinds.add("hand_3d#right")
        elif t in _PASSTHROUGH_TYPES:
            stack.extend(e.get("source") for e in edges
                         if e.get("target") == nid)
        # 其他节点类型不贡献导出输入,不回溯
    return {"video_keys": video_keys, "kinds": kinds}


async def _run_re_export(task_id: str, episode_id: str) -> None:
    from app.lerobot_export import build_lerobot_dataset
    try:
        _re_set_task(task_id, status="loading", detail="定位 run 与工作流")
        await asyncio.sleep(0)
        ep = get_episode(episode_id)
        if ep is None:
            raise RuntimeError("Episode not found")
        runs = [r for r in list_runs()
                if r.get("episode_id") == episode_id
                and r.get("status") == "completed" and r.get("created_at")]
        runs.sort(key=lambda r: r.get("created_at") or "", reverse=True)
        run = runs[0] if runs else None
        if run is None:
            raise RuntimeError("无 completed run 记录(先跑一次工作流再重导出)")
        project_root = Path(ep["path"])
        source_row = episode_row(project_root, episode_id)
        if source_row is None:
            raise RuntimeError("canonical episode metadata 不存在")
        source_index = int(source_row.get("episode_index", 0))
        source_data = (project_root / "data"
                       / f"chunk-{episode_chunk_for_index(source_index):03d}"
                       / f"episode_{source_index:06d}.parquet")
        if not source_data.is_file():
            raise RuntimeError("canonical episode data parquet 不存在")
        # 当前工作流:优先项目**当前绑定**的工作流(用户改的连线/配置
        # 就生效在那里);run 的来源工作流仅作兜底(run 可能出自已被
        # 解绑的旧工作流,若优先它会拿到过期的连线/源键)。
        wf = None
        project = next((p for p in list_projects()
                        if p.get("name") == ep.get("project")), None)
        for wf_id in (project.get("workflow_ids") or []) if project else []:
            w = get_workflow(wf_id)
            if w is not None and _export_spec_from_graph(
                    w.get("graph") or {}, w.get("node_configs") or {}) is not None:
                wf = w
                break
        if wf is None:
            old_wf = get_workflow(run.get("workflow_id") or "")
            if old_wf is not None and _export_spec_from_graph(
                    old_wf.get("graph") or {},
                    old_wf.get("node_configs") or {}) is not None:
                wf = old_wf
        if wf is None:
            raise RuntimeError("项目绑定工作流里没有 LeRobot 导出节点")
        spec = _export_spec_from_graph(wf.get("graph") or {},
                                       wf.get("node_configs") or {})
        if spec is None:
            raise RuntimeError("当前工作流没有 LeRobot 导出节点")
        export_node = next(
            n for n in (wf.get("graph") or {}).get("nodes", [])
            if (n.get("data") or {}).get("nodeType") == "lerobot_export")
        cfg = dict((export_node.get("data") or {}).get("config") or {})
        cfg.update((wf.get("node_configs") or {}).get(export_node.get("id"), {}))
        version = str(cfg.get("version") or "v3.0")
        split_ratio = float(cfg.get("split_ratio", 0.9))
        # 检测结果已经合并进 canonical episode parquet,重导出不重跑检测。
        kinds = spec["kinds"]
        hand_keypoints_paths = ([str(source_data)]
                                if "hand_keypoints" in kinds else None)
        hand_3d_paths = ([str(source_data)] if "hand_3d" in kinds else None)
        hand_3d_right_paths = ([str(source_data)]
                               if "hand_3d#right" in kinds else None)
        include_video_keys = spec["video_keys"] or None  # None=全量(旧语义),[]=无视频
        _re_set_task(task_id, status="building",
                     detail=f"重建 LeRobot {version} 数据集中(复用检测产物,不重跑)")
        await asyncio.sleep(0)
        # 导出副本位于系统级缓存,不污染项目根目录。
        import shutil
        run_root = EXPORTS_DIR / str(run["id"]) / "dataset"
        shutil.rmtree(run_root, ignore_errors=True)
        run_root.parent.mkdir(parents=True, exist_ok=True)
        await asyncio.to_thread(
            build_lerobot_dataset, episode_id, [episode_id], run_root,
            split_ratio, include_video_keys,
            hand_keypoints_paths, hand_3d_paths, hand_3d_right_paths,
            version, None)
        # 回写 run 快照的导出节点配置 → 下载徽标/格式检测显示新配置。
        # 旧 run 快照可能没有导出节点(早期工作流无导出)→ 从当前工作流
        # 把导出节点(带新配置)补进快照图,保证 _episode_export_target
        # 能读到格式。
        patched = False
        run_graph = run.setdefault("graph", {})
        for n in (run_graph.get("nodes") or []):
            if (n.get("data") or {}).get("nodeType") == "lerobot_export":
                n.setdefault("data", {})["config"] = dict(
                    n.get("data", {}).get("config") or {})
                n["data"]["config"].update({
                    "version": version, "split_ratio": split_ratio})
                if (run.get("node_configs") or {}).get(n.get("id")) is not None:
                    run["node_configs"][n.get("id")].update({
                        "version": version, "split_ratio": split_ratio})
                patched = True
                break
        if not patched:
            node_copy = dict(export_node)
            node_copy["id"] = f"re_export_{node_copy.get('id')}"
            node_copy["data"] = dict(node_copy.get("data") or {})
            node_copy["data"]["config"] = dict(cfg)
            run_graph.setdefault("nodes", [])
            run_graph["nodes"].append(node_copy)
            export_output_node_id = node_copy["id"]
        else:
            export_output_node_id = export_node.get("id")
        run_outputs = run.setdefault("outputs", {})
        artifact_map = run_outputs.setdefault("artifacts", {})
        artifact_map.setdefault(export_output_node_id, {})["dataset"] = {
            "kind": "dataset",
            "path": str(run_root),
            "source_key": None,
            "schema_version": "1.0",
            "metadata": {"root": ".", "version": version},
        }
        save_run(run)
        _re_set_task(task_id, status="done",
                     detail=f"重建完成:LeRobot {version}"
                            + (f",视频:{len(spec['video_keys'])} 路(按连线)"
                               if spec["video_keys"]
                               else ",视频:自动识别全量")
                            + (f",骨骼列 {sorted(kinds)}" if kinds else ""))
    except Exception as exc:
        _re_set_task(task_id, status="failed", detail=str(exc))


@router.post("/re-export/{episode_id}")
async def re_export_episode(episode_id: str):
    """只重建导出:复用最新 completed run 的检测产物 + 当前工作流的
    导出节点配置/连线,原地重建 LeRobot 数据集并回写 run 快照。
    不重跑 mediapipe 等检测节点。"""
    if get_episode(episode_id) is None:
        raise HTTPException(status_code=404, detail="Episode not found")
    task_id = str(uuid4())
    _RE_TASKS[task_id] = {"task_id": task_id, "episode_id": episode_id,
                          "status": "queued", "detail": ""}
    asyncio.get_running_loop().create_task(_run_re_export(task_id, episode_id))
    return {"task_id": task_id, "status": "queued"}


@router.get("/re-export/{episode_id}/status")
async def re_export_status(episode_id: str):
    for t in reversed(list(_RE_TASKS.values())):
        if t.get("episode_id") == episode_id:
            return t
    disk = _read_json(_RE_TASKS_DIR / f"{episode_id}.json", None)
    if disk is None:
        return {"episode_id": episode_id, "status": "idle"}
    if disk.get("status") in ("queued", "loading", "building"):
        disk["status"] = "interrupted"
        disk["detail"] = str(disk.get("detail") or "") + "(服务重启,任务中断,请重新触发)"
    return disk