Add Human demo videos (robot → human, fal MiniMax-H3 480P) as a Video generation option

#1
by pepijn223 HF Staff - opened
README.md CHANGED
@@ -105,6 +105,40 @@ combined. Camera names load automatically from the dataset's `meta/info.json`
105
  before estimating, including private datasets after login. Depth and non-video
106
  features are excluded. Visual questions have a configurable cadence.
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  ## Prompts and examples
109
 
110
  Every annotation card has an **Edit prompt** editor with the actual model
 
105
  before estimating, including private datasets after login. Depth and non-video
106
  features are excluded. Visual questions have a configurable cadence.
107
 
108
+ ## Human demo videos
109
+
110
+ A separate **Video generation** category on the Annotations step, next to the text
111
+ annotations. For every subtask (or up to 1–8 per episode), the job turns the robot view into
112
+ a first-person human demonstration, following LeRobot's `--human_video` annotation module:
113
+
114
+ 1. the selected vision model reads the robot segment and writes the human task and an
115
+ image-edit prompt;
116
+ 2. `Qwen/Qwen-Image-Edit-2509` removes the robot from the segment's first full-resolution
117
+ frame and adds two resting human hands;
118
+ 3. the vision model writes a step-by-step video prompt from the edited frame;
119
+ 4. `MiniMaxAI/MiniMax-H3` animates it at 480P (5 s).
120
+
121
+ Both media models run on fal through HF Inference Providers with the signed-in user's
122
+ token. Selecting videos also selects Subtasks, since there is one video per subtask.
123
+
124
+ The estimate follows the selected episodes' lengths: it expects about one subtask per 5 s of
125
+ each episode (at least one per episode), allows twice that in the upper bound, and applies the
126
+ per-episode cap. **Parallel generations** (1–32, default 8) limits simultaneous media
127
+ requests across the whole job. The review warns when the expected videos at that parallelism
128
+ would outlast the job timeout (about 3 min per video).
129
+
130
+ Videos start only after an episode's
131
+ annotations pass validation, and each request reserves its quoted price
132
+ (`model_catalog.HUMAN_VIDEO`) from the spending target first. A failed video is recorded
133
+ and the others continue. Reaching the target or the runtime limit stops further videos but
134
+ keeps the episode's annotations. The launch checks that both fal mappings are live.
135
+
136
+ The output holds `annotation_studio/human_videos/episode_XXXXXX/segment_XXX{.mp4,
137
+ _first_frame.png,_robot_frame.png}` (named by source episode) and `index.json`, which lists
138
+ each segment's subtask, timestamps, prompts, status and materialized `episode_index`.
139
+ These are synthetic media, not language columns. The Jobs page previews completed videos
140
+ next to the robot frame, served through the Space so private outputs stay private.
141
+
142
  ## Prompts and examples
143
 
144
  Every annotation card has an **Edit prompt** editor with the actual model
frontend/src/JobsView.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from "react";
2
  import {
3
  ArrowUpRight,
4
  ChevronDown,
@@ -30,6 +30,8 @@ export type Progress = {
30
  input_tokens?: number;
31
  output_tokens?: number;
32
  rate_limited_requests?: number;
 
 
33
  };
34
  cost?: {
35
  inference_usd: number | null;
@@ -42,7 +44,81 @@ export type Progress = {
42
  };
43
  annotation_url?: string | null;
44
  output_mode?: string;
 
45
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  const terminal = new Set(["COMPLETED", "ERROR", "CANCELED", "DELETED"]);
47
  const usd = (value: number | null | undefined) =>
48
  value == null
@@ -252,7 +328,24 @@ export default function JobsView({
252
  {p?.usage.successful_calls?.toLocaleString() ?? "—"}
253
  </dd>
254
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  </dl>
 
 
 
256
  {p &&
257
  (p.failed > 0 ||
258
  (p.stopped || 0) > 0 ||
 
1
+ import { useState, type ReactNode } from "react";
2
  import {
3
  ArrowUpRight,
4
  ChevronDown,
 
30
  input_tokens?: number;
31
  output_tokens?: number;
32
  rate_limited_requests?: number;
33
+ media_usd?: number;
34
+ completed_media_requests?: number;
35
  };
36
  cost?: {
37
  inference_usd: number | null;
 
44
  };
45
  annotation_url?: string | null;
46
  output_mode?: string;
47
+ human_videos?: Record<string, number> | null;
48
  };
49
+ type HumanVideo = {
50
+ episode_index?: number | null;
51
+ source_episode_index: number;
52
+ segment_index?: number;
53
+ subtask?: string;
54
+ caption?: string | null;
55
+ status: string;
56
+ robot_frame?: string;
57
+ first_frame?: string;
58
+ video?: string;
59
+ };
60
+
61
+ function HumanVideos({ jobId }: { jobId: string }) {
62
+ const [videos, setVideos] = useState<HumanVideo[] | null>(null);
63
+ const [error, setError] = useState("");
64
+ const [loading, setLoading] = useState(false);
65
+ async function load() {
66
+ setLoading(true);
67
+ setError("");
68
+ try {
69
+ const response = await fetch(`/api/jobs/${jobId}/human-videos`, {
70
+ credentials: "same-origin",
71
+ });
72
+ if (!response.ok) throw new Error("No human demo videos saved yet.");
73
+ setVideos(await response.json());
74
+ } catch (e) {
75
+ setError((e as Error).message);
76
+ } finally {
77
+ setLoading(false);
78
+ }
79
+ }
80
+ if (!videos)
81
+ return (
82
+ <div className="human-videos">
83
+ <button className="text-action" onClick={load} disabled={loading}>
84
+ {loading ? "Loading videos…" : "Preview human demo videos"}
85
+ </button>
86
+ {error && <p className="job-cost-note">{error}</p>}
87
+ </div>
88
+ );
89
+ const ready = videos.filter((v) => v.status === "completed" && v.video);
90
+ return (
91
+ <div className="human-videos">
92
+ {ready.length === 0 && <p>No completed videos yet.</p>}
93
+ <div className="human-video-grid">
94
+ {ready.map((v) => (
95
+ <figure key={`${v.source_episode_index}-${v.segment_index}`}>
96
+ <div className="human-video-pair">
97
+ {v.robot_frame && (
98
+ <img src={v.robot_frame} alt="Robot view" loading="lazy" />
99
+ )}
100
+ <video
101
+ src={v.video}
102
+ poster={v.first_frame}
103
+ controls
104
+ muted
105
+ loop
106
+ playsInline
107
+ preload="none"
108
+ />
109
+ </div>
110
+ <figcaption>
111
+ <strong>
112
+ Episode {v.episode_index ?? v.source_episode_index}
113
+ </strong>{" "}
114
+ · {v.subtask}
115
+ </figcaption>
116
+ </figure>
117
+ ))}
118
+ </div>
119
+ </div>
120
+ );
121
+ }
122
  const terminal = new Set(["COMPLETED", "ERROR", "CANCELED", "DELETED"]);
123
  const usd = (value: number | null | undefined) =>
124
  value == null
 
328
  {p?.usage.successful_calls?.toLocaleString() ?? "—"}
329
  </dd>
330
  </div>
331
+ {p?.human_videos && (
332
+ <div>
333
+ <dt>Human videos</dt>
334
+ <dd>
335
+ {(p.human_videos.completed || 0).toLocaleString()}
336
+ {p.human_videos.failed
337
+ ? ` · ${p.human_videos.failed} failed`
338
+ : ""}
339
+ {p.usage.media_usd
340
+ ? ` · ${usd(p.usage.media_usd)}`
341
+ : ""}
342
+ </dd>
343
+ </div>
344
+ )}
345
  </dl>
346
+ {!!p?.human_videos?.completed && (
347
+ <HumanVideos jobId={job.id} />
348
+ )}
349
  {p &&
350
  (p.failed > 0 ||
351
  (p.stopped || 0) > 0 ||
frontend/src/jobs.css CHANGED
@@ -312,3 +312,39 @@
312
  flex-direction: row;
313
  align-items: center;
314
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  flex-direction: row;
313
  align-items: center;
314
  }
315
+
316
+ .human-videos {
317
+ margin-top: 14px;
318
+ font-size: 12px;
319
+ }
320
+ .human-video-grid {
321
+ display: grid;
322
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
323
+ gap: 14px;
324
+ }
325
+ .human-video-pair {
326
+ display: grid;
327
+ grid-template-columns: 1fr 1fr;
328
+ gap: 6px;
329
+ }
330
+ .human-video-pair img,
331
+ .human-video-pair video {
332
+ width: 100%;
333
+ aspect-ratio: 4 / 3;
334
+ object-fit: cover;
335
+ border-radius: 6px;
336
+ background: var(--line-subtle);
337
+ }
338
+ .human-videos figure {
339
+ margin: 0;
340
+ }
341
+ .human-videos figcaption {
342
+ margin-top: 6px;
343
+ color: var(--fg-muted);
344
+ line-height: 1.5;
345
+ }
346
+ @media (max-width: 480px) {
347
+ .human-video-grid {
348
+ grid-template-columns: 1fr;
349
+ }
350
+ }
frontend/src/main.tsx CHANGED
@@ -45,7 +45,9 @@ type Feature =
45
  | "Memory"
46
  | "Task rephrasings"
47
  | "Interjections & speech"
48
- | "Visual questions";
 
 
49
  type Options = {
50
  model: string;
51
  dataset: string;
@@ -57,6 +59,8 @@ type Options = {
57
  hours: number;
58
  mode: string;
59
  prompts: Record<string, string>;
 
 
60
  };
61
  type PromptTemplate = {
62
  key: string;
@@ -84,10 +88,26 @@ type ModelProfile = {
84
  pricing_url: string;
85
  pricing_note?: string;
86
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  type ModelCatalog = {
88
  models: ModelProfile[];
89
  default: string;
90
  unavailable: { name: string; reason: string }[];
 
91
  };
92
  type Quote = {
93
  model_profile: ModelProfile;
@@ -96,6 +116,13 @@ type Quote = {
96
  low: number;
97
  high: number;
98
  inference: number;
 
 
 
 
 
 
 
99
  cpu_max: number;
100
  custom_prompts: number;
101
  calls: number;
@@ -133,6 +160,8 @@ const defaults: Options = {
133
  hours: 4,
134
  mode: COPY,
135
  prompts: {},
 
 
136
  };
137
  const definitions: {
138
  name: Feature;
@@ -519,11 +548,16 @@ function App() {
519
  setNotice("");
520
  }
521
  function toggle(name: Feature) {
522
- change({
523
- features: options.features.includes(name)
524
- ? options.features.filter((x) => x !== name)
525
- : [...options.features, name],
526
- });
 
 
 
 
 
527
  }
528
  async function estimate(advance = false) {
529
  setLoading(true);
@@ -936,6 +970,12 @@ function App() {
936
  className="card step-card"
937
  aria-label="Annotation settings"
938
  >
 
 
 
 
 
 
939
  <div className="annotation-choices">
940
  {definitions.map(({ name, title, description }) => (
941
  <div
@@ -1005,6 +1045,111 @@ function App() {
1005
  </details>
1006
  </section>
1007
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1008
  {step === 2 && (
1009
  <section
1010
  className="card step-card model-picker"
@@ -1263,6 +1408,19 @@ function App() {
1263
  <span>–</span>
1264
  {money(quote.costs.high)}
1265
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
1266
  <details className="progressive-details cost-breakdown">
1267
  <summary>
1268
  Cost breakdown <ChevronDown size={15} />
@@ -1276,6 +1434,24 @@ function App() {
1276
  : "—"}
1277
  </strong>
1278
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1279
  <div>
1280
  <span>
1281
  CPU job reservation{" "}
 
45
  | "Memory"
46
  | "Task rephrasings"
47
  | "Interjections & speech"
48
+ | "Visual questions"
49
+ | "Human demo videos";
50
+ const HUMAN_VIDEOS: Feature = "Human demo videos";
51
  type Options = {
52
  model: string;
53
  dataset: string;
 
59
  hours: number;
60
  mode: string;
61
  prompts: Record<string, string>;
62
+ human_video_segments: number;
63
+ human_video_parallel: number;
64
  };
65
  type PromptTemplate = {
66
  key: string;
 
88
  pricing_url: string;
89
  pricing_note?: string;
90
  };
91
+ type HumanVideoProfile = {
92
+ provider_name: string;
93
+ edit_model: string;
94
+ video_model: string;
95
+ resolution: string;
96
+ duration_s: number;
97
+ edit_usd: number;
98
+ video_usd: number;
99
+ checked_at: string;
100
+ pricing_url: string;
101
+ segment_choices: number[];
102
+ parallel_choices: number[];
103
+ seconds_per_subtask: number;
104
+ minutes_per_video: number;
105
+ };
106
  type ModelCatalog = {
107
  models: ModelProfile[];
108
  default: string;
109
  unavailable: { name: string; reason: string }[];
110
+ human_video?: HumanVideoProfile;
111
  };
112
  type Quote = {
113
  model_profile: ModelProfile;
 
116
  low: number;
117
  high: number;
118
  inference: number;
119
+ media?: number;
120
+ media_max?: number;
121
+ videos?: number;
122
+ videos_max?: number;
123
+ video_minutes?: number;
124
+ video_parallel?: number;
125
+ segment_usd?: number;
126
  cpu_max: number;
127
  custom_prompts: number;
128
  calls: number;
 
160
  hours: 4,
161
  mode: COPY,
162
  prompts: {},
163
+ human_video_segments: 0,
164
+ human_video_parallel: 8,
165
  };
166
  const definitions: {
167
  name: Feature;
 
548
  setNotice("");
549
  }
550
  function toggle(name: Feature) {
551
+ const on = !options.features.includes(name);
552
+ let features = on
553
+ ? [...options.features, name]
554
+ : options.features.filter((x) => x !== name);
555
+ // Videos are generated per subtask, so they need the subtask annotations.
556
+ if (on && name === HUMAN_VIDEOS && !features.includes("Subtasks"))
557
+ features = ["Subtasks", ...features];
558
+ if (!on && name === "Subtasks")
559
+ features = features.filter((x) => x !== HUMAN_VIDEOS);
560
+ change({ features });
561
  }
562
  async function estimate(advance = false) {
563
  setLoading(true);
 
970
  className="card step-card"
971
  aria-label="Annotation settings"
972
  >
973
+ <div className="category-heading">
974
+ <h3>Text annotations</h3>
975
+ <p className="field-help">
976
+ Language written into the dataset's Parquet files.
977
+ </p>
978
+ </div>
979
  <div className="annotation-choices">
980
  {definitions.map(({ name, title, description }) => (
981
  <div
 
1045
  </details>
1046
  </section>
1047
  )}
1048
+ {step === 1 && (
1049
+ <section
1050
+ className={`card step-card video-category ${options.features.includes(HUMAN_VIDEOS) ? "selected" : ""}`}
1051
+ aria-label="Video generation settings"
1052
+ >
1053
+ <div className="category-heading">
1054
+ <h3>
1055
+ <Video size={16} /> Video generation
1056
+ </h3>
1057
+ <p className="field-help">
1058
+ New media generated from your robot videos, saved next to
1059
+ the dataset.
1060
+ </p>
1061
+ </div>
1062
+ <div className="annotation-choice">
1063
+ <label>
1064
+ <input
1065
+ type="checkbox"
1066
+ checked={options.features.includes(HUMAN_VIDEOS)}
1067
+ onChange={() => toggle(HUMAN_VIDEOS)}
1068
+ />
1069
+ <span>
1070
+ <strong>Human demo videos</strong>
1071
+ <small>
1072
+ A first-person video of human hands doing each
1073
+ subtask: the robot is edited out of the first frame,
1074
+ then the scene is animated.
1075
+ </small>
1076
+ </span>
1077
+ </label>
1078
+ </div>
1079
+ {options.features.includes(HUMAN_VIDEOS) && (
1080
+ <div className="fields-row video-settings">
1081
+ <label>
1082
+ Videos per episode
1083
+ <select
1084
+ value={options.human_video_segments}
1085
+ onChange={(e) =>
1086
+ change({
1087
+ human_video_segments: Number(e.target.value),
1088
+ })
1089
+ }
1090
+ >
1091
+ {(
1092
+ modelCatalog?.human_video?.segment_choices ?? [
1093
+ 0, 1, 2, 3, 5, 8,
1094
+ ]
1095
+ ).map((n) => (
1096
+ <option key={n} value={n}>
1097
+ {n === 0 ? "Every subtask" : `Up to ${n}`}
1098
+ </option>
1099
+ ))}
1100
+ </select>
1101
+ </label>
1102
+ <label>
1103
+ Parallel generations
1104
+ <select
1105
+ value={options.human_video_parallel}
1106
+ onChange={(e) =>
1107
+ change({
1108
+ human_video_parallel: Number(e.target.value),
1109
+ })
1110
+ }
1111
+ >
1112
+ {(
1113
+ modelCatalog?.human_video?.parallel_choices ?? [
1114
+ 1, 4, 8, 16, 32,
1115
+ ]
1116
+ ).map((n) => (
1117
+ <option key={n} value={n}>
1118
+ Up to {n} at a time
1119
+ </option>
1120
+ ))}
1121
+ </select>
1122
+ </label>
1123
+ </div>
1124
+ )}
1125
+ {modelCatalog?.human_video && (
1126
+ <p className="field-help dependency-hint">
1127
+ {modelCatalog.human_video.edit_model} →{" "}
1128
+ {modelCatalog.human_video.video_model}{" "}
1129
+ {modelCatalog.human_video.resolution} via{" "}
1130
+ {modelCatalog.human_video.provider_name} on Inference
1131
+ Providers · about{" "}
1132
+ {money(
1133
+ modelCatalog.human_video.edit_usd +
1134
+ modelCatalog.human_video.video_usd,
1135
+ )}{" "}
1136
+ per video, billed to your account. The estimate counts
1137
+ about one subtask per{" "}
1138
+ {modelCatalog.human_video.seconds_per_subtask} s of each
1139
+ episode.
1140
+ {options.features.includes(HUMAN_VIDEOS) &&
1141
+ " One video per subtask, so Subtasks are included."}{" "}
1142
+ <a
1143
+ href={modelCatalog.human_video.pricing_url}
1144
+ target="_blank"
1145
+ rel="noreferrer"
1146
+ >
1147
+ Pricing
1148
+ </a>
1149
+ </p>
1150
+ )}
1151
+ </section>
1152
+ )}
1153
  {step === 2 && (
1154
  <section
1155
  className="card step-card model-picker"
 
1408
  <span>–</span>
1409
  {money(quote.costs.high)}
1410
  </div>
1411
+ {!!quote.costs.video_minutes &&
1412
+ quote.costs.video_minutes >
1413
+ quote.costs.hours * 60 && (
1414
+ <p className="field-help video-time-warning">
1415
+ ~{num(quote.costs.videos ?? 0)} videos at{" "}
1416
+ {quote.costs.video_parallel} at a time may need
1417
+ about {Math.ceil(quote.costs.video_minutes / 60)}{" "}
1418
+ h, longer than the {quote.costs.hours} h timeout.
1419
+ Later videos would be skipped; raise parallel
1420
+ generations or the timeout, or cap videos per
1421
+ episode.
1422
+ </p>
1423
+ )}
1424
  <details className="progressive-details cost-breakdown">
1425
  <summary>
1426
  Cost breakdown <ChevronDown size={15} />
 
1434
  : "—"}
1435
  </strong>
1436
  </div>
1437
+ {!!quote.costs.videos && (
1438
+ <div>
1439
+ <span>
1440
+ Human demo videos (~{num(quote.costs.videos)},
1441
+ up to {num(quote.costs.videos_max ?? 0)}){" "}
1442
+ <span
1443
+ className="info-tooltip"
1444
+ title="One video per subtask, estimated from each episode's length. Each video reserves its quoted price before it starts; the spending target stops further videos."
1445
+ >
1446
+ <HelpCircle size={12} />
1447
+ </span>
1448
+ </span>
1449
+ <strong>
1450
+ {money(quote.costs.media ?? 0)}–
1451
+ {money(quote.costs.media_max ?? 0)}
1452
+ </strong>
1453
+ </div>
1454
+ )}
1455
  <div>
1456
  <span>
1457
  CPU job reservation{" "}
frontend/src/wizard.css CHANGED
@@ -201,6 +201,34 @@
201
  .dependency-hint {
202
  margin-top: 20px;
203
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  .text-action {
205
  display: inline-block;
206
  color: var(--fg-muted);
 
201
  .dependency-hint {
202
  margin-top: 20px;
203
  }
204
+ .category-heading {
205
+ margin-bottom: 18px;
206
+ }
207
+ .category-heading h3 {
208
+ display: flex;
209
+ align-items: center;
210
+ gap: 8px;
211
+ font-size: 14px;
212
+ font-weight: 600;
213
+ margin: 0 0 4px;
214
+ }
215
+ .category-heading .field-help {
216
+ margin: 0;
217
+ }
218
+ .video-category {
219
+ margin-top: 16px;
220
+ }
221
+ .video-category .annotation-choice {
222
+ padding: 0;
223
+ border: 0;
224
+ }
225
+ .video-settings {
226
+ margin-top: 16px;
227
+ }
228
+ .video-time-warning {
229
+ color: var(--warning, #b45309);
230
+ margin-top: 8px;
231
+ }
232
  .text-action {
233
  display: inline-block;
234
  color: var(--fg-muted);
human-video-plan.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are preparing a human demonstration of one step of a robot manipulation video.
2
+
3
+ The {n} images are frames sampled uniformly from the robot's segment ({duration:.1f} s); image 1 is its first frame.
4
+ Subtask performed in this segment (authoritative): "{subtask}"
5
+ Episode instruction (context only): "{task}"
6
+
7
+ Return one JSON object with exactly these keys:
8
+ - "human_task": the subtask phrased for a person, naming the hand that acts (the hand on the same image side as the robot arm that acts), e.g. "Pick up the red cup with the right hand and place it on the tray."
9
+ - "object": short name and appearance of the manipulated object.
10
+ - "target": short name and appearance of where it goes (or "" if there is none).
11
+ - "hand": "left" or "right".
12
+ - "steps": list of 2 to 4 short steps describing how the objects move during the segment.
13
+ - "final_state": one short phrase describing the final object state.
14
+ - "edit_prompt": an instruction for an image-editing model that turns image 1 into a first-person view of a person doing the task. It must: (1) completely remove every robot arm, gripper, cable and robot base, filling those areas with the continuing table and background; (2) add exactly two realistic human hands and forearms (the viewer's own) entering from the bottom edge and resting relaxed on the near edge of the table, touching nothing; (3) list every object that must stay exactly as it is, with colour and position; (4) keep the camera viewpoint, table, background, lighting and layout unchanged and photorealistic. It must not add any other people, arms or objects.
human-video-prompt.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Image 1 is the first frame of a new first-person video of a person doing a task; an image-to-video model will animate it.
2
+ Images 2 onwards show how a robot did the same task; use them only for the order of events and object motion. No robot may appear in the new video.
3
+
4
+ Task: "{human_task}"
5
+ Object: {object}
6
+ Target: {target}
7
+ Steps: {steps}
8
+ Final state: {final_state}
9
+
10
+ Write "video_prompt": one paragraph of 100 to 170 words for the image-to-video model. Start with "From a fixed first-person camera perspective, based strictly on the initial image, the viewer's two human hands are resting on the near edge of the table." Describe the object and the target with their colours and positions as in image 1. Include verbatim: "Generate only these two human arms, with no extra limbs and no robotic arms. Every object stays still unless the hand holds it." Then describe with "First, ... Next, ... Finally, ..." how the {hand} hand reaches the object, grasps it, moves it and releases it at the target while the other hand stays still, then returns to rest without touching anything. The camera never moves. End with "The final frame shows {final_state}, with both hands resting at the near edge." The motion is realistic and smooth and completes within about {duration_s} seconds.
11
+
12
+ Also write "caption": two sentences describing the human video.
13
+
14
+ Return one JSON object with keys "video_prompt" and "caption".
human_video.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Robot → human demonstration videos, one per annotated subtask.
2
+
3
+ Mirrors LeRobot's ``human_video`` annotation module (``--human_video.enabled``), self-contained so
4
+ that it runs on the pinned pipeline bundle:
5
+
6
+ 1. the VLM reads frames of the robot segment and writes the human task and an image-edit prompt;
7
+ 2. Qwen-Image-Edit turns the segment's first full-resolution frame into a first-person human view;
8
+ 3. the VLM writes a step-by-step video prompt from the edited frame;
9
+ 4. MiniMax-H3 (480P) animates it.
10
+
11
+ Media requests go through Hugging Face Inference Providers (fal) with the signed-in user's token and
12
+ reserve their quoted price from the job's spending target before they start.
13
+ """
14
+
15
+ import io
16
+ import threading
17
+ import time
18
+ from concurrent.futures import ThreadPoolExecutor
19
+ from pathlib import Path
20
+ from urllib.parse import urlparse
21
+
22
+ from model_catalog import human_video_profile
23
+ from PIL import Image
24
+ from provider import BudgetExceededError, DeadlineExceededError
25
+
26
+ from lerobot.annotations.steerable_pipeline.frames import to_image_blocks
27
+
28
+ PLAN_PROMPT = Path(__file__).with_name("human-video-plan.txt").read_text()
29
+ VIDEO_PROMPT = Path(__file__).with_name("human-video-prompt.txt").read_text()
30
+ # A video takes about three minutes; do not start one the runtime limit would cut off.
31
+ VIDEO_SECONDS_NEEDED = 420
32
+
33
+
34
+ def png_bytes(image):
35
+ buffer = io.BytesIO()
36
+ image.convert("RGB").save(buffer, format="PNG")
37
+ return buffer.getvalue()
38
+
39
+
40
+ def subtask_spans(record, staging):
41
+ """``{text, start, end}`` from the staged subtask rows; each span ends at the next start."""
42
+ rows = [r for r in staging.read("plan") if r.get("style") == "subtask"] if staging.has("plan") else []
43
+ rows = sorted(rows, key=lambda r: float(r["timestamp"]))
44
+ end_of_episode = float(record.frame_timestamps[-1])
45
+ spans = []
46
+ for i, row in enumerate(rows):
47
+ start = float(row["timestamp"])
48
+ end = float(rows[i + 1]["timestamp"]) if i + 1 < len(rows) else end_of_episode
49
+ if end > start and row.get("content"):
50
+ spans.append({"text": str(row["content"]), "start": start, "end": end})
51
+ return spans
52
+
53
+
54
+ class FalMedia:
55
+ """Qwen-Image-Edit + MiniMax-H3 on fal through the HF router, charged to ``budget``."""
56
+
57
+ def __init__(self, token, budget, remaining_time, profile=None, concurrency=None, poll_interval=2.0):
58
+ self.token, self.budget, self.remaining_time = token, budget, remaining_time
59
+ self.profile = profile or human_video_profile()
60
+ self.poll_interval = poll_interval
61
+ # Job-wide limit on simultaneous media requests, shared by every episode thread.
62
+ self.concurrency = max(1, min(int(concurrency or self.profile["concurrency"]), 32))
63
+ self.semaphore = threading.BoundedSemaphore(self.concurrency)
64
+
65
+ def edit_image(self, image, prompt):
66
+ from huggingface_hub import InferenceClient
67
+
68
+ self.remaining_time()
69
+ with self.semaphore:
70
+ reservation = self.budget.reserve_media(self.profile["edit_usd"])
71
+ client = InferenceClient(provider=self.profile["provider"], api_key=self.token)
72
+ edited = client.image_to_image(
73
+ png_bytes(image),
74
+ prompt=prompt,
75
+ model=self.profile["edit_model"],
76
+ guidance_scale=self.profile["edit_guidance_scale"],
77
+ )
78
+ self.budget.record_media(reservation)
79
+ # fal returns a square image; keep the camera's size and aspect ratio.
80
+ return edited.convert("RGB").resize(image.size, Image.LANCZOS)
81
+
82
+ def image_to_video(self, image, prompt):
83
+ from huggingface_hub.inference._providers.fal_ai import FalAIImageToVideoTask
84
+ from huggingface_hub.utils import get_session, hf_raise_for_status
85
+
86
+ if self.remaining_time() < VIDEO_SECONDS_NEEDED:
87
+ raise DeadlineExceededError("Not enough runtime left for another video.")
88
+ # InferenceClient has no image-text-to-video method yet; fal's image-to-video helper speaks
89
+ # the same queue protocol once its task matches the model's mapping.
90
+ helper = FalAIImageToVideoTask()
91
+ helper.task = self.profile["video_task"]
92
+ params = {
93
+ "prompt": prompt,
94
+ "resolution": self.profile["resolution"],
95
+ "duration": self.profile["duration_s"],
96
+ }
97
+ session = get_session()
98
+ with self.semaphore:
99
+ request = helper.prepare_request(
100
+ inputs=png_bytes(image),
101
+ parameters=params,
102
+ headers={},
103
+ model=self.profile["video_model"],
104
+ api_key=self.token,
105
+ )
106
+ reservation = self.budget.reserve_media(self.profile["video_usd"])
107
+ response = session.post(request.url, json=request.json, headers=request.headers, timeout=60)
108
+ hf_raise_for_status(response)
109
+ queued = response.json()
110
+ if not queued.get("request_id") or not queued.get("response_url"):
111
+ raise RuntimeError("The video provider did not queue the request.")
112
+ parsed = urlparse(request.url)
113
+ base = f"{parsed.scheme}://{parsed.netloc}"
114
+ if parsed.netloc == "router.huggingface.co":
115
+ base += "/fal-ai"
116
+ query = f"?{parsed.query}" if parsed.query else ""
117
+ path = urlparse(queued["response_url"]).path
118
+ status = queued.get("status")
119
+ while status != "COMPLETED":
120
+ if status in ("FAILED", "ERROR", "CANCELLED"):
121
+ raise RuntimeError(f"The video provider reported {status}.")
122
+ self.remaining_time()
123
+ time.sleep(self.poll_interval)
124
+ polled = session.get(f"{base}{path}/status{query}", headers=request.headers, timeout=30)
125
+ hf_raise_for_status(polled)
126
+ status = polled.json().get("status")
127
+ result = session.get(f"{base}{path}{query}", headers=request.headers, timeout=60)
128
+ hf_raise_for_status(result)
129
+ url = result.json()["video"]["url"]
130
+ # The result is a public CDN link; the token is never sent there.
131
+ video = session.get(url, timeout=120)
132
+ hf_raise_for_status(video)
133
+ self.budget.record_media(reservation)
134
+ return video.content
135
+
136
+
137
+ def _nearest(timestamps, t):
138
+ return min(timestamps, key=lambda ts: abs(ts - t))
139
+
140
+
141
+ def generate_human_videos(record, staging, vlm, frames, full_frames, media, out_dir, max_segments):
142
+ """Generate the episode's videos (``max_segments`` 0 = every subtask); returns entries with local ``files``.
143
+
144
+ A failed segment is recorded and the others continue. Reaching the spending target or runtime
145
+ limit stops the remaining segments without failing the (already validated) annotations.
146
+ """
147
+ spans = subtask_spans(record, staging)
148
+ if max_segments:
149
+ spans = spans[:max_segments]
150
+ if not spans:
151
+ return []
152
+ out_dir.mkdir(parents=True, exist_ok=True)
153
+ ts = record.frame_timestamps
154
+ contexts = []
155
+ for span in spans:
156
+ samples = [span["start"] + (span["end"] - span["start"]) * i / 5 for i in range(6)]
157
+ contexts.append(frames.frames_at(record, [_nearest(ts, t) for t in samples]))
158
+ plans = vlm.generate_json(
159
+ [
160
+ [
161
+ {
162
+ "role": "user",
163
+ "content": [
164
+ *to_image_blocks(ctx),
165
+ {
166
+ "type": "text",
167
+ "text": PLAN_PROMPT.format(
168
+ n=len(ctx),
169
+ duration=span["end"] - span["start"],
170
+ subtask=span["text"],
171
+ task=record.episode_task,
172
+ ),
173
+ },
174
+ ],
175
+ }
176
+ ]
177
+ for span, ctx in zip(spans, contexts, strict=True)
178
+ ]
179
+ )
180
+ stop = threading.Event()
181
+ profile = media.profile
182
+
183
+ def segment(k):
184
+ span, ctx, plan = spans[k], contexts[k], plans[k]
185
+ stem = out_dir / f"segment_{k:03d}"
186
+ entry = {
187
+ "source_episode_index": record.episode_index,
188
+ "segment_index": k,
189
+ "subtask": span["text"],
190
+ "start_timestamp": span["start"],
191
+ "end_timestamp": span["end"],
192
+ "provider": profile["provider"],
193
+ "edit_model": profile["edit_model"],
194
+ "video_model": profile["video_model"],
195
+ "resolution": profile["resolution"],
196
+ "files": {},
197
+ }
198
+ if stop.is_set():
199
+ entry["status"] = "skipped"
200
+ return entry
201
+ try:
202
+ if not isinstance(plan, dict) or not plan.get("edit_prompt"):
203
+ raise ValueError("The model returned no human-video plan.")
204
+ robot = full_frames.frames_at(record, [_nearest(ts, span["start"])])[0].convert("RGB")
205
+ robot.save(f"{stem}_robot_frame.png")
206
+ entry["files"]["robot_frame"] = f"{stem}_robot_frame.png"
207
+ entry.update(human_task=plan.get("human_task"), edit_prompt=plan["edit_prompt"])
208
+ edited = media.edit_image(robot, str(plan["edit_prompt"]))
209
+ edited.save(f"{stem}_first_frame.png")
210
+ entry["files"]["first_frame"] = f"{stem}_first_frame.png"
211
+ text = VIDEO_PROMPT.format(
212
+ human_task=plan.get("human_task", ""),
213
+ object=plan.get("object", ""),
214
+ target=plan.get("target", ""),
215
+ steps="; ".join(map(str, plan.get("steps") or [])),
216
+ final_state=plan.get("final_state", "the task completed"),
217
+ hand=plan.get("hand", "right"),
218
+ duration_s=profile["duration_s"],
219
+ )
220
+ content = [{"type": "image", "image": edited}, *to_image_blocks(ctx)]
221
+ messages = [{"role": "user", "content": [*content, {"type": "text", "text": text}]}]
222
+ written = vlm.generate_json([messages])[0]
223
+ if not isinstance(written, dict) or not written.get("video_prompt"):
224
+ raise ValueError("The model returned no video prompt.")
225
+ entry.update(video_prompt=written["video_prompt"], caption=written.get("caption"))
226
+ Path(f"{stem}.mp4").write_bytes(media.image_to_video(edited, str(written["video_prompt"])))
227
+ entry["files"]["video"] = f"{stem}.mp4"
228
+ entry["status"] = "completed"
229
+ except BudgetExceededError as exc:
230
+ stop.set()
231
+ entry["status"] = "time_stopped" if isinstance(exc, DeadlineExceededError) else "budget_stopped"
232
+ except Exception as exc:
233
+ entry.update(status="failed", error_type=type(exc).__name__)
234
+ return entry
235
+
236
+ with ThreadPoolExecutor(max_workers=min(len(spans), getattr(media, "concurrency", 1))) as pool:
237
+ return list(pool.map(segment, range(len(spans))))
model_catalog.py CHANGED
@@ -206,6 +206,36 @@ MODELS = {
206
 
207
  UNAVAILABLE = []
208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
  def model_profile(key=DEFAULT_MODEL):
211
  if key not in MODELS:
@@ -213,9 +243,23 @@ def model_profile(key=DEFAULT_MODEL):
213
  return deepcopy(MODELS[key])
214
 
215
 
 
 
 
 
 
 
 
 
 
216
  def public_models():
217
  return {
218
  "default": DEFAULT_MODEL,
219
  "models": [model_profile(k) for k in MODELS],
220
  "unavailable": deepcopy(UNAVAILABLE),
 
 
 
 
 
221
  }
 
206
 
207
  UNAVAILABLE = []
208
 
209
+ # Robot → human demonstration videos. Both media models run on fal through HF Inference Providers
210
+ # and are billed per request to the signed-in account, so the Space reserves a fixed quoted price.
211
+ HUMAN_VIDEO = {
212
+ "provider": "fal-ai",
213
+ "provider_name": "fal",
214
+ "edit_model": "Qwen/Qwen-Image-Edit-2509",
215
+ # Lower guidance keeps the scene's colours; fal's default oversaturates.
216
+ "edit_guidance_scale": 1.5,
217
+ "video_model": "MiniMaxAI/MiniMax-H3",
218
+ # MiniMax-H3 is mapped under the image-text-to-video task on fal.
219
+ "video_task": "image-text-to-video",
220
+ "resolution": "480P",
221
+ "duration_s": 5,
222
+ # $0.03 per megapixel; fal returns about 1 MP, reserved as 1.33 MP.
223
+ "edit_usd": 0.04,
224
+ # $0.05 per second at 480p; the clip is 5 s, reserved as 6 s.
225
+ "video_usd": 0.30,
226
+ # Default simultaneous media requests per job (user-selectable up to 32).
227
+ "concurrency": 8,
228
+ # Estimation only: the action-phase prompt yields about one subtask per 5 s of episode
229
+ # (the same rate the memory estimate uses), and a video takes up to ~3 min on fal.
230
+ "seconds_per_subtask": 5,
231
+ "minutes_per_video": 3,
232
+ "checked_at": "2026-09-30",
233
+ "pricing_url": "https://fal.ai/models/minimax/h3/image-to-video",
234
+ }
235
+ # 0 = one video for every subtask.
236
+ HUMAN_VIDEO_SEGMENT_CHOICES = (0, 1, 2, 3, 5, 8)
237
+ HUMAN_VIDEO_PARALLEL_CHOICES = (1, 4, 8, 16, 32)
238
+
239
 
240
  def model_profile(key=DEFAULT_MODEL):
241
  if key not in MODELS:
 
243
  return deepcopy(MODELS[key])
244
 
245
 
246
+ def human_video_profile():
247
+ return deepcopy(HUMAN_VIDEO)
248
+
249
+
250
+ def human_video_segment_cost(profile=None):
251
+ profile = profile or HUMAN_VIDEO
252
+ return profile["edit_usd"] + profile["video_usd"]
253
+
254
+
255
  def public_models():
256
  return {
257
  "default": DEFAULT_MODEL,
258
  "models": [model_profile(k) for k in MODELS],
259
  "unavailable": deepcopy(UNAVAILABLE),
260
+ "human_video": {
261
+ **human_video_profile(),
262
+ "segment_choices": list(HUMAN_VIDEO_SEGMENT_CHOICES),
263
+ "parallel_choices": list(HUMAN_VIDEO_PARALLEL_CHOICES),
264
+ },
265
  }
provider.py CHANGED
@@ -37,6 +37,8 @@ class Budget:
37
  self.context_length = context_length
38
  self.pricing_tiers = sorted(pricing_tiers or [], key=lambda t: t["min_input_tokens"])
39
  self.input_tokens = self.output_tokens = self.cached_tokens = self.attempts = self.rate_limits = 0
 
 
40
  self.lock = threading.Lock()
41
 
42
  def reserve(self, max_tokens):
@@ -54,6 +56,22 @@ class Budget:
54
  output_rate = max([self.output_rate] + [t["output_rate"] for t in self.pricing_tiers])
55
  return (self.context_length * input_rate + max_tokens * output_rate) / 1e6
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  def reconcile(self, reservation, usage):
58
  if not isinstance(usage, dict):
59
  return # Unknown charges keep the complete reservation.
@@ -94,6 +112,9 @@ class Budget:
94
  "attempted_requests": self.attempts,
95
  "requests_without_usage": self.attempts - self.calls,
96
  "rate_limited_requests": self.rate_limits,
 
 
 
97
  }
98
 
99
 
 
37
  self.context_length = context_length
38
  self.pricing_tiers = sorted(pricing_tiers or [], key=lambda t: t["min_input_tokens"])
39
  self.input_tokens = self.output_tokens = self.cached_tokens = self.attempts = self.rate_limits = 0
40
+ self.media_attempts = self.media_completed = 0
41
+ self.media_usd = 0.0
42
  self.lock = threading.Lock()
43
 
44
  def reserve(self, max_tokens):
 
56
  output_rate = max([self.output_rate] + [t["output_rate"] for t in self.pricing_tiers])
57
  return (self.context_length * input_rate + max_tokens * output_rate) / 1e6
58
 
59
+ def reserve_media(self, amount):
60
+ """Reserve a fixed-price media request (image edit or video). Never refunded."""
61
+ with self.lock:
62
+ if self.reserved + amount > self.limit:
63
+ raise BudgetExceededError("Spending target reached; completed results are saved.")
64
+ self.reserved += amount
65
+ self.media_attempts += 1
66
+ return amount
67
+
68
+ def record_media(self, amount):
69
+ """A media request returned its output; count its quoted price as measured."""
70
+ with self.lock:
71
+ self.measured += amount
72
+ self.media_usd += amount
73
+ self.media_completed += 1
74
+
75
  def reconcile(self, reservation, usage):
76
  if not isinstance(usage, dict):
77
  return # Unknown charges keep the complete reservation.
 
112
  "attempted_requests": self.attempts,
113
  "requests_without_usage": self.attempts - self.calls,
114
  "rate_limited_requests": self.rate_limits,
115
+ "media_requests": self.media_attempts,
116
+ "completed_media_requests": self.media_completed,
117
+ "media_usd": self.media_usd,
118
  }
119
 
120
 
server.py CHANGED
@@ -4,6 +4,7 @@ import base64
4
  import hashlib
5
  import json
6
  import os
 
7
  import secrets
8
  import tempfile
9
  import threading
@@ -14,7 +15,7 @@ from urllib.parse import urlencode
14
 
15
  import requests
16
  from fastapi import FastAPI, HTTPException, Request
17
- from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
18
  from fastapi.staticfiles import StaticFiles
19
  from huggingface_hub import HfApi, hf_hub_download
20
  from model_catalog import DEFAULT_MODEL, public_models
@@ -43,12 +44,15 @@ class EstimateInput(DatasetInput):
43
  model: str = Field(default=DEFAULT_MODEL, min_length=1, max_length=80)
44
  start: int = Field(default=0, ge=0)
45
  count: int = Field(default=100, ge=0)
46
- features: list[str] = Field(min_length=1, max_length=6)
47
  camera: str | None = Field(default=None, max_length=200)
48
  hz: float = 0.1
49
  hours: int = 4
50
  mode: Literal["Annotated dataset copy"] = COPY
51
  prompts: dict[str, str] = Field(default_factory=dict, max_length=7)
 
 
 
52
 
53
 
54
  class PromptInput(BaseModel):
@@ -265,6 +269,8 @@ def calculate(options: EstimateInput, request: Request):
265
  current.get("token", False),
266
  prompts=options.prompts,
267
  model_key=options.model,
 
 
268
  )
269
  except Exception as exc:
270
  raise HTTPException(400, safe_error(exc)) from None
@@ -387,27 +393,40 @@ def cancel(job_id: str, request: Request):
387
  return {"ok": True}
388
 
389
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
  @app.get("/api/jobs/{job_id}/progress")
391
  def progress(job_id: str, request: Request):
392
  current = session(request, required=True)
393
  try:
394
- api = HfApi(token=current["token"])
395
- job = api.inspect_job(job_id=job_id, namespace=current["user"]["name"])
396
- output = output_repo(job, current["user"]["name"])
397
- if (job.labels or {}).get("app") != "lerobot-annotation-studio" or not output.startswith(
398
- current["user"]["name"] + "/"
399
- ):
400
- raise ValueError("This job has no Annotation Studio result.")
401
- with tempfile.TemporaryDirectory() as temp:
402
- path = hf_hub_download(
403
- output,
404
- "annotation_studio/run.json",
405
- repo_type="dataset",
406
- token=current["token"],
407
- cache_dir=temp,
408
- )
409
- report = json.loads(Path(path).read_text())
410
  episodes = report.get("episodes", [])
 
 
 
 
411
  return {
412
  "status": "interrupted"
413
  if job.status.stage in {"COMPLETED", "ERROR", "CANCELED", "DELETED"}
@@ -423,11 +442,61 @@ def progress(job_id: str, request: Request):
423
  "annotation_url": annotation_url(report),
424
  "output_mode": report.get("manifest", {}).get("mode"),
425
  "model": report.get("manifest", {}).get("model"),
 
426
  }
427
  except Exception as exc:
428
  raise HTTPException(400, "No saved progress yet. The job may still be starting.") from exc
429
 
430
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
  @app.get("/health")
432
  def health():
433
  return {"status": "ok", "service": "lerobot-annotation-studio"}
 
4
  import hashlib
5
  import json
6
  import os
7
+ import re
8
  import secrets
9
  import tempfile
10
  import threading
 
15
 
16
  import requests
17
  from fastapi import FastAPI, HTTPException, Request
18
+ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
19
  from fastapi.staticfiles import StaticFiles
20
  from huggingface_hub import HfApi, hf_hub_download
21
  from model_catalog import DEFAULT_MODEL, public_models
 
44
  model: str = Field(default=DEFAULT_MODEL, min_length=1, max_length=80)
45
  start: int = Field(default=0, ge=0)
46
  count: int = Field(default=100, ge=0)
47
+ features: list[str] = Field(min_length=1, max_length=7)
48
  camera: str | None = Field(default=None, max_length=200)
49
  hz: float = 0.1
50
  hours: int = 4
51
  mode: Literal["Annotated dataset copy"] = COPY
52
  prompts: dict[str, str] = Field(default_factory=dict, max_length=7)
53
+ # 0 = every subtask.
54
+ human_video_segments: int | None = Field(default=None, ge=0, le=8)
55
+ human_video_parallel: int = Field(default=8, ge=1, le=32)
56
 
57
 
58
  class PromptInput(BaseModel):
 
269
  current.get("token", False),
270
  prompts=options.prompts,
271
  model_key=options.model,
272
+ human_video_segments=options.human_video_segments,
273
+ human_video_parallel=options.human_video_parallel,
274
  )
275
  except Exception as exc:
276
  raise HTTPException(400, safe_error(exc)) from None
 
393
  return {"ok": True}
394
 
395
 
396
+ def studio_output(job_id, current):
397
+ """The job and its output dataset, only for Annotation Studio jobs in the user's namespace."""
398
+ api = HfApi(token=current["token"])
399
+ job = api.inspect_job(job_id=job_id, namespace=current["user"]["name"])
400
+ output = output_repo(job, current["user"]["name"])
401
+ if (job.labels or {}).get("app") != "lerobot-annotation-studio" or not output.startswith(
402
+ current["user"]["name"] + "/"
403
+ ):
404
+ raise ValueError("This job has no Annotation Studio result.")
405
+ return job, output
406
+
407
+
408
+ def read_output_file(output, path, token):
409
+ with tempfile.TemporaryDirectory() as temp:
410
+ local = hf_hub_download(output, path, repo_type="dataset", token=token, cache_dir=temp)
411
+ return Path(local).read_bytes()
412
+
413
+
414
+ HUMAN_VIDEO_FILE = re.compile(
415
+ r"annotation_studio/human_videos/episode_\d{6}/segment_\d{3}(_robot_frame\.png|_first_frame\.png|\.mp4)"
416
+ )
417
+
418
+
419
  @app.get("/api/jobs/{job_id}/progress")
420
  def progress(job_id: str, request: Request):
421
  current = session(request, required=True)
422
  try:
423
+ job, output = studio_output(job_id, current)
424
+ report = json.loads(read_output_file(output, "annotation_studio/run.json", current["token"]))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
  episodes = report.get("episodes", [])
426
+ videos = {}
427
+ for episode in episodes:
428
+ for status, count in (episode.get("human_videos") or {}).items():
429
+ videos[status] = videos.get(status, 0) + count
430
  return {
431
  "status": "interrupted"
432
  if job.status.stage in {"COMPLETED", "ERROR", "CANCELED", "DELETED"}
 
442
  "annotation_url": annotation_url(report),
443
  "output_mode": report.get("manifest", {}).get("mode"),
444
  "model": report.get("manifest", {}).get("model"),
445
+ "human_videos": videos if (report.get("manifest", {}).get("human_video")) else None,
446
  }
447
  except Exception as exc:
448
  raise HTTPException(400, "No saved progress yet. The job may still be starting.") from exc
449
 
450
 
451
+ @app.get("/api/jobs/{job_id}/human-videos")
452
+ def human_videos(job_id: str, request: Request):
453
+ current = session(request, required=True)
454
+ try:
455
+ _, output = studio_output(job_id, current)
456
+ index = json.loads(
457
+ read_output_file(output, "annotation_studio/human_videos/index.json", current["token"])
458
+ )
459
+ except Exception as exc:
460
+ raise HTTPException(400, "No human demo videos saved yet.") from exc
461
+ media = f"/api/jobs/{job_id}/media?path="
462
+ return [
463
+ {
464
+ key: entry.get(key)
465
+ for key in (
466
+ "episode_index",
467
+ "source_episode_index",
468
+ "segment_index",
469
+ "subtask",
470
+ "human_task",
471
+ "caption",
472
+ "start_timestamp",
473
+ "end_timestamp",
474
+ "status",
475
+ )
476
+ }
477
+ | {
478
+ kind: media + entry[f"{kind}_path"]
479
+ for kind in ("robot_frame", "first_frame", "video")
480
+ if HUMAN_VIDEO_FILE.fullmatch(entry.get(f"{kind}_path") or "")
481
+ }
482
+ for entry in index[:60]
483
+ ]
484
+
485
+
486
+ @app.get("/api/jobs/{job_id}/media")
487
+ def human_video_media(job_id: str, path: str, request: Request):
488
+ """Stream a generated video or frame from the user's (possibly private) output dataset."""
489
+ current = session(request, required=True)
490
+ if not HUMAN_VIDEO_FILE.fullmatch(path):
491
+ raise HTTPException(400, "Unsupported media path.")
492
+ try:
493
+ _, output = studio_output(job_id, current)
494
+ content = read_output_file(output, path, current["token"])
495
+ except Exception as exc:
496
+ raise HTTPException(404, "Media not found.") from exc
497
+ return Response(content, media_type="video/mp4" if path.endswith(".mp4") else "image/png")
498
+
499
+
500
  @app.get("/health")
501
  def health():
502
  return {"status": "ok", "service": "lerobot-annotation-studio"}
studio.py CHANGED
@@ -14,7 +14,14 @@ from pathlib import Path, PurePosixPath
14
  import pyarrow.parquet as pq
15
  from huggingface_hub import HfApi, hf_hub_download
16
  from huggingface_hub.utils import validate_repo_id
17
- from model_catalog import DEFAULT_MODEL, model_profile
 
 
 
 
 
 
 
18
  from prompt_config import TEMPLATES, validate_prompts
19
 
20
  MODEL = model_profile()["route"]
@@ -26,7 +33,10 @@ FEATURES = {
26
  "Task rephrasings": "task_aug",
27
  "Interjections & speech": "interjection",
28
  "Visual questions": "vqa",
 
 
29
  }
 
30
  COPY = "Annotated dataset copy"
31
  EXPORT = "Annotation files only" # Historical reports only; new runs always write Parquet.
32
 
@@ -151,11 +161,28 @@ def read_selection(dataset, start, count, token=False, revision=None, cache_dir=
151
  }
152
 
153
 
154
- def estimate(selection, features, vqa_hz, hours, cpu_rate, prompts=None, model_key=DEFAULT_MODEL):
 
 
 
 
 
 
 
 
 
 
155
  profile = model_profile(model_key)
156
  prompts = validate_prompts(prompts or {}, features)
157
  if not features or any(f not in FEATURES for f in features):
158
  raise ValueError("Choose at least one annotation type.")
 
 
 
 
 
 
 
159
  if hours not in (1, 4, 12) or vqa_hz not in (0.1, 0.5, 1.0):
160
  raise ValueError("Choose a supported timeout and visual-question frequency.")
161
  n, seconds = len(selection["episodes"]), selection["seconds"]
@@ -185,6 +212,24 @@ def estimate(selection, features, vqa_hz, hours, cpu_rate, prompts=None, model_k
185
  inputs += ticks * 1500
186
  outputs += ticks * 200
187
  calls += ticks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  if "plan_render" in prompts:
189
  plan_calls = max(n, seconds / 5) + (n * 3 if "Interjections & speech" in features else 0)
190
  inputs += plan_calls * 1500
@@ -207,9 +252,16 @@ def estimate(selection, features, vqa_hz, hours, cpu_rate, prompts=None, model_k
207
  cpu_max = math.ceil(hours * 60 + 1) * cpu_rate
208
  # Range includes retries, grounding, variable segment counts and recovery.
209
  return {
210
- "low": inference * 0.6 + cpu_max,
211
- "high": inference * 2.5 + cpu_max,
212
  "inference": inference,
 
 
 
 
 
 
 
213
  "cpu_max": cpu_max,
214
  "cpu_rate": cpu_rate,
215
  "calls": math.ceil(calls),
@@ -226,10 +278,34 @@ def estimate(selection, features, vqa_hz, hours, cpu_rate, prompts=None, model_k
226
  }
227
 
228
 
229
- def fingerprint(dataset, start, count, features, camera, vqa_hz, hours, mode, model_key=DEFAULT_MODEL):
 
 
 
 
 
 
 
 
 
 
 
 
230
  return hashlib.sha256(
231
  json.dumps(
232
- [dataset.strip(), start, count, sorted(features), camera, vqa_hz, hours, mode, model_key],
 
 
 
 
 
 
 
 
 
 
 
 
233
  sort_keys=True,
234
  ).encode()
235
  ).hexdigest()
@@ -253,9 +329,13 @@ def prepare(
253
  token=False,
254
  prompts=None,
255
  model_key=DEFAULT_MODEL,
 
 
256
  ):
257
  profile = model_profile(model_key)
258
  prompts = validate_prompts(prompts or {}, features)
 
 
259
  if mode != COPY:
260
  raise ValueError("Annotations must be saved in a LeRobot Parquet dataset. Refresh the Space.")
261
  selection = read_selection(dataset, start, count, token)
@@ -266,7 +346,17 @@ def prepare(
266
  hardware = next(h for h in HfApi(token=token or False).list_jobs_hardware() if h.name == "cpu-upgrade")
267
  if hardware.unit_label != "minute":
268
  raise ValueError("HF changed its billing units; refresh the Space before launching.")
269
- costs = estimate(selection, features, vqa_hz, hours, hardware.unit_cost_usd, prompts, model_key)
 
 
 
 
 
 
 
 
 
 
270
  return {
271
  "selection": selection,
272
  "features": list(features),
@@ -276,12 +366,42 @@ def prepare(
276
  "prompts": prompts,
277
  "costs": costs,
278
  "model_profile": profile,
 
 
 
 
 
 
 
 
279
  "created": time.time(),
280
  "nonce": uuid.uuid4().hex,
281
- "fingerprint": fingerprint(dataset, start, count, features, camera, vqa_hz, hours, mode, model_key),
 
 
 
 
 
 
 
 
 
 
 
 
282
  }
283
 
284
 
 
 
 
 
 
 
 
 
 
 
285
  def job_command(space, revision, timeout_minutes=None):
286
  # Only trusted deployment values enter shell syntax. User options travel in JSON env.
287
  validate_repo_id(space)
@@ -382,20 +502,23 @@ def launch(
382
  * max([profile["input_rate"]] + [t["input_rate"] for t in profile.get("pricing_tiers", [])])
383
  + 4096 * max([profile["output_rate"]] + [t["output_rate"] for t in profile.get("pricing_tiers", [])])
384
  ) / 1e6
 
 
 
 
 
 
385
  if budget - plan["costs"]["cpu_max"] < minimum_reservation:
386
  raise ValueError(
387
  f"This model needs at least ${minimum_reservation + plan['costs']['cpu_max']:.2f} for one conservative request reservation. Increase the target or choose a lower-cost model."
388
  )
389
- mapping = api.model_info(
390
- profile["model_id"], expand=["inferenceProviderMapping"]
391
- ).inference_provider_mapping
392
- hosted = (
393
- mapping.get(profile["provider"])
394
- if isinstance(mapping, dict)
395
- else next((item for item in mapping or [] if item.provider == profile["provider"]), None)
396
- )
397
- if hosted is None or hosted.status != "live":
398
  raise ValueError("The selected model is temporarily unavailable from this provider.")
 
 
 
 
 
399
  space = space or os.environ.get("SPACE_ID")
400
  if not space:
401
  raise ValueError("Job launch is enabled only on the deployed Hugging Face Space.")
 
14
  import pyarrow.parquet as pq
15
  from huggingface_hub import HfApi, hf_hub_download
16
  from huggingface_hub.utils import validate_repo_id
17
+ from model_catalog import (
18
+ DEFAULT_MODEL,
19
+ HUMAN_VIDEO_PARALLEL_CHOICES,
20
+ HUMAN_VIDEO_SEGMENT_CHOICES,
21
+ human_video_profile,
22
+ human_video_segment_cost,
23
+ model_profile,
24
+ )
25
  from prompt_config import TEMPLATES, validate_prompts
26
 
27
  MODEL = model_profile()["route"]
 
33
  "Task rephrasings": "task_aug",
34
  "Interjections & speech": "interjection",
35
  "Visual questions": "vqa",
36
+ # Not a language style: videos are saved under annotation_studio/human_videos/.
37
+ "Human demo videos": "human_video",
38
  }
39
+ HUMAN_VIDEOS = "Human demo videos"
40
  COPY = "Annotated dataset copy"
41
  EXPORT = "Annotation files only" # Historical reports only; new runs always write Parquet.
42
 
 
161
  }
162
 
163
 
164
+ def estimate(
165
+ selection,
166
+ features,
167
+ vqa_hz,
168
+ hours,
169
+ cpu_rate,
170
+ prompts=None,
171
+ model_key=DEFAULT_MODEL,
172
+ human_video_segments=None,
173
+ human_video_parallel=None,
174
+ ):
175
  profile = model_profile(model_key)
176
  prompts = validate_prompts(prompts or {}, features)
177
  if not features or any(f not in FEATURES for f in features):
178
  raise ValueError("Choose at least one annotation type.")
179
+ if HUMAN_VIDEOS in features:
180
+ if "Subtasks" not in features:
181
+ raise ValueError("Human demo videos are generated per subtask; also select Subtasks.")
182
+ if human_video_segments not in HUMAN_VIDEO_SEGMENT_CHOICES:
183
+ raise ValueError("Choose a supported number of videos per episode.")
184
+ if human_video_parallel not in HUMAN_VIDEO_PARALLEL_CHOICES:
185
+ raise ValueError("Choose a supported number of parallel videos.")
186
  if hours not in (1, 4, 12) or vqa_hz not in (0.1, 0.5, 1.0):
187
  raise ValueError("Choose a supported timeout and visual-question frequency.")
188
  n, seconds = len(selection["episodes"]), selection["seconds"]
 
212
  inputs += ticks * 1500
213
  outputs += ticks * 200
214
  calls += ticks
215
+ videos = videos_max = media = media_max = video_minutes = 0
216
+ if HUMAN_VIDEOS in features:
217
+ video_profile = human_video_profile()
218
+ # One video per subtask, and subtask counts scale with episode length: expect one per
219
+ # `seconds_per_subtask`, allow twice that in the upper bound, and apply the per-episode cap.
220
+ cap = human_video_segments or math.inf
221
+ per = video_profile["seconds_per_subtask"]
222
+ fps = selection["info"]["fps"]
223
+ lengths = [r["length"] / fps for r in selection["episodes"]]
224
+ videos = sum(min(cap, max(1.0, s / per)) for s in lengths)
225
+ videos_max = sum(min(cap, max(1, math.ceil(2 * s / per))) for s in lengths)
226
+ media = videos * human_video_segment_cost()
227
+ media_max = videos_max * human_video_segment_cost()
228
+ video_minutes = math.ceil(videos / human_video_parallel) * video_profile["minutes_per_video"]
229
+ # Two VLM calls per video: plan + edit prompt (6 frames), video prompt (edited frame + 6).
230
+ inputs += videos * (2600 + 3500)
231
+ outputs += videos * (300 + 350)
232
+ calls += videos * 2
233
  if "plan_render" in prompts:
234
  plan_calls = max(n, seconds / 5) + (n * 3 if "Interjections & speech" in features else 0)
235
  inputs += plan_calls * 1500
 
252
  cpu_max = math.ceil(hours * 60 + 1) * cpu_rate
253
  # Range includes retries, grounding, variable segment counts and recovery.
254
  return {
255
+ "low": inference * 0.6 + media * 0.6 + cpu_max,
256
+ "high": inference * 2.5 + media_max + cpu_max,
257
  "inference": inference,
258
+ "media": media,
259
+ "media_max": media_max,
260
+ "videos": math.ceil(videos),
261
+ "videos_max": videos_max,
262
+ "video_minutes": video_minutes,
263
+ "video_parallel": human_video_parallel if videos else 0,
264
+ "segment_usd": human_video_segment_cost() if videos else 0,
265
  "cpu_max": cpu_max,
266
  "cpu_rate": cpu_rate,
267
  "calls": math.ceil(calls),
 
278
  }
279
 
280
 
281
+ def fingerprint(
282
+ dataset,
283
+ start,
284
+ count,
285
+ features,
286
+ camera,
287
+ vqa_hz,
288
+ hours,
289
+ mode,
290
+ model_key=DEFAULT_MODEL,
291
+ human_video_segments=None,
292
+ human_video_parallel=None,
293
+ ):
294
  return hashlib.sha256(
295
  json.dumps(
296
+ [
297
+ dataset.strip(),
298
+ start,
299
+ count,
300
+ sorted(features),
301
+ camera,
302
+ vqa_hz,
303
+ hours,
304
+ mode,
305
+ model_key,
306
+ human_video_segments,
307
+ human_video_parallel,
308
+ ],
309
  sort_keys=True,
310
  ).encode()
311
  ).hexdigest()
 
329
  token=False,
330
  prompts=None,
331
  model_key=DEFAULT_MODEL,
332
+ human_video_segments=None,
333
+ human_video_parallel=None,
334
  ):
335
  profile = model_profile(model_key)
336
  prompts = validate_prompts(prompts or {}, features)
337
+ if HUMAN_VIDEOS not in features:
338
+ human_video_segments = human_video_parallel = None
339
  if mode != COPY:
340
  raise ValueError("Annotations must be saved in a LeRobot Parquet dataset. Refresh the Space.")
341
  selection = read_selection(dataset, start, count, token)
 
346
  hardware = next(h for h in HfApi(token=token or False).list_jobs_hardware() if h.name == "cpu-upgrade")
347
  if hardware.unit_label != "minute":
348
  raise ValueError("HF changed its billing units; refresh the Space before launching.")
349
+ costs = estimate(
350
+ selection,
351
+ features,
352
+ vqa_hz,
353
+ hours,
354
+ hardware.unit_cost_usd,
355
+ prompts,
356
+ model_key,
357
+ human_video_segments,
358
+ human_video_parallel,
359
+ )
360
  return {
361
  "selection": selection,
362
  "features": list(features),
 
366
  "prompts": prompts,
367
  "costs": costs,
368
  "model_profile": profile,
369
+ "human_video": {
370
+ # 0 = every subtask.
371
+ "segments_per_episode": human_video_segments,
372
+ "parallel": human_video_parallel,
373
+ "profile": human_video_profile(),
374
+ }
375
+ if HUMAN_VIDEOS in features
376
+ else None,
377
  "created": time.time(),
378
  "nonce": uuid.uuid4().hex,
379
+ "fingerprint": fingerprint(
380
+ dataset,
381
+ start,
382
+ count,
383
+ features,
384
+ camera,
385
+ vqa_hz,
386
+ hours,
387
+ mode,
388
+ model_key,
389
+ human_video_segments,
390
+ human_video_parallel,
391
+ ),
392
  }
393
 
394
 
395
+ def provider_live(api, model_id, provider):
396
+ mapping = api.model_info(model_id, expand=["inferenceProviderMapping"]).inference_provider_mapping
397
+ hosted = (
398
+ mapping.get(provider)
399
+ if isinstance(mapping, dict)
400
+ else next((item for item in mapping or [] if item.provider == provider), None)
401
+ )
402
+ return hosted is not None and hosted.status == "live"
403
+
404
+
405
  def job_command(space, revision, timeout_minutes=None):
406
  # Only trusted deployment values enter shell syntax. User options travel in JSON env.
407
  validate_repo_id(space)
 
502
  * max([profile["input_rate"]] + [t["input_rate"] for t in profile.get("pricing_tiers", [])])
503
  + 4096 * max([profile["output_rate"]] + [t["output_rate"] for t in profile.get("pricing_tiers", [])])
504
  ) / 1e6
505
+ human_video = plan.get("human_video")
506
+ if human_video:
507
+ if human_video["profile"] != human_video_profile():
508
+ raise ValueError("Video pricing changed. Calculate a fresh estimate.")
509
+ # One video must fit next to one conservative text reservation.
510
+ minimum_reservation += human_video_segment_cost()
511
  if budget - plan["costs"]["cpu_max"] < minimum_reservation:
512
  raise ValueError(
513
  f"This model needs at least ${minimum_reservation + plan['costs']['cpu_max']:.2f} for one conservative request reservation. Increase the target or choose a lower-cost model."
514
  )
515
+ if not provider_live(api, profile["model_id"], profile["provider"]):
 
 
 
 
 
 
 
 
516
  raise ValueError("The selected model is temporarily unavailable from this provider.")
517
+ if human_video and not all(
518
+ provider_live(api, human_video["profile"][key], human_video["profile"]["provider"])
519
+ for key in ("edit_model", "video_model")
520
+ ):
521
+ raise ValueError("The video models are temporarily unavailable from fal.")
522
  space = space or os.environ.get("SPACE_ID")
523
  if not space:
524
  raise ValueError("Job launch is enabled only on the deployed Hugging Face Space.")
test_human_video.py ADDED
@@ -0,0 +1,485 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Human demo videos: pricing, launch checks, generation and media access, without paid requests."""
2
+
3
+ import json
4
+ import sys
5
+ import time
6
+ from pathlib import Path
7
+ from types import SimpleNamespace
8
+ from unittest.mock import Mock
9
+
10
+ import pytest
11
+ from PIL import Image
12
+
13
+ sys.path.insert(0, str(Path(__file__).parent))
14
+ import human_video # noqa: E402
15
+ import studio # noqa: E402
16
+ from model_catalog import human_video_profile, human_video_segment_cost # noqa: E402
17
+ from provider import Budget, BudgetExceededError, DeadlineExceededError # noqa: E402
18
+
19
+ from lerobot.annotations.steerable_pipeline.reader import EpisodeRecord
20
+ from lerobot.annotations.steerable_pipeline.staging import EpisodeStaging
21
+ from lerobot.annotations.steerable_pipeline.vlm_client import StubVlmClient
22
+
23
+ CAMERA = "observation.images.top"
24
+ PLAN = {
25
+ "human_task": "Pick up the cup with the right hand and place it on the table.",
26
+ "object": "white cup",
27
+ "target": "table",
28
+ "hand": "right",
29
+ "steps": ["grasp the cup", "place it"],
30
+ "final_state": "the cup on the table",
31
+ "edit_prompt": "Remove the robot and add two human hands resting at the bottom edge.",
32
+ }
33
+
34
+
35
+ def selection():
36
+ return {
37
+ "episodes": [{"episode_index": 0, "length": 300}, {"episode_index": 1, "length": 600}],
38
+ "seconds": 30,
39
+ "info": {"fps": 30},
40
+ "dataset": "source/robot",
41
+ "revision": "a" * 40,
42
+ "start": 0,
43
+ "count": 2,
44
+ }
45
+
46
+
47
+ def video_estimate(selection_, segments=0, parallel=8, hours=4):
48
+ return studio.estimate(
49
+ selection_,
50
+ ["Subtasks", studio.HUMAN_VIDEOS],
51
+ 0.1,
52
+ hours,
53
+ 0.0005,
54
+ human_video_segments=segments,
55
+ human_video_parallel=parallel,
56
+ )
57
+
58
+
59
+ def episodes(*seconds, fps=30):
60
+ return {
61
+ **selection(),
62
+ "episodes": [{"episode_index": i, "length": int(s * fps)} for i, s in enumerate(seconds)],
63
+ "seconds": sum(seconds),
64
+ }
65
+
66
+
67
+ def test_video_estimate_scales_with_episode_length_and_count():
68
+ base = studio.estimate(selection(), ["Subtasks"], 0.1, 4, 0.0005)
69
+ assert base["media"] == 0 and base["videos"] == 0
70
+ # 10 s and 20 s episodes: about one subtask per 5 s -> 2 + 4 videos, up to 4 + 8.
71
+ every = video_estimate(selection())
72
+ assert every["videos"] == 6 and every["videos_max"] == 12
73
+ assert every["media"] == pytest.approx(6 * human_video_segment_cost())
74
+ assert every["media_max"] == pytest.approx(12 * human_video_segment_cost())
75
+ assert every["high"] - base["high"] > every["media_max"]
76
+ assert every["calls"] == base["calls"] + 12
77
+ # Longer episodes and more episodes cost more; very short episodes still get one video.
78
+ assert video_estimate(episodes(40, 40))["media"] > every["media"]
79
+ assert video_estimate(episodes(10, 20, 10, 20))["videos"] == 12
80
+ assert video_estimate(episodes(1, 2))["videos"] == 2
81
+ # A per-episode cap bounds both the expected and maximum count.
82
+ capped = video_estimate(selection(), segments=3)
83
+ assert capped["videos"] == 5 and capped["videos_max"] == 6
84
+
85
+
86
+ def test_parallel_generations_shorten_the_video_time_estimate():
87
+ long = episodes(*[60] * 20) # 240 videos expected
88
+ slow, fast = video_estimate(long, parallel=1), video_estimate(long, parallel=32)
89
+ assert slow["video_minutes"] == 240 * 3 and fast["video_minutes"] == 8 * 3
90
+ assert slow["media"] == fast["media"]
91
+ with pytest.raises(ValueError, match="parallel"):
92
+ video_estimate(selection(), parallel=64)
93
+
94
+
95
+ def test_videos_need_subtasks_and_supported_choices():
96
+ with pytest.raises(ValueError, match="Subtasks"):
97
+ studio.estimate(
98
+ selection(), [studio.HUMAN_VIDEOS], 0.1, 4, 0.0005, human_video_segments=0, human_video_parallel=8
99
+ )
100
+ with pytest.raises(ValueError, match="videos per episode"):
101
+ video_estimate(selection(), segments=4)
102
+
103
+
104
+ def video_plan(profile=None):
105
+ features = ["Subtasks", studio.HUMAN_VIDEOS]
106
+ return {
107
+ "selection": selection(),
108
+ "features": features,
109
+ "camera": CAMERA,
110
+ "vqa_hz": 0.1,
111
+ "mode": studio.COPY,
112
+ "created": time.time(),
113
+ "nonce": "request123",
114
+ "human_video": {
115
+ "segments_per_episode": 2,
116
+ "parallel": 8,
117
+ "profile": profile or human_video_profile(),
118
+ },
119
+ "costs": studio.estimate(
120
+ selection(), features, 0.1, 4, 0.0005, human_video_segments=2, human_video_parallel=8
121
+ ),
122
+ }
123
+
124
+
125
+ def api_mock(fal_status="live"):
126
+ api = Mock()
127
+ api.whoami.return_value = {"name": "alice"}
128
+ api.list_jobs.return_value = []
129
+ api.repo_exists.return_value = False
130
+ api.dataset_info.return_value = SimpleNamespace(id="source/robot", sha="a" * 40)
131
+ api.space_info.return_value = SimpleNamespace(sha="b" * 40)
132
+ api.list_jobs_hardware.return_value = [
133
+ SimpleNamespace(name="cpu-upgrade", unit_label="minute", unit_cost_usd=0.0005)
134
+ ]
135
+ api.model_info.return_value = SimpleNamespace(
136
+ inference_provider_mapping={
137
+ studio.PROVIDER: SimpleNamespace(status="live"),
138
+ "fal-ai": SimpleNamespace(status=fal_status),
139
+ }
140
+ )
141
+ api.run_job.return_value = SimpleNamespace(id="job123")
142
+ return api
143
+
144
+
145
+ def launch(plan, api, budget=20):
146
+ return studio.launch(
147
+ plan,
148
+ "alice/out",
149
+ budget,
150
+ True,
151
+ "token",
152
+ space="lerobot/annotation-studio",
153
+ api_factory=lambda **_: api,
154
+ )
155
+
156
+
157
+ def test_launch_checks_fal_models_and_pins_video_settings():
158
+ api = api_mock()
159
+ assert launch(video_plan(), api)["id"] == "job123"
160
+ checked = [call.args[0] for call in api.model_info.call_args_list]
161
+ assert {"MiniMaxAI/MiniMax-H3", "Qwen/Qwen-Image-Edit-2509"} <= set(checked)
162
+ manifest = json.loads(api.run_job.call_args.kwargs["env"]["ANNOTATION_MANIFEST"])
163
+ assert manifest["human_video"]["segments_per_episode"] == 2
164
+ assert manifest["human_video"]["profile"]["resolution"] == "480P"
165
+ # The job downloads the human-video prompt files with the other *.txt resources.
166
+ assert "*.txt" in str(api.run_job.call_args.kwargs["command"])
167
+
168
+
169
+ def test_launch_refuses_offline_fal_models_or_stale_video_prices():
170
+ api = api_mock(fal_status="staging")
171
+ with pytest.raises(ValueError, match="fal"):
172
+ launch(video_plan(), api)
173
+ stale = {**human_video_profile(), "video_usd": 0.01}
174
+ with pytest.raises(ValueError, match="Video pricing"):
175
+ launch(video_plan(stale), api_mock())
176
+ api.run_job.assert_not_called()
177
+
178
+
179
+ def test_media_budget_reserves_before_request_and_stops_at_target():
180
+ budget = Budget(0.5, 0.15, 0.5)
181
+ reservation = budget.reserve_media(0.3)
182
+ budget.record_media(reservation)
183
+ assert budget.summary()["media_usd"] == pytest.approx(0.3)
184
+ assert budget.summary()["measured_inference_usd"] == pytest.approx(0.3)
185
+ with pytest.raises(BudgetExceededError):
186
+ budget.reserve_media(0.3)
187
+ assert budget.summary()["media_requests"] == 1
188
+
189
+
190
+ class FakeMedia:
191
+ concurrency = 4
192
+
193
+ def __init__(self, fail_first=False, budget_after=None):
194
+ self.profile = human_video_profile()
195
+ self.fail_first, self.budget_after = fail_first, budget_after
196
+ self.edits, self.videos = [], []
197
+
198
+ def edit_image(self, image, prompt):
199
+ self.edits.append((image.size, prompt))
200
+ return image.transpose(Image.FLIP_LEFT_RIGHT)
201
+
202
+ def image_to_video(self, image, prompt):
203
+ self.videos.append(prompt)
204
+ if self.fail_first and len(self.videos) == 1:
205
+ raise RuntimeError("provider error")
206
+ if self.budget_after is not None and len(self.videos) > self.budget_after:
207
+ raise BudgetExceededError("Spending target reached.")
208
+ return b"\x00\x00\x00\x18ftypmp42video"
209
+
210
+
211
+ def respond(messages):
212
+ text = " ".join(p.get("text", "") for m in messages for p in m["content"] if isinstance(p, dict))
213
+ if "meaningful action phases" in text:
214
+ return {
215
+ "subtasks": [
216
+ {"text": "grasp the cup", "start": 0, "end": 2.5},
217
+ {"text": "place the cup on table", "start": 2.5, "end": 3.9},
218
+ ]
219
+ }
220
+ if "preparing a human demonstration" in text:
221
+ return PLAN
222
+ if "image-to-video model will animate it" in text:
223
+ return {"video_prompt": "From a fixed first-person camera perspective, ...", "caption": "A hand."}
224
+ raise AssertionError("Unexpected prompt")
225
+
226
+
227
+ @pytest.fixture
228
+ def episode(tmp_path):
229
+ import av
230
+ import numpy as np
231
+
232
+ video = tmp_path / "videos" / CAMERA / "chunk-000" / "file-000.mp4"
233
+ video.parent.mkdir(parents=True)
234
+ with av.open(str(video), "w") as container:
235
+ stream = container.add_stream("mpeg4", rate=10)
236
+ stream.width, stream.height, stream.pix_fmt = 320, 240, "yuv420p"
237
+ for i in range(40):
238
+ frame = av.VideoFrame.from_ndarray(np.full((240, 320, 3), i * 5, dtype=np.uint8), format="rgb24")
239
+ for packet in stream.encode(frame):
240
+ container.mux(packet)
241
+ for packet in stream.encode():
242
+ container.mux(packet)
243
+ info = {"fps": 10, "video_path": "videos/{video_key}/chunk-{chunk_index:03d}/file-{file_index:03d}.mp4"}
244
+ row = {
245
+ "length": 40,
246
+ f"videos/{CAMERA}/chunk_index": 0,
247
+ f"videos/{CAMERA}/file_index": 0,
248
+ f"videos/{CAMERA}/from_timestamp": 0.0,
249
+ }
250
+ record = EpisodeRecord(
251
+ 0,
252
+ "move the cup to the table",
253
+ tuple(i / 10 for i in range(40)),
254
+ tuple(range(40)),
255
+ tmp_path / "x",
256
+ 0,
257
+ 40,
258
+ )
259
+ return record, row, info
260
+
261
+
262
+ def annotate(tmp_path, episode, media, segments=2, staging="staging"):
263
+ from worker import annotate_episode
264
+
265
+ record, row, info = episode
266
+ manifest = {
267
+ "camera": CAMERA,
268
+ "features": ["Subtasks", studio.HUMAN_VIDEOS],
269
+ "vqa_hz": 0.1,
270
+ "prompts": {},
271
+ "human_video": {"segments_per_episode": segments, "profile": human_video_profile()},
272
+ }
273
+ return annotate_episode(
274
+ record, row, info, tmp_path, manifest, StubVlmClient(respond), tmp_path / staging, media
275
+ )
276
+
277
+
278
+ def test_episode_generates_videos_after_annotations_from_full_resolution_frames(tmp_path, episode):
279
+ media = FakeMedia()
280
+ result = annotate(tmp_path, episode, media)
281
+ assert result["status"] == "completed" and result["human_videos"] == {"completed": 2}
282
+ entries = result["human_video_entries"]
283
+ assert [e["subtask"] for e in entries] == ["grasp the cup", "place the cup on table"]
284
+ assert entries[1]["start_timestamp"] == 2.5 and entries[1]["end_timestamp"] == 3.9
285
+ # The edit uses the camera's full resolution, not the 224 px VLM thumbnails.
286
+ assert {size for size, _ in media.edits} == {(320, 240)}
287
+ for entry in entries:
288
+ assert set(entry["files"]) == {"robot_frame", "first_frame", "video"}
289
+ assert Path(entry["files"]["video"]).read_bytes().startswith(b"\x00\x00\x00\x18ftyp")
290
+ # Only language styles are staged for the parquet writer.
291
+ staged = EpisodeStaging(tmp_path / "staging", 0).read_all()
292
+ assert {r.get("style") for rows in staged.values() for r in rows} == {"subtask"}
293
+
294
+
295
+ def test_failed_video_is_recorded_and_budget_stop_keeps_annotations(tmp_path, episode):
296
+ result = annotate(tmp_path, episode, FakeMedia(fail_first=True))
297
+ assert sorted(e["status"] for e in result["human_video_entries"]) == ["completed", "failed"]
298
+
299
+ stopped = annotate(tmp_path, episode, FakeMedia(budget_after=0), segments=1, staging="second")
300
+ assert stopped["status"] == "completed"
301
+ assert stopped["human_videos"] == {"budget_stopped": 1}
302
+
303
+
304
+ def test_segment_cap_limits_videos_and_zero_means_every_subtask(tmp_path, episode):
305
+ media = FakeMedia()
306
+ result = annotate(tmp_path, episode, media, segments=1)
307
+ assert len(result["human_video_entries"]) == 1 and len(media.videos) == 1
308
+ every = annotate(tmp_path, episode, FakeMedia(), segments=0, staging="every")
309
+ assert len(every["human_video_entries"]) == 2
310
+
311
+
312
+ def test_parallel_setting_bounds_simultaneous_media_requests():
313
+ import threading
314
+
315
+ budget = Budget(5, 0.15, 0.5)
316
+ assert human_video.FalMedia("t", budget, lambda: 3600).concurrency == human_video_profile()["concurrency"]
317
+ assert human_video.FalMedia("t", budget, lambda: 3600, concurrency=64).concurrency == 32
318
+ media = human_video.FalMedia("t", budget, lambda: 3600, concurrency=3)
319
+ active, peak, lock = [0], [0], threading.Lock()
320
+
321
+ def request():
322
+ with media.semaphore:
323
+ with lock:
324
+ active[0] += 1
325
+ peak[0] = max(peak[0], active[0])
326
+ time.sleep(0.02)
327
+ with lock:
328
+ active[0] -= 1
329
+
330
+ threads = [threading.Thread(target=request) for _ in range(12)]
331
+ for t in threads:
332
+ t.start()
333
+ for t in threads:
334
+ t.join()
335
+ assert peak[0] == 3
336
+
337
+
338
+ def test_operations_upload_media_and_index(tmp_path):
339
+ from worker import HUMAN_VIDEO_DIR, human_video_operations
340
+
341
+ video = tmp_path / "segment_000.mp4"
342
+ video.write_bytes(b"video")
343
+ entries = [
344
+ {"source_episode_index": 7, "segment_index": 0, "status": "completed", "files": {"video": str(video)}}
345
+ ]
346
+ index = []
347
+ operations = human_video_operations(entries, index)
348
+ assert [op.path_in_repo for op in operations] == [
349
+ f"{HUMAN_VIDEO_DIR}/episode_000007/segment_000.mp4",
350
+ f"{HUMAN_VIDEO_DIR}/index.json",
351
+ ]
352
+ assert index == [
353
+ {
354
+ "source_episode_index": 7,
355
+ "segment_index": 0,
356
+ "status": "completed",
357
+ "video_path": f"{HUMAN_VIDEO_DIR}/episode_000007/segment_000.mp4",
358
+ }
359
+ ]
360
+
361
+
362
+ class FakeSession:
363
+ def __init__(self, statuses):
364
+ self.statuses, self.calls = list(statuses), []
365
+
366
+ def post(self, url, json=None, headers=None, timeout=None):
367
+ self.calls.append(("post", url, headers))
368
+ return SimpleNamespace(
369
+ status_code=200,
370
+ json=lambda: {
371
+ "request_id": "r1",
372
+ "status": "IN_QUEUE",
373
+ "response_url": "https://queue.fal.run/minimax/h3/requests/r1",
374
+ },
375
+ )
376
+
377
+ def get(self, url, headers=None, timeout=None):
378
+ self.calls.append(("get", url, headers))
379
+ if url.endswith("/status?_subdomain=queue"):
380
+ status = self.statuses.pop(0)
381
+ return SimpleNamespace(status_code=200, json=lambda: {"status": status})
382
+ if url.startswith("https://router.huggingface.co"):
383
+ return SimpleNamespace(
384
+ status_code=200, json=lambda: {"video": {"url": "https://cdn.fal.media/v.mp4"}}
385
+ )
386
+ return SimpleNamespace(status_code=200, content=b"mp4-bytes")
387
+
388
+
389
+ @pytest.fixture
390
+ def fal(monkeypatch):
391
+ import huggingface_hub.utils
392
+ from huggingface_hub.inference._providers.fal_ai import FalAIImageToVideoTask
393
+
394
+ seen = {}
395
+
396
+ def prepare_request(self, *, inputs, parameters, headers, model, api_key):
397
+ seen.update(task=self.task, parameters=parameters, model=model)
398
+ return SimpleNamespace(
399
+ url="https://router.huggingface.co/fal-ai/minimax/h3/image-to-video?_subdomain=queue",
400
+ json={"prompt": parameters["prompt"]},
401
+ headers={"authorization": f"Bearer {api_key}"},
402
+ )
403
+
404
+ monkeypatch.setattr(FalAIImageToVideoTask, "prepare_request", prepare_request)
405
+ monkeypatch.setattr(huggingface_hub.utils, "hf_raise_for_status", lambda response: None)
406
+
407
+ def use(session):
408
+ monkeypatch.setattr(huggingface_hub.utils, "get_session", lambda: session)
409
+
410
+ return seen, use
411
+
412
+
413
+ def test_video_polls_router_charges_quoted_price_and_never_sends_token_to_cdn(fal):
414
+ seen, use = fal
415
+ session = FakeSession(["IN_PROGRESS", "COMPLETED"])
416
+ use(session)
417
+ budget = Budget(5, 0.15, 0.5)
418
+ media = human_video.FalMedia("hf_token", budget, lambda: 3600, poll_interval=0)
419
+ assert media.image_to_video(Image.new("RGB", (64, 48)), "a prompt") == b"mp4-bytes"
420
+ assert seen["task"] == "image-text-to-video" and seen["model"] == "MiniMaxAI/MiniMax-H3"
421
+ assert seen["parameters"] == {"prompt": "a prompt", "resolution": "480P", "duration": 5}
422
+ status_url = "https://router.huggingface.co/fal-ai/minimax/h3/requests/r1/status?_subdomain=queue"
423
+ assert [c[1] for c in session.calls if c[0] == "get"][:2] == [status_url, status_url]
424
+ cdn = [c for c in session.calls if c[1].startswith("https://cdn.fal.media")]
425
+ assert cdn and cdn[0][2] is None
426
+ assert budget.summary()["media_usd"] == pytest.approx(human_video_profile()["video_usd"])
427
+
428
+
429
+ def test_video_is_not_started_without_runtime_for_it(fal):
430
+ _, use = fal
431
+ session = FakeSession([])
432
+ use(session)
433
+ budget = Budget(5, 0.15, 0.5)
434
+ media = human_video.FalMedia("hf_token", budget, lambda: 60, poll_interval=0)
435
+ with pytest.raises(DeadlineExceededError):
436
+ media.image_to_video(Image.new("RGB", (64, 48)), "a prompt")
437
+ assert session.calls == [] and budget.summary()["media_requests"] == 0
438
+
439
+
440
+ def test_media_endpoint_serves_only_generated_files(monkeypatch):
441
+ from fastapi.testclient import TestClient
442
+
443
+ import server
444
+
445
+ server.SESSIONS.clear()
446
+ client = TestClient(server.app)
447
+ sid_response = client.get("/api/session")
448
+ sid = client.cookies.get(server.COOKIE)
449
+ server.SESSIONS[sid].update(user={"name": "alice"}, token="secret-token")
450
+ assert sid_response.status_code == 200
451
+ reads = []
452
+ monkeypatch.setattr(server, "studio_output", lambda job_id, current: (None, "alice/out"))
453
+
454
+ def read(output, path, token):
455
+ reads.append((output, path, token))
456
+ if path.endswith("index.json"):
457
+ return json.dumps(
458
+ [
459
+ {
460
+ "source_episode_index": 3,
461
+ "episode_index": 0,
462
+ "segment_index": 0,
463
+ "status": "completed",
464
+ "subtask": "grasp",
465
+ "video_path": "annotation_studio/human_videos/episode_000003/segment_000.mp4",
466
+ "edit_prompt": "not exposed in the preview",
467
+ }
468
+ ]
469
+ ).encode()
470
+ return b"mp4"
471
+
472
+ monkeypatch.setattr(server, "read_output_file", read)
473
+ listed = client.get("/api/jobs/job1/human-videos").json()
474
+ assert listed[0]["video"] == (
475
+ "/api/jobs/job1/media?path=annotation_studio/human_videos/episode_000003/segment_000.mp4"
476
+ )
477
+ assert "edit_prompt" not in listed[0]
478
+ media = client.get(listed[0]["video"])
479
+ assert media.status_code == 200 and media.content == b"mp4"
480
+ assert media.headers["content-type"] == "video/mp4"
481
+ for bad in ("annotation_studio/run.json", "data/chunk-000/file-000.parquet", "../x.mp4"):
482
+ assert client.get(f"/api/jobs/job1/media?path={bad}").status_code == 400
483
+ assert "secret-token" not in json.dumps(listed)
484
+ anonymous = TestClient(server.app).get("/api/jobs/job1/media?path=" + listed[0]["video"].split("=")[1])
485
+ assert anonymous.status_code == 401
test_studio.py CHANGED
@@ -111,7 +111,7 @@ def test_real_video_pipeline_and_checkbox_filtering(tmp_path, features, custom):
111
  )
112
  staged = EpisodeStaging(tmp_path / "staging", 0).read_all()
113
  actual_styles = {r.get("style") for rows in staged.values() for r in rows}
114
- expected = {studio.FEATURES[f] for f in features}
115
  if "Interjections & speech" in features:
116
  expected.add(None)
117
  assert actual_styles == expected
 
111
  )
112
  staged = EpisodeStaging(tmp_path / "staging", 0).read_all()
113
  actual_styles = {r.get("style") for rows in staged.values() for r in rows}
114
+ expected = {studio.FEATURES[f] for f in features if f != studio.HUMAN_VIDEOS}
115
  if "Interjections & speech" in features:
116
  expected.add(None)
117
  assert actual_styles == expected
test_subset.py CHANGED
@@ -268,7 +268,7 @@ def test_worker_publishes_only_successful_subset_without_hub_duplication(
268
  monkeypatch.setattr(worker, "hf_hub_download", download)
269
  monkeypatch.setattr(worker, "Provider", lambda *args, **kw: SimpleNamespace(concurrency=2))
270
 
271
- def annotate(record, row, info, root, manifest, provider, staging):
272
  if record.episode_index == 1:
273
  raise ValueError("Simulated annotation validation failure")
274
  EpisodeStaging(staging, record.episode_index).write(
 
268
  monkeypatch.setattr(worker, "hf_hub_download", download)
269
  monkeypatch.setattr(worker, "Provider", lambda *args, **kw: SimpleNamespace(concurrency=2))
270
 
271
+ def annotate(record, row, info, root, manifest, provider, staging, media=None):
272
  if record.episode_index == 1:
273
  raise ValueError("Simulated annotation validation failure")
274
  EpisodeStaging(staging, record.episode_index).write(
worker.py CHANGED
@@ -15,9 +15,10 @@ from pathlib import Path
15
  import av
16
  import pyarrow.parquet as pq
17
  from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download, snapshot_download
 
18
  from prompt_config import validate_prompts
19
  from provider import Budget, BudgetExceededError, DeadlineExceededError, Provider
20
- from studio import COPY, FEATURES, read_selection, relative_path
21
  from subset import EpisodeSubset
22
 
23
  from lerobot.annotations.steerable_pipeline.config import InterjectionsConfig, PlanConfig, VqaConfig
@@ -127,9 +128,11 @@ class TunedPlan(plan_module.PlanSubtasksMemoryModule):
127
  class SourceFrames:
128
  """Exact timestamp seeks; a missing image fails the episode instead of generating from text."""
129
 
130
- def __init__(self, row, info, root, camera):
131
  self.row, self.info, self.root = row, info, root
132
  self.camera_key, self.camera_keys, self.cache = camera, [camera], {}
 
 
133
 
134
  def frames_at(self, record, timestamps, camera_key=None):
135
  camera = camera_key or self.camera_key
@@ -159,7 +162,8 @@ class SourceFrames:
159
  if closest is None or distance > 1 / fps + 1e-4:
160
  raise RuntimeError("Source video is missing an expected frame.")
161
  image = closest.to_image()
162
- image.thumbnail((224, 224))
 
163
  self.cache[idx] = image
164
  return [self.cache[i] for i in indices]
165
 
@@ -197,21 +201,29 @@ class PreservingWriter(LanguageColumnsWriter):
197
 
198
 
199
  def requested_styles(features):
200
- styles = {FEATURES[f] for f in features}
 
201
  if "Interjections & speech" in features:
202
  styles.add(None)
203
  return styles
204
 
205
 
206
- def annotate_episode(record, row, info, root, manifest, provider, staging_dir):
207
  context = CUSTOM_PROMPTS.set(validate_prompts(manifest.get("prompts", {}), manifest["features"]))
208
  try:
209
- return _annotate_episode(record, row, info, root, manifest, provider, staging_dir)
210
  finally:
211
  CUSTOM_PROMPTS.reset(context)
212
 
213
 
214
- def _annotate_episode(record, row, info, root, manifest, provider, staging_dir):
 
 
 
 
 
 
 
215
  frames = SourceFrames(row, info, root, manifest["camera"])
216
  features = manifest["features"]
217
  staging = EpisodeStaging(staging_dir, record.episode_index)
@@ -252,15 +264,62 @@ def _annotate_episode(record, row, info, root, manifest, provider, staging_dir):
252
  report = StagingValidator(dataset_camera_keys=(manifest["camera"],)).validate([record], staging_dir)
253
  if not report.ok:
254
  raise ValueError("Generated annotations did not pass pipeline validation.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  styles = requested_styles(features)
256
  for module_name, rows in staging.read_all().items():
257
  staging.write(module_name, [r for r in rows if r.get("style") in styles])
258
- return {
259
  "episode_index": record.episode_index,
260
  "status": "completed",
261
  "recovery_used": getattr(plan, "used_fallback", False),
262
  "rows": sum(len(rows) for rows in staging.read_all().values()),
263
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
 
265
 
266
  def create_output_repo(api, manifest):
@@ -286,6 +345,21 @@ def create_output_repo(api, manifest):
286
  f"at `{manifest['dataset_revision']}`. {description} "
287
  "See `annotation_studio/run.json` for completeness.\n"
288
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  api.upload_file(
290
  path_or_fileobj=card.encode(), path_in_repo="README.md", repo_id=output, repo_type="dataset"
291
  )
@@ -344,6 +418,18 @@ def run(manifest, work=Path("/workspace/run")):
344
  concurrency=manifest.get("request_concurrency", 2),
345
  generation_options=manifest.get("generation_options"),
346
  )
 
 
 
 
 
 
 
 
 
 
 
 
347
  progress = {
348
  "manifest": manifest,
349
  "pipeline": json.loads(Path(__file__).with_name("pipeline-version.json").read_text()),
@@ -435,6 +521,7 @@ def run(manifest, work=Path("/workspace/run")):
435
  manifest,
436
  provider,
437
  staging,
 
438
  ): record
439
  for record in records
440
  }
@@ -461,8 +548,15 @@ def run(manifest, work=Path("/workspace/run")):
461
  "status": "failed",
462
  "error_type": type(exc).__name__,
463
  }
 
464
  progress["episodes"].append(result)
465
- checkpoint()
 
 
 
 
 
 
466
  print(f"Saved episode {record.episode_index}: {result['status']}", flush=True)
467
  if completed:
468
  writer = PreservingWriter(
@@ -498,6 +592,11 @@ def run(manifest, work=Path("/workspace/run")):
498
  local_dir=root,
499
  )
500
  new_id, written = subset.add(record.episode_index, annotated_table)
 
 
 
 
 
501
  with commit_lock:
502
  previous_ids = list(progress["written_episode_ids"])
503
  previous_map = list(progress["source_episode_map"])
@@ -511,6 +610,7 @@ def run(manifest, work=Path("/workspace/run")):
511
  )
512
  for p in [*written, *attribution]
513
  ]
 
514
  )
515
  except Exception:
516
  progress["written_episode_ids"] = previous_ids
 
15
  import av
16
  import pyarrow.parquet as pq
17
  from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download, snapshot_download
18
+ from human_video import FalMedia, generate_human_videos
19
  from prompt_config import validate_prompts
20
  from provider import Budget, BudgetExceededError, DeadlineExceededError, Provider
21
+ from studio import COPY, FEATURES, HUMAN_VIDEOS, read_selection, relative_path
22
  from subset import EpisodeSubset
23
 
24
  from lerobot.annotations.steerable_pipeline.config import InterjectionsConfig, PlanConfig, VqaConfig
 
128
  class SourceFrames:
129
  """Exact timestamp seeks; a missing image fails the episode instead of generating from text."""
130
 
131
+ def __init__(self, row, info, root, camera, max_size=(224, 224)):
132
  self.row, self.info, self.root = row, info, root
133
  self.camera_key, self.camera_keys, self.cache = camera, [camera], {}
134
+ # None keeps full resolution (the first frame of a human demo video).
135
+ self.max_size = max_size
136
 
137
  def frames_at(self, record, timestamps, camera_key=None):
138
  camera = camera_key or self.camera_key
 
162
  if closest is None or distance > 1 / fps + 1e-4:
163
  raise RuntimeError("Source video is missing an expected frame.")
164
  image = closest.to_image()
165
+ if self.max_size:
166
+ image.thumbnail(self.max_size)
167
  self.cache[idx] = image
168
  return [self.cache[i] for i in indices]
169
 
 
201
 
202
 
203
  def requested_styles(features):
204
+ # Human demo videos are media files, not language rows.
205
+ styles = {FEATURES[f] for f in features if f != HUMAN_VIDEOS}
206
  if "Interjections & speech" in features:
207
  styles.add(None)
208
  return styles
209
 
210
 
211
+ def annotate_episode(record, row, info, root, manifest, provider, staging_dir, media=None):
212
  context = CUSTOM_PROMPTS.set(validate_prompts(manifest.get("prompts", {}), manifest["features"]))
213
  try:
214
+ return _annotate_episode(record, row, info, root, manifest, provider, staging_dir, media)
215
  finally:
216
  CUSTOM_PROMPTS.reset(context)
217
 
218
 
219
+ def human_video_summary(entries):
220
+ counts = defaultdict(int)
221
+ for entry in entries:
222
+ counts[entry["status"]] += 1
223
+ return dict(counts)
224
+
225
+
226
+ def _annotate_episode(record, row, info, root, manifest, provider, staging_dir, media=None):
227
  frames = SourceFrames(row, info, root, manifest["camera"])
228
  features = manifest["features"]
229
  staging = EpisodeStaging(staging_dir, record.episode_index)
 
264
  report = StagingValidator(dataset_camera_keys=(manifest["camera"],)).validate([record], staging_dir)
265
  if not report.ok:
266
  raise ValueError("Generated annotations did not pass pipeline validation.")
267
+ videos = None
268
+ if HUMAN_VIDEOS in features and media is not None:
269
+ # After validation, so no video is paid for on an episode whose annotations fail.
270
+ full_frames = SourceFrames(row, info, root, manifest["camera"], max_size=None)
271
+ try:
272
+ videos = generate_human_videos(
273
+ record,
274
+ staging,
275
+ provider,
276
+ frames,
277
+ full_frames,
278
+ media,
279
+ staging_dir.parent / "human_videos" / f"episode_{record.episode_index:06d}",
280
+ manifest["human_video"]["segments_per_episode"],
281
+ )
282
+ except BudgetExceededError as exc:
283
+ # The annotations are complete; only the videos stop.
284
+ status = "time_stopped" if isinstance(exc, DeadlineExceededError) else "budget_stopped"
285
+ videos = [{"source_episode_index": record.episode_index, "status": status, "files": {}}]
286
  styles = requested_styles(features)
287
  for module_name, rows in staging.read_all().items():
288
  staging.write(module_name, [r for r in rows if r.get("style") in styles])
289
+ result = {
290
  "episode_index": record.episode_index,
291
  "status": "completed",
292
  "recovery_used": getattr(plan, "used_fallback", False),
293
  "rows": sum(len(rows) for rows in staging.read_all().values()),
294
  }
295
+ if videos is not None:
296
+ result["human_videos"] = human_video_summary(videos)
297
+ result["human_video_entries"] = videos
298
+ return result
299
+
300
+
301
+ HUMAN_VIDEO_DIR = "annotation_studio/human_videos"
302
+
303
+
304
+ def human_video_operations(entries, index):
305
+ """Upload an episode's media and the updated index (source episode IDs until materialized)."""
306
+ operations = []
307
+ for entry in entries:
308
+ files = entry.pop("files", {})
309
+ folder = f"{HUMAN_VIDEO_DIR}/episode_{entry['source_episode_index']:06d}"
310
+ for kind, local in files.items():
311
+ name = f"{folder}/{Path(local).name}"
312
+ operations.append(CommitOperationAdd(path_in_repo=name, path_or_fileobj=local))
313
+ entry[f"{kind}_path"] = name
314
+ index.append(entry)
315
+ operations.append(human_video_index_operation(index))
316
+ return operations
317
+
318
+
319
+ def human_video_index_operation(index):
320
+ return CommitOperationAdd(
321
+ path_in_repo=f"{HUMAN_VIDEO_DIR}/index.json", path_or_fileobj=json.dumps(index, indent=2).encode()
322
+ )
323
 
324
 
325
  def create_output_repo(api, manifest):
 
345
  f"at `{manifest['dataset_revision']}`. {description} "
346
  "See `annotation_studio/run.json` for completeness.\n"
347
  )
348
+ if HUMAN_VIDEOS in manifest.get("features", ()):
349
+ profile = manifest["human_video"]["profile"]
350
+ card += (
351
+ "\n## Human demonstration videos\n\n"
352
+ "`annotation_studio/human_videos/` holds a generated first-person human video for "
353
+ + (
354
+ f"up to {manifest['human_video']['segments_per_episode']} subtasks"
355
+ if manifest["human_video"]["segments_per_episode"]
356
+ else "every subtask"
357
+ )
358
+ + " per episode: the robot frame, "
359
+ + f"the {profile['edit_model']} edit and a {profile['resolution']} {profile['video_model']} "
360
+ "video. `index.json` lists the subtask, timestamps, prompts and status of every segment. "
361
+ "These videos are synthetic.\n"
362
+ )
363
  api.upload_file(
364
  path_or_fileobj=card.encode(), path_in_repo="README.md", repo_id=output, repo_type="dataset"
365
  )
 
418
  concurrency=manifest.get("request_concurrency", 2),
419
  generation_options=manifest.get("generation_options"),
420
  )
421
+ media = (
422
+ FalMedia(
423
+ token,
424
+ budget,
425
+ provider.remaining_time,
426
+ manifest["human_video"]["profile"],
427
+ concurrency=manifest["human_video"].get("parallel"),
428
+ )
429
+ if HUMAN_VIDEOS in manifest["features"] and manifest.get("human_video")
430
+ else None
431
+ )
432
+ human_index = []
433
  progress = {
434
  "manifest": manifest,
435
  "pipeline": json.loads(Path(__file__).with_name("pipeline-version.json").read_text()),
 
521
  manifest,
522
  provider,
523
  staging,
524
+ media,
525
  ): record
526
  for record in records
527
  }
 
548
  "status": "failed",
549
  "error_type": type(exc).__name__,
550
  }
551
+ entries = result.pop("human_video_entries", None)
552
  progress["episodes"].append(result)
553
+ if entries:
554
+ local_files = [f for e in entries for f in e.get("files", {}).values()]
555
+ checkpoint(human_video_operations(entries, human_index))
556
+ for local in local_files:
557
+ Path(local).unlink(missing_ok=True)
558
+ else:
559
+ checkpoint()
560
  print(f"Saved episode {record.episode_index}: {result['status']}", flush=True)
561
  if completed:
562
  writer = PreservingWriter(
 
592
  local_dir=root,
593
  )
594
  new_id, written = subset.add(record.episode_index, annotated_table)
595
+ extra_operations = []
596
+ for entry in human_index:
597
+ if entry["source_episode_index"] == record.episode_index:
598
+ entry["episode_index"] = new_id
599
+ extra_operations = [human_video_index_operation(human_index)]
600
  with commit_lock:
601
  previous_ids = list(progress["written_episode_ids"])
602
  previous_map = list(progress["source_episode_map"])
 
610
  )
611
  for p in [*written, *attribution]
612
  ]
613
+ + extra_operations
614
  )
615
  except Exception:
616
  progress["written_episode_ids"] = previous_ids