> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bfl.ml/llms.txt
> Use this file to discover all available pages before exploring further.

# FLUX 3 Action

> 7B open weights world action model. Turn FLUX's visual intelligence into action for your robot, simulator, or game.

export const ActionExampleVideo = ({title, src}) => {
  const [width, setWidth] = useState(0);
  const [ratio, setRatio] = useState(16 / 9);
  const container = useRef(null);
  const video = useRef(null);
  useEffect(() => {
    const player = video.current;
    const observer = new ResizeObserver(([entry]) => {
      const nextWidth = entry.contentRect.width;
      setWidth(nextWidth);
      if (!nextWidth) player.pause();
    });
    observer.observe(container.current);
    return () => {
      observer.disconnect();
      player.pause();
    };
  }, []);
  return <div ref={container}>
      {}
      <video ref={video} controls muted playsInline preload="metadata" aria-label={title} src={src} style={{
    width: '100%',
    display: 'block',
    objectFit: 'contain',
    objectPosition: 'top',
    background: '#000',
    borderRadius: 8,
    margin: 0,
    height: width ? width / ratio + 80 : undefined
  }} onLoadedMetadata={event => {
    const player = event.currentTarget;
    setRatio(player.videoWidth / player.videoHeight);
  }} />
    </div>;
};

export const ActionArmSim = () => {
  const BG = "var(--arm-scene)";
  const TILE = ["var(--arm-tile)", "var(--arm-tile-alt)"];
  const CLAY = "#9B7563";
  const STEEL = "#A6ACB5";
  const INK = "#13251D";
  const GLOW = "var(--arm-glow)";
  const TEXT = "var(--arm-text)";
  const SAGE = "#486A58";
  const CREAM = "#F6F1E7";
  const ACCENT = "var(--arm-accent)";
  const ON_ACCENT = "var(--arm-on-accent)";
  const MUTED = "var(--arm-muted)";
  const LOGO = "M0 70.5 L50 0 L100 70.5 Z M16.8 61.8 L50 15.1 L60.6 30 L50 30 L27.5 61.8 Z M38.2 61.8 L60.6 30 L71.3 30 L48.8 61.8 Z M59.5 70.5 L59.5 61.8 L71.3 45 L83.2 61.8 L83.2 70.5 Z";
  const CUBES = {
    red: {
      fill: "#D94A3D",
      bin: "left"
    },
    yellow: {
      fill: "#F2C233",
      bin: "left"
    },
    blue: {
      fill: "#3D7BD9",
      bin: "right"
    },
    green: {
      fill: "#3FB86B",
      bin: "right"
    }
  };
  const BINS = {
    left: {
      x: 32,
      y: 60,
      color: "#668873"
    },
    right: {
      x: 78,
      y: 14,
      color: CLAY
    }
  };
  const BIN = 32;
  const RIM = 4;
  const BIN_H = 5;
  const BIN_FLOOR = 2;
  const slot = (b, i) => ({
    x: b.x + (i % 2 ? 7 : -7),
    y: b.y + (i % 2 ? -7 : 7),
    z: BIN_FLOOR + Math.floor(i / 2) * CUBE
  });
  const inBin = (cubes, k) => Object.values(cubes).filter(o => o.inBin === k).length;
  const depthOrder = items => {
    const pending = [...items].sort((a, b) => a.bounds.x[0] + a.bounds.y[0] + a.bounds.z[0] - b.bounds.x[0] - b.bounds.y[0] - b.bounds.z[0]);
    const behind = (a, b) => {
      const before = ["x", "y", "z"].some(axis => a.bounds[axis][1] <= b.bounds[axis][0]);
      const after = ["x", "y", "z"].some(axis => b.bounds[axis][1] <= a.bounds[axis][0]);
      return before && !after;
    };
    const ordered = [];
    while (pending.length) {
      const next = pending.findIndex(item => !pending.some(other => other !== item && behind(other, item)));
      ordered.push(pending.splice(next < 0 ? 0 : next, 1)[0]);
    }
    return ordered;
  };
  const cubeBounds = c => ({
    x: [c.x - CUBE / 2, c.x + CUBE / 2],
    y: [c.y - CUBE / 2, c.y + CUBE / 2],
    z: [c.z, c.z + CUBE]
  });
  const U = 4;
  const W = 272;
  const H = 212;
  const CX = 136;
  const CY = 52;
  const CUBE = 10;
  const BASE = {
    x: 28,
    y: 114,
    z: 20
  };
  const BASE_W = 24;
  const L1 = 62;
  const L2 = 58;
  const L3 = 14;
  const HOVER = 42;
  const GRASP = 6;
  const OPEN = 30;
  const CLOSED = 4;
  const PLAN = 32;
  const TICK = 110;
  const OBS_HOLD = 8;
  const PLAN_HOLD = 12;
  const JOINTS = ["shoulder_pan", "shoulder_lift", "elbow_flex", "wrist_flex", "wrist_roll", "gripper"];
  const PLAIN = ["Base turn", "Shoulder", "Elbow", "Wrist tilt", "Wrist twist", "Gripper"];
  const snap = v => Math.round(v / U) * U;
  const deg = 180 / Math.PI;
  const rad = Math.PI / 180;
  const Pr = (x, y, z) => ({
    sx: (x - y) * 0.866 + CX,
    sy: (x + y) * 0.5 - z + CY
  });
  const P = Pr;
  const ik = (tx, ty, tz, grip) => {
    const dx = tx - BASE.x;
    const dy = ty - BASE.y;
    const pan = Math.atan2(dy, dx);
    const r = Math.hypot(dx, dy);
    const h = tz + L3 - BASE.z;
    const d = Math.min(Math.hypot(r, h), L1 + L2 - 0.01);
    const e = Math.acos(Math.max(-1, Math.min(1, (d * d - L1 * L1 - L2 * L2) / (2 * L1 * L2))));
    const base = Math.atan2(h, r);
    const pick = [e, -e].map(t2 => {
      const t1 = base - Math.atan2(L2 * Math.sin(t2), L1 + L2 * Math.cos(t2));
      return {
        t1,
        t2,
        ez: Math.sin(t1)
      };
    });
    const c = pick[0].ez > pick[1].ez ? pick[0] : pick[1];
    return [pan * deg, c.t1 * deg, c.t2 * deg, -90 - (c.t1 + c.t2) * deg, 0, grip];
  };
  const fk = q => {
    const pan = q[0] * rad;
    const t1 = q[1] * rad;
    const t12 = t1 + q[2] * rad;
    const a = t12 + q[3] * rad;
    const dir = {
      x: Math.cos(pan),
      y: Math.sin(pan)
    };
    const at = (o, len, ang) => ({
      x: o.x + dir.x * len * Math.cos(ang),
      y: o.y + dir.y * len * Math.cos(ang),
      z: o.z + len * Math.sin(ang)
    });
    const elbow = at(BASE, L1, t1);
    const wrist = at(elbow, L2, t12);
    const tip = at(wrist, L3, a);
    return {
      elbow,
      wrist,
      tip,
      dir
    };
  };
  const HOME = ik(120, 124, HOVER, OPEN);
  const near = (a, b) => a.every((v, i) => Math.abs(v - b[i]) < 1);
  const scatter = () => {
    const cells = [];
    [80, 100, 120].forEach(x => [82, 102, 122].forEach(y => cells.push({
      x,
      y
    })));
    const out = {};
    Object.keys(CUBES).forEach(k => {
      const c = cells.splice(Math.floor(Math.random() * cells.length), 1)[0];
      out[k] = {
        x: c.x + snap(Math.random() * 8 - 4),
        y: c.y + snap(Math.random() * 8 - 4),
        z: 0,
        inBin: false
      };
    });
    return out;
  };
  const seg = (from, to, n, holding) => Array.from({
    length: n
  }, (_, i) => ({
    q: from.map((v, k) => v + (to[k] - v) * ((i + 1) / n)),
    holding
  }));
  const trajectory = s => {
    const targets = (s.target === "all" ? Object.keys(CUBES) : [s.target]).filter(k => !s.cubes[k].inBin);
    if (s.holding && !targets.includes(s.holding)) targets.unshift(s.holding);
    let q = s.q;
    const rows = [];
    const placed = {
      left: inBin(s.cubes, "left"),
      right: inBin(s.cubes, "right")
    };
    targets.forEach(k => {
      const c = s.cubes[k];
      if (s.holding !== k) {
        const above = ik(c.x, c.y, HOVER, OPEN);
        const down = ik(c.x, c.y, GRASP, OPEN);
        const shut = ik(c.x, c.y, GRASP, CLOSED);
        const up = ik(c.x, c.y, HOVER, CLOSED);
        rows.push(...seg(q, above, 8, null), ...seg(above, down, 5, null), ...seg(down, shut, 3, k), ...seg(shut, up, 5, k));
        q = up;
      }
      const b = BINS[s.target === "all" ? CUBES[k].bin : s.bin];
      const binKey = s.target === "all" ? CUBES[k].bin : s.bin;
      const sl = slot(b, placed[binKey]++);
      const aboveBin = ik(sl.x, sl.y, HOVER, CLOSED);
      const lower = ik(sl.x, sl.y, sl.z + GRASP + 4, CLOSED);
      const release = ik(sl.x, sl.y, sl.z + GRASP + 4, OPEN);
      const upBin = ik(sl.x, sl.y, HOVER, OPEN);
      rows.push(...seg(q, aboveBin, 10, k), ...seg(aboveBin, lower, 4, k), ...seg(lower, release, 3, null), ...seg(release, upBin, 4, null));
      q = upBin;
    });
    if (rows.length || !near(q, HOME)) rows.push(...seg(q, HOME, 8, null));
    return rows;
  };
  const pad = rows => Array.from({
    length: PLAN
  }, (_, i) => rows[Math.min(i, rows.length - 1)]);
  const apply = (s, row, drop) => {
    const tip = fk(row.q).tip;
    let cubes = s.cubes;
    let holding = row.holding === s.slipKey ? null : row.holding;
    if (s.holding && !holding) {
      const c = s.cubes[s.holding];
      const hit = Object.entries(BINS).find(([, bb]) => Math.hypot(bb.x - c.x, bb.y - c.y) < 14);
      cubes = {
        ...cubes,
        [s.holding]: hit ? {
          ...slot(hit[1], inBin(s.cubes, hit[0])),
          inBin: hit[0]
        } : {
          ...c,
          z: 0
        }
      };
    }
    if (holding && drop) {
      let {x, y} = tip;
      Object.values(BINS).forEach(b => {
        if (Math.hypot(b.x - x, b.y - y) < 20) {
          x = b.x - 24;
          y = b.y + 24;
        }
      });
      cubes = {
        ...cubes,
        [holding]: {
          x: snap(x),
          y: snap(y),
          z: 0,
          inBin: false
        }
      };
      holding = null;
    } else if (holding) {
      cubes = {
        ...cubes,
        [holding]: {
          x: tip.x,
          y: tip.y,
          z: Math.max(0, tip.z - GRASP),
          inBin: false
        }
      };
    }
    return {
      ...s,
      q: row.q,
      cubes,
      holding
    };
  };
  const snapshot = st => ({
    q: st.q,
    cubes: st.cubes,
    holding: st.holding,
    slipKey: null
  });
  const predictFrames = (st, plan) => {
    let predicted = snapshot(st);
    const frames = [predicted];
    plan.forEach(row => {
      predicted = snapshot(apply(predicted, row, false));
      frames.push(predicted);
    });
    return frames;
  };
  const jointValue = (q, j) => j === 5 ? Math.max(0, Math.min(100, (q[j] - CLOSED) / (OPEN - CLOSED) * 100)) : q[j];
  const [n, setN] = useState(32);
  const [inspection, setInspection] = useState(null);
  const [detailsOpen, setDetailsOpen] = useState(false);
  const [s, setS] = useState(() => ({
    phase: "idle",
    q: HOME,
    cubes: scatter(),
    holding: null,
    target: "all",
    bin: "left",
    plan: null,
    step: 0,
    executed: 0,
    hold: 0,
    plans: 0,
    dropAt: -1,
    slipped: false,
    slipKey: null,
    frames: [],
    stale: false,
    route: null,
    observation: null,
    paused: false,
    moves: 0
  }));
  const initial = useRef(s.cubes);
  const root = useRef(null);
  const [narrow, setNarrow] = useState(false);
  useEffect(() => {
    if (!root.current || typeof ResizeObserver === "undefined") return undefined;
    const ro = new ResizeObserver(es => setNarrow(es[0].contentRect.width < 640));
    ro.observe(root.current);
    return () => ro.disconnect();
  }, []);
  const busy = s.phase === "observe" || s.phase === "plan" || s.phase === "execute";
  const reduced = () => typeof window !== "undefined" && window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const advance = st => {
    if (st.phase === "observe") {
      if (st.hold < OBS_HOLD) return {
        ...st,
        hold: st.hold + 1
      };
      const rows = st.route === null ? trajectory(st) : st.route;
      if (!rows.length) return {
        ...st,
        phase: "done",
        stale: false
      };
      const plan = pad(rows);
      const frames = predictFrames(st, plan);
      return {
        ...st,
        phase: "plan",
        hold: 0,
        plan,
        planSteps: n,
        route: rows,
        plans: st.plans + 1,
        step: 0,
        executed: 0,
        dropAt: -1,
        stale: false,
        frames
      };
    }
    if (st.phase === "plan") {
      if (st.hold < PLAN_HOLD) return {
        ...st,
        hold: st.hold + 1
      };
      return {
        ...st,
        phase: "execute",
        hold: 0,
        step: 1
      };
    }
    if (st.phase === "execute") {
      const drop = st.step - 1 === st.dropAt;
      const next = apply(st, st.plan[st.step - 1], drop);
      next.moves = st.moves + 1;
      next.executed = st.step;
      if (drop) {
        next.slipped = true;
        next.slipKey = st.plan[st.step - 1].holding;
      }
      if (st.step < n) return {
        ...next,
        step: st.step + 1
      };
      const observed = {
        q: next.q,
        cubes: next.cubes,
        holding: next.holding
      };
      return {
        ...next,
        phase: "observe",
        hold: 0,
        stale: true,
        slipKey: null,
        observation: observed,
        route: next.slipKey ? null : st.route.slice(n)
      };
    }
    return st;
  };
  useEffect(() => {
    if (!busy || s.paused) return undefined;
    const t = setTimeout(() => setS(advance), TICK);
    return () => clearTimeout(t);
  }, [s, n]);
  const play = () => {
    const fresh = {
      ...s,
      plan: null,
      route: null,
      frames: [],
      step: 0,
      executed: 0,
      hold: 0,
      dropAt: -1,
      slipped: false,
      slipKey: null,
      stale: false,
      paused: false
    };
    fresh.observation = {
      q: fresh.q,
      cubes: fresh.cubes,
      holding: null
    };
    if (reduced()) {
      let st = fresh;
      const rows = trajectory(st);
      for (let offset = 0; offset < rows.length; offset += n) {
        const plan = pad(rows.slice(offset));
        const frames = predictFrames(st, plan);
        plan.slice(0, n).forEach(row => {
          st = apply(st, row, false);
        });
        st = {
          ...st,
          plan,
          planSteps: n,
          frames,
          executed: n
        };
      }
      setS({
        ...st,
        phase: "done",
        moves: fresh.moves + Math.ceil(rows.length / n) * n,
        plans: fresh.plans + Math.ceil(rows.length / n),
        observation: snapshot(st)
      });
      return;
    }
    setS({
      ...fresh,
      phase: "observe"
    });
  };
  const reset = (shuffle = false) => {
    if (shuffle) initial.current = scatter();
    setS(st => ({
      ...st,
      phase: "idle",
      q: HOME,
      cubes: initial.current,
      holding: null,
      plan: null,
      route: null,
      observation: null,
      frames: [],
      step: 0,
      executed: 0,
      hold: 0,
      plans: 0,
      moves: 0,
      dropAt: -1,
      slipped: false,
      slipKey: null,
      stale: false,
      paused: false
    }));
  };
  const pts = (...ps) => ps.map(q => `${q.sx},${q.sy}`).join(" ");
  const foot = (x, y, z, r) => pts(P(x - r, y - r, z), P(x + r, y - r, z), P(x + r, y + r, z), P(x - r, y + r, z));
  const box = (x, y, z, w, d, h, top, left, right, key) => {
    const a = P(x, y, z + h), b = P(x + w, y, z + h), c = P(x + w, y + d, z + h), dd = P(x, y + d, z + h);
    const b0 = P(x + w, y, z), c0 = P(x + w, y + d, z), d0 = P(x, y + d, z);
    return <g key={key} stroke={INK} strokeWidth={1}>
        <polygon points={pts(a, b, c, dd)} fill={top} />
        <polygon points={pts(dd, c, c0, d0)} fill={left} />
        <polygon points={pts(c, b, b0, c0)} fill={right} />
      </g>;
  };
  const binBack = (k, b) => {
    const x0 = b.x - BIN / 2, y0 = b.y - BIN / 2;
    return <g key={k} stroke={INK} strokeWidth={1}>
        <polygon points={foot(b.x, b.y, BIN_FLOOR, BIN / 2)} fill="var(--arm-bin-floor)" />
        <polygon points={pts(P(x0, y0, BIN_FLOOR), P(x0, y0 + BIN, BIN_FLOOR), P(x0, y0 + BIN, BIN_H), P(x0, y0, BIN_H))} fill={shade(STEEL, 0.55)} />
        <polygon points={pts(P(x0, y0, BIN_FLOOR), P(x0 + BIN, y0, BIN_FLOOR), P(x0 + BIN, y0, BIN_H), P(x0, y0, BIN_H))} fill={shade(STEEL, 0.75)} />
        {box(x0, y0, BIN_H, BIN, RIM, 2, b.color, shade(b.color, 0.65), shade(b.color, 0.65), "n")}
        {box(x0, y0, BIN_H, RIM, BIN, 2, b.color, shade(b.color, 0.65), shade(b.color, 0.65), "w")}
      </g>;
  };
  const binFront = (k, b) => {
    const x0 = b.x - BIN / 2, y0 = b.y - BIN / 2;
    return <g key={k}>
        {box(x0, y0 + BIN - RIM, 0, BIN, RIM, BIN_H + 2, b.color, shade(b.color, 0.65), shade(b.color, 0.65), "s")}
        {box(x0 + BIN - RIM, y0, 0, RIM, BIN, BIN_H + 2, b.color, shade(b.color, 0.65), shade(b.color, 0.8), "e")}
        <text transform={`matrix(0.866 0.5 0 1 ${P(b.x, y0 + BIN, 1.3).sx} ${P(b.x, y0 + BIN, 1.3).sy})`} textAnchor="middle" fill={CREAM} fontSize={4.5} fontWeight={600} fontFamily="ui-monospace, monospace">{k.toUpperCase()}</text>
      </g>;
  };
  const decal = (x, y, z, size, plane, fill, opacity) => {
    const o = Pr(x, y, z);
    const k = size / 100;
    const m = plane === "floor" ? [0.866 * k, 0.5 * k, -0.866 * k, 0.5 * k] : [-0.866 * k, 0.5 * k, 0, k];
    return <path d={LOGO} fillRule="evenodd" fill={fill} opacity={opacity} transform={`matrix(${m.join(" ")} ${o.sx} ${o.sy})`} />;
  };
  const shade = (hex, f) => {
    const v = parseInt(hex.slice(1), 16);
    const ch = sh => Math.max(0, Math.min(255, Math.round((v >> sh & 255) * f)));
    return `rgb(${ch(16)},${ch(8)},${ch(0)})`;
  };
  const link = (a, b, size, fill) => {
    const start = P(a.x, a.y, a.z), end = P(b.x, b.y, b.z);
    return <line x1={start.sx} y1={start.sy} x2={end.sx} y2={end.sy} stroke={fill} strokeWidth={size} strokeLinecap="round" />;
  };
  const scene = (st, glowKey) => {
    const {elbow, wrist, tip, dir} = fk(st.q);
    const jaw = 5 + (st.q[5] - CLOSED) / (OPEN - CLOSED) * 8;
    const perp = {
      x: -dir.y,
      y: dir.x
    };
    const cubes = Object.entries(st.cubes).map(([k, c]) => ({
      key: k,
      bounds: cubeBounds(c),
      bin: c.inBin,
      el: <g key={k} data-cube={k}>
        {box(c.x - CUBE / 2, c.y - CUBE / 2, c.z, CUBE, CUBE, CUBE, shade(CUBES[k].fill, 1.15), shade(CUBES[k].fill, 0.65), shade(CUBES[k].fill, 0.88), k)}
      </g>
    }));
    const objects = Object.entries(BINS).map(([k, b]) => {
      const contents = depthOrder(cubes.filter(c => c.bin === k));
      return {
        key: k,
        bounds: {
          x: [b.x - BIN / 2, b.x + BIN / 2],
          y: [b.y - BIN / 2, b.y + BIN / 2],
          z: [0, Math.max(BIN_H + 2, ...contents.map(c => c.bounds.z[1]))]
        },
        el: <g key={k} data-bin={k}>{binBack(k, b)}{contents.map(c => c.el)}{binFront(k, b)}</g>
      };
    });
    objects.push({
      key: "base",
      bounds: {
        x: [BASE.x - BASE_W / 2, BASE.x + BASE_W / 2],
        y: [BASE.y - BASE_W / 2, BASE.y + BASE_W / 2],
        z: [0, BASE.z - 4]
      },
      el: <g key="base">
        {box(BASE.x - BASE_W / 2, BASE.y - BASE_W / 2, 0, BASE_W, BASE_W, BASE.z - 4, SAGE, shade(SAGE, 0.6), shade(SAGE, 0.8), "base")}
        {decal(BASE.x + BASE_W / 2, BASE.y - 7, BASE.z - 6, 14, "wall", CREAM, 1)}
      </g>
    }, ...cubes.filter(c => !c.bin));
    const tiles = [];
    for (let i = 0; i < 5; i++) for (let j = 0; j < 5; j++) {
      const p = [P(i * 30, j * 30, 0), P(i * 30 + 30, j * 30, 0), P(i * 30 + 30, j * 30 + 30, 0), P(i * 30, j * 30 + 30, 0)];
      tiles.push(<polygon key={`${i}${j}`} points={p.map(q => `${q.sx},${q.sy}`).join(" ")} fill={TILE[(i + j) % 2]} stroke="var(--arm-grid)" strokeWidth={0.5} />);
    }
    return <g>
        {box(0, 0, -5, 150, 150, 5, TILE[0], "var(--arm-table-edge)", "var(--arm-table-side)", "table")}
        {tiles}
        {Object.entries(st.cubes).filter(([, c]) => !c.inBin).map(([k, c]) => <g key={`shadow-${k}`}>
          {(glowKey === k && !st.holding || st.slipKey === k) && <polygon points={foot(c.x, c.y, 0, CUBE / 2 + 5)} fill={GLOW} />}
          <polygon points={foot(c.x + 2, c.y + 2, 0, CUBE / 2)} fill="var(--arm-shadow)" />
        </g>)}
        {depthOrder(objects).map(object => object.el)}
        {box(BASE.x - 7, BASE.y - 7, BASE.z - 4, 14, 14, 4, shade(SAGE, 1.2), shade(SAGE, 0.7), shade(SAGE, 0.9), "turret")}
        {link(BASE, elbow, 14, INK)}
        {link(BASE, elbow, 10, CREAM)}
        {link(BASE, elbow, 4, shade(CREAM, 0.8))}
        {link(elbow, wrist, 14, INK)}
        {link(elbow, wrist, 10, SAGE)}
        {link(elbow, wrist, 4, shade(SAGE, 1.3))}
        {link(wrist, tip, 10, INK)}
        {link(wrist, tip, 6, CREAM)}
        {[BASE, elbow, wrist].map((j, i) => {
      const p = P(j.x, j.y, j.z), r = i === 2 ? 6 : 8;
      return <g key={i}><circle cx={p.sx} cy={p.sy} r={r} fill={SAGE} stroke={INK} strokeWidth={2} /><circle cx={p.sx} cy={p.sy} r={r - 3} fill={CREAM} /><circle cx={p.sx} cy={p.sy} r={1.5} fill={STEEL} /></g>;
    })}
        {link({
      x: tip.x - perp.x * jaw,
      y: tip.y - perp.y * jaw,
      z: tip.z + 7
    }, {
      x: tip.x + perp.x * jaw,
      y: tip.y + perp.y * jaw,
      z: tip.z + 7
    }, 5, STEEL)}
        {[1, -1].map(sgn => <g key={sgn}>{link({
      x: tip.x + perp.x * jaw * sgn,
      y: tip.y + perp.y * jaw * sgn,
      z: tip.z + 7
    }, {
      x: tip.x + perp.x * jaw * sgn,
      y: tip.y + perp.y * jaw * sgn,
      z: tip.z - 3
    }, 6, INK)}{link({
      x: tip.x + perp.x * jaw * sgn,
      y: tip.y + perp.y * jaw * sgn,
      z: tip.z + 7
    }, {
      x: tip.x + perp.x * jaw * sgn,
      y: tip.y + perp.y * jaw * sgn,
      z: tip.z - 3
    }, 3, STEEL)}</g>)}
      </g>;
  };
  const nextCube = (() => {
    if (s.holding) return s.holding;
    const rows = s.plan ? s.plan : [];
    const first = rows.find(r => r.holding);
    return first ? first.holding : s.target === "all" ? Object.keys(CUBES).find(k => !s.cubes[k].inBin) : s.target;
  })();
  const hasPlan = !!s.plan && s.frames.length === PLAN + 1;
  const planUsed = hasPlan ? s.planSteps || n : n;
  const inspecting = hasPlan && inspection && inspection.plan === s.plan;
  const planStep = hasPlan ? inspecting ? inspection.step : s.executed || 0 : 0;
  const observation = hasPlan ? s.frames[0] : s.observation || s;
  const predicted = hasPlan ? s.frames[planStep] : null;
  const moveDescription = (() => {
    if (!predicted || planStep === 0) return "Starting pose";
    const previous = s.frames[planStep - 1];
    const gripChange = predicted.q[5] - previous.q[5];
    if (gripChange < -0.1) return "Closing the gripper";
    if (gripChange > 0.1) return "Opening the gripper";
    if (predicted.q.every((value, j) => Math.abs(value - previous.q[j]) < 0.01)) return "Holding position";
    if (predicted.holding) return `Carrying the ${predicted.holding} cube`;
    const heightChange = fk(predicted.q).tip.z - fk(previous.q).tip.z;
    return heightChange > 0.5 ? "Lifting the arm" : heightChange < -0.5 ? "Lowering the arm" : "Moving into position";
  })();
  const tipObserved = fk(observation.q).tip;
  const tp = P(tipObserved.x, tipObserved.y, tipObserved.z);
  const targets = Object.keys(CUBES);
  const placed = targets.filter(k => s.cubes[k].inBin).length;
  const instruction = s.target === "all" ? "Sort all four cubes" : `Move the ${s.target} cube to the ${s.bin} bin`;
  const status = s.paused ? "Paused. Resume to continue this plan." : s.phase === "idle" ? "Choose a mission, then start the arm." : s.phase === "observe" ? s.slipped && s.stale && s.dropAt >= 0 ? "The new observation shows the dropped cube. Replanning." : "Looking at the cameras and the arm's position." : s.phase === "plan" ? "Predicting 32 moves and their future camera images together." : s.phase === "execute" ? s.slipKey ? "The cube fell. The arm is still following the old plan." : `Move ${s.step} of ${n}, then a new camera observation.` : placed === 4 ? "Mission complete. All four cubes delivered. Reset the layout to play again." : `Instruction complete. ${placed} of ${targets.length} cubes delivered. Choose another cube to continue.`;
  const btn = (active, disabled, extra) => ({
    minHeight: 44,
    minWidth: 44,
    padding: "0.55rem 0.8rem",
    border: `1px solid ${active ? ACCENT : "var(--arm-border)"}`,
    borderRadius: 6,
    background: active ? ACCENT : "var(--arm-control)",
    color: active ? ON_ACCENT : TEXT,
    fontSize: "0.85rem",
    lineHeight: 1.3,
    cursor: disabled ? "not-allowed" : "pointer",
    opacity: disabled ? 0.45 : 1,
    display: "inline-flex",
    alignItems: "center",
    justifyContent: "center",
    gap: "0.4rem",
    ...extra
  });
  const label = {
    fontSize: "0.75rem",
    color: MUTED,
    margin: 0,
    lineHeight: 1.5
  };
  const cam = (children, vb, aria, extra) => <svg viewBox={vb} role="img" aria-label={aria} shapeRendering="geometricPrecision" style={{
    width: "100%",
    display: "block",
    background: BG,
    ...extra
  }}>{children}</svg>;
  const choose = (target, bin = s.bin) => {
    setS(st => ({
      ...st,
      target,
      bin,
      phase: "idle",
      plan: null,
      route: null,
      frames: [],
      step: 0,
      executed: 0,
      hold: 0,
      observation: null,
      dropAt: -1,
      slipped: false,
      slipKey: null,
      stale: false,
      paused: false
    }));
  };
  const canDrop = s.phase === "execute" && !s.paused && !!s.holding && !s.slipKey && !!s.plan[s.step - 1].holding;
  const darkTheme = `color-scheme: dark; --arm-surface: #0E1D16; --arm-panel: #13251D; --arm-scene: #182D23;
    --arm-text: #F6F1E7; --arm-muted: #B1C1B6; --arm-accent: #B9D7A2; --arm-on-accent: #13251D;
    --arm-border: #3C5546; --arm-control: #20392C; --arm-complete: #789181;
    --arm-tile: #304B3A; --arm-tile-alt: #35523F; --arm-grid: #536D5A;
    --arm-table-edge: #12271C; --arm-table-side: #203A2A; --arm-bin-floor: #15231B;
    --arm-bin-left: #A3C6AE; --arm-bin-right: #D2AE9C; --arm-shadow: rgba(0,0,0,0.3);
    --arm-glow: rgba(185,215,162,0.3); --arm-pulse: rgba(185,215,162,0.45);`;
  return <section ref={root} className="action-arm-game" aria-label="Robot sorting game" data-phase={s.phase} data-plan={s.plans} data-executed={s.executed || 0} data-placed={placed} data-moves={s.moves} style={{
    margin: "1.5rem 0",
    border: "1px solid var(--arm-border)",
    borderRadius: 12,
    overflow: "hidden",
    background: "var(--arm-surface)",
    color: TEXT,
    fontFamily: "inherit"
  }}>
      <style>{`.action-arm-game {
          color-scheme: light; --arm-surface: #FAF8F1; --arm-panel: #E9EDE2; --arm-scene: #E2E8DB;
          --arm-text: #13251D; --arm-muted: #526557; --arm-accent: #486A58; --arm-on-accent: #F6F1E7;
          --arm-border: #BCCABD; --arm-control: #F3F1E6; --arm-complete: #788B7B;
          --arm-tile: #BCCCB4; --arm-tile-alt: #C7D4C0; --arm-grid: #98AD94;
          --arm-table-edge: #81987A; --arm-table-side: #9AAF91; --arm-bin-floor: #63735D;
          --arm-bin-left: #3D634B; --arm-bin-right: #80533E; --arm-shadow: rgba(19,37,29,0.18);
          --arm-glow: rgba(72,106,88,0.22); --arm-pulse: rgba(72,106,88,0.4);
        }
        .dark .action-arm-game, [data-theme="dark"] .action-arm-game { ${darkTheme} }
        @media (prefers-color-scheme: dark) {
          :root:not(.light):not(.dark):not([data-theme]) .action-arm-game { ${darkTheme} }
        }
        .action-arm-game button:focus-visible,.action-arm-game summary:focus-visible,.action-arm-game input:focus-visible { outline: 3px solid var(--arm-accent); outline-offset: 3px; }
        .action-arm-game button:not(:disabled):hover { filter: brightness(1.15); }
        .action-arm-game button:not([role="switch"]):not(:disabled):active { transform: translateY(1px); }
        @keyframes arm-drop-invite {
          0%, 100% { transform: rotate(0deg); box-shadow: 0 0 0 0 var(--arm-pulse); }
          12% { transform: rotate(-3deg); }
          24% { transform: rotate(3deg); }
          36% { transform: rotate(-2deg); }
          48% { transform: rotate(0deg); box-shadow: 0 0 0 9px transparent; }
          70% { box-shadow: 0 0 0 9px transparent; }
        }
        .action-arm-game .arm-drop-button:not(:disabled) { animation: arm-drop-invite 1.8s ease-in-out infinite; }
        .action-arm-game .arm-drop-button:not(:disabled):active { animation: none; transform: scale(0.96); }
        @media (prefers-reduced-motion: reduce) {
          .action-arm-game .arm-drop-button:not(:disabled) { animation: none; }
          .action-arm-game .arm-drop-button:not(:disabled):active { transform: none; }
        }
        .action-arm-game summary { cursor: pointer; min-height: 44px; padding: 10px 0; }
        .action-arm-game p { margin: 0; }
        .action-arm-game svg { margin: 0; }
        .action-arm-game button { font-family: inherit; }
        .action-arm-game .arm-control-group { min-width:0; padding:0; margin:0; border:0; border-bottom:0; }
        .action-arm-game .arm-control-group legend { padding:0; margin-bottom:8px; font-size:0.85rem; font-weight:600; }

      `}</style>
      <div style={{
    padding: "0.65rem 1rem",
    borderBottom: "1px solid var(--arm-border)",
    display: "flex",
    alignItems: "center",
    justifyContent: "space-between",
    gap: "0.5rem",
    flexWrap: "wrap"
  }}>
        <div style={{
    flex: "1 1 360px",
    minWidth: 0
  }}>
          <div style={{
    ...label,
    letterSpacing: "0.08em",
    textTransform: "uppercase",
    color: ACCENT
  }}>Your mission</div>
          <div style={{
    fontSize: "1.2rem",
    fontWeight: 600,
    marginTop: "0.2rem"
  }}>“{instruction}”</div>
        </div>
        <div aria-label={`${placed} of ${targets.length} cubes delivered`} style={{
    display: "flex",
    alignItems: "center",
    gap: 6,
    fontFamily: "ui-monospace, monospace",
    fontVariantNumeric: "tabular-nums"
  }}>
          {targets.map(k => <span key={k} aria-hidden="true" style={{
    display: "inline-block",
    width: 14,
    height: 14,
    background: s.cubes[k].inBin ? "var(--arm-complete)" : "transparent",
    border: `2px solid ${s.cubes[k].inBin ? "var(--arm-complete)" : CUBES[k].fill}`
  }} />)}
          <span style={{
    marginLeft: 6
  }}>{placed}/{targets.length}</span>
        </div>
      </div>

      <div style={{
    display: "grid",
    gridTemplateColumns: narrow ? "minmax(0, 1fr)" : "minmax(0, 1fr) 240px"
  }}>
        <div style={{
    position: "relative",
    minWidth: 0,
    background: BG,
    display: "flex",
    alignItems: "center",
    padding: "44px 0 24px"
  }}>
          <div style={{
    position: "absolute",
    top: 12,
    left: 14,
    zIndex: 1,
    ...label,
    color: s.phase === "done" ? ACCENT : TEXT
  }}>
            {s.phase === "done" ? placed === 4 ? "MISSION COMPLETE" : "INSTRUCTION COMPLETE" : s.paused ? "PAUSED" : busy ? "MISSION IN PROGRESS" : "READY TO SORT"}
          </div>
          <button className="arm-drop-button" type="button" disabled={!canDrop} onClick={() => setS(st => ({
    ...st,
    dropAt: st.step - 1
  }))} style={btn(false, !canDrop, {
    position: "absolute",
    top: 8,
    right: 10,
    zIndex: 1,
    borderColor: canDrop ? ACCENT : "var(--arm-border)",
    background: canDrop ? ACCENT : "var(--arm-control)",
    color: canDrop ? ON_ACCENT : TEXT,
    fontWeight: 600
  })}>Drop the cube</button>
          {cam(scene(s, nextCube), `-8 -12 ${W + 16} ${H + 24}`, "Scripted robot arm sorting four colored cubes into two bins.", {
    height: narrow ? 250 : 300
  })}
          <div style={{
    position: "absolute",
    bottom: 10,
    left: 14,
    right: 14,
    display: "flex",
    justifyContent: "space-between",
    gap: 8,
    ...label,
    fontFamily: "ui-monospace, monospace",
    fontVariantNumeric: "tabular-nums"
  }}>
            <span>{s.plans} plans / {s.moves} moves</span><span>Scripted illustration</span>
          </div>
        </div>
        <div style={{
    padding: "0.75rem",
    display: "flex",
    flexDirection: "column",
    gap: "0.5rem",
    borderLeft: narrow ? "none" : "1px solid var(--arm-border)",
    borderTop: narrow ? "1px solid var(--arm-border)" : "none"
  }}>
          <fieldset className="arm-control-group" disabled={busy}>
            <legend>1. Choose a mission</legend>
            <button type="button" aria-pressed={s.target === "all"} onClick={() => choose("all")} style={btn(s.target === "all", busy, {
    width: "100%",
    fontWeight: 600
  })}>Sort all four cubes</button>
            {s.target === "all" && <p style={{
    ...label,
    margin: "6px 0"
  }}>Red + yellow → left.<br />Blue + green → right.</p>}
            <div style={{
    ...label,
    margin: "8px 0 6px"
  }}>Or move one cube</div>
            <div style={{
    display: "grid",
    gridTemplateColumns: narrow ? "repeat(4, 1fr)" : "1fr 1fr",
    gap: 6
  }}>
              {Object.entries(CUBES).map(([k, c]) => <button key={k} type="button" aria-label={`Move ${k} cube`} aria-pressed={s.target === k} onClick={() => choose(k)} style={btn(s.target === k, busy, {
    padding: "0.4rem",
    borderRadius: 2
  })}>
                <span aria-hidden="true" style={{
    width: 12,
    height: 12,
    background: c.fill,
    border: "1px solid currentColor",
    boxShadow: "2px 2px 0 var(--arm-shadow)"
  }} />{k}{s.cubes[k].inBin ? " ✓" : ""}
              </button>)}
            </div>
            {s.target !== "all" && <button type="button" role="switch" aria-label="Use right bin" aria-checked={s.bin === "right"} disabled={busy} onClick={() => choose(s.target, s.bin === "left" ? "right" : "left")} style={{
    minHeight: 44,
    width: "100%",
    marginTop: 8,
    padding: 0,
    border: 0,
    borderRadius: 6,
    background: "transparent",
    display: "flex",
    alignItems: "center",
    justifyContent: "space-between",
    gap: 6,
    fontSize: "0.75rem",
    cursor: busy ? "not-allowed" : "pointer",
    opacity: busy ? 0.45 : 1
  }}>
              <span style={{
    color: s.bin === "left" ? "var(--arm-bin-left)" : MUTED,
    fontWeight: 600
  }}>Left bin</span>
              <span aria-hidden="true" style={{
    position: "relative",
    width: 44,
    height: 24,
    flexShrink: 0,
    borderRadius: 999,
    background: BINS[s.bin].color,
    boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.2)"
  }}>
                <span style={{
    position: "absolute",
    top: 3,
    left: s.bin === "left" ? 3 : 23,
    width: 18,
    height: 18,
    borderRadius: "50%",
    background: CREAM,
    boxShadow: "0 1px 3px rgba(0,0,0,0.35)"
  }} />
              </span>
              <span style={{
    color: s.bin === "right" ? "var(--arm-bin-right)" : MUTED,
    fontWeight: 600
  }}>Right bin</span>
            </button>}
          </fieldset>
        </div>
      </div>
      <div style={{
    display: "grid",
    gridTemplateColumns: narrow ? "1fr" : "1fr 1fr",
    gap: 12,
    padding: "10px 16px",
    borderTop: "1px solid var(--arm-border)"
  }}>
          <fieldset className="arm-control-group" disabled={busy}>
            <legend>2. Look again after</legend>
            <div style={{
    display: "flex",
    gap: 6
  }}>{[8, 32].map(v => <button key={v} type="button" aria-pressed={n === v} onClick={() => setN(v)} style={btn(n === v, busy, {
    flex: 1
  })}>{v} moves</button>)}</div>
          </fieldset>
          <div className="arm-launch" style={{
    borderLeft: narrow ? "none" : "1px solid var(--arm-border)",
    borderTop: narrow ? "1px solid var(--arm-border)" : "none",
    paddingLeft: narrow ? 0 : 12,
    paddingTop: narrow ? 12 : 0
  }}>
            <div style={{
    fontWeight: 600,
    fontSize: "0.85rem",
    marginBottom: 8
  }}>3. Run your mission</div>
            <div style={{
    display: "flex",
    gap: 8
  }}>
            <button type="button" disabled={s.phase === "done"} onClick={busy ? () => setS(st => ({
    ...st,
    paused: !st.paused
  })) : play} style={btn(true, s.phase === "done", {
    flex: 1
  })}><span aria-hidden="true">{busy && !s.paused ? "Ⅱ" : "▶"}</span>{busy ? s.paused ? "Resume mission" : "Pause mission" : s.phase === "done" ? "Mission complete" : "Start mission"}</button>
              <button type="button" aria-label="Reset with a new layout" title="Reset with a new layout" onClick={() => reset(true)} style={btn(false, false, {
    width: 44,
    padding: 0,
    flexShrink: 0
  })}>
                <svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 3v6h-6" /><path d="M21 9a9 9 0 1 0-2.6 9.4" /></svg>
              </button>
            </div>
          </div>
      </div>

      <div style={{
    padding: "0.85rem 1rem",
    borderTop: "1px solid var(--arm-border)",
    background: "var(--arm-panel)"
  }}>
        <div style={{
    display: "grid",
    gridTemplateColumns: "repeat(3, 1fr)",
    gap: 6
  }}>
          {[["observe", "01 Look"], ["plan", "02 Think"], ["execute", "03 Move"]].map(([phase, name]) => <div key={phase} aria-current={s.phase === phase ? "step" : undefined} style={{
    borderBottom: `3px solid ${s.phase === phase ? ACCENT : "var(--arm-border)"}`,
    paddingBottom: 6,
    color: s.phase === phase ? ACCENT : MUTED,
    fontSize: "0.85rem"
  }}>{name}</div>)}
        </div>
        <div role="status" style={{
    minHeight: "1.5em",
    marginTop: 8,
    fontSize: "0.9rem",
    lineHeight: 1.5
  }}>{status}</div>
        <div style={{
    marginTop: 10,
    display: "grid",
    gridTemplateColumns: "repeat(32, 1fr)",
    gap: 2
  }} aria-label={`${s.executed || 0} of ${n} moves before looking again`}>
          {Array.from({
    length: PLAN
  }, (_, i) => <span key={i} style={{
    height: 5,
    background: i >= n ? "var(--arm-border)" : i < (s.executed || 0) ? ACCENT : "#798E77",
    opacity: i >= n ? 0.4 : 1
  }} />)}
        </div>
        <p style={{
    ...label,
    marginTop: 6
  }}>32 predicted moves · {n} executed before looking again · Animation slowed</p>
      </div>

      <details className="arm-inspector" onToggle={event => setDetailsOpen(event.currentTarget.open)} style={{
    padding: "0 1rem 0.3rem",
    borderTop: "1px solid var(--arm-border)"
  }}>
        <summary style={{
    fontSize: "0.9rem"
  }}>Inside the plan: inspect cameras and joint movements</summary>
        {detailsOpen && <>
          <div style={{
    display: "flex",
    justifyContent: "space-between",
    alignItems: "center",
    flexWrap: "wrap",
    gap: 8,
    marginTop: 8
  }}>
            <div style={{
    fontWeight: 600,
    fontSize: "0.9rem"
  }}>{hasPlan ? `Plan ${s.plans} · 32 predicted moves` : "No plan yet"}</div>
            <div style={{
    display: "flex",
    gap: 6
  }}>
              {busy && <button type="button" onClick={() => setS(st => ({
    ...st,
    paused: !st.paused
  }))} style={btn(false, false)}>{s.paused ? "Resume arm" : "Pause arm"}</button>}
              <button type="button" disabled={!inspecting} onClick={() => setInspection(null)} style={btn(false, !inspecting)}>{inspecting ? "Follow arm" : "Following arm"}</button>
            </div>
          </div>
          <p style={{
    ...label,
    marginTop: 6,
    minHeight: "3em"
  }}>
            {!hasPlan ? "Start a mission to see a predicted motion sequence. Then pause or scrub through it below." : s.phase === "observe" ? "Taking a new look. The completed plan stays here until its replacement is ready." : s.slipKey || s.slipped && s.stale ? "The cube fell, but these predictions still assume a successful grasp. Watch them update after the next look." : `The arm uses moves 1–${planUsed}, then takes new camera images. ${planUsed < PLAN ? `Moves ${planUsed + 1}–32 are predictions only and will be replaced.` : "All 32 moves are used before the next look."}`}
          </p>
          <div style={{
    display: "grid",
    gridTemplateColumns: narrow ? "minmax(0, 1fr)" : "repeat(2, minmax(0, 1fr))",
    gap: 16,
    marginTop: 12
  }}>
            <div>
              <div style={{
    ...label,
    fontWeight: 600,
    color: TEXT
  }}>What the model saw</div>
              <div className="arm-plan-observation" style={{
    display: "grid",
    gridTemplateColumns: "repeat(2, minmax(0, 1fr))",
    gap: 6,
    marginTop: 6
  }}>
                <div>
                  {cam(scene(observation, null), `-8 -12 ${W + 16} ${H + 24}`, "Scene camera captured at the start of this plan", {
    aspectRatio: "1",
    borderRadius: 6
  })}
                  <div style={{
    ...label,
    marginTop: 4
  }}>Scene camera</div>
                </div>
                <div>
                  {cam(scene(observation, null), `${tp.sx - 40} ${tp.sy - 44} 80 80`, "Wrist camera captured at the start of this plan", {
    aspectRatio: "1",
    borderRadius: 6
  })}
                  <div style={{
    ...label,
    marginTop: 4
  }}>Wrist camera</div>
                </div>
              </div>
              <p style={{
    ...label,
    marginTop: 8
  }}>Captured before move 1. These inputs stay frozen while this plan runs.</p>
            </div>
            <div>
              <div style={{
    ...label,
    fontWeight: 600,
    color: TEXT
  }}>What the model predicts · move {planStep}</div>
              <div className="arm-plan-prediction" data-step={planStep} style={{
    marginTop: 6,
    border: "1px solid var(--arm-border)",
    borderRadius: 6,
    overflow: "hidden",
    background: BG
  }}>
                {predicted ? cam(scene(predicted, null), `-8 -12 ${W + 16} ${H + 24}`, `Predicted scene at move ${planStep}: ${moveDescription}`, {
    aspectRatio: "1.6"
  }) : <div style={{
    aspectRatio: "1.6",
    display: "grid",
    placeItems: "center",
    ...label
  }}>Waiting for the first plan</div>}
              </div>
              <div style={{
    ...label,
    color: TEXT,
    marginTop: 8
  }}>{hasPlan ? moveDescription : "The predicted scene will appear here."}</div>
            </div>
          </div>
          <div style={{
    marginTop: 16,
    padding: "0.8rem",
    background: "var(--arm-panel)",
    borderRadius: 8
  }}>
            <label style={{
    display: "block",
    fontSize: "0.8rem"
  }}>
              Inspect planned move <strong style={{
    fontVariantNumeric: "tabular-nums"
  }}>{planStep} / 32</strong>
              <input className="arm-plan-scrubber" type="range" aria-label="Inspect planned move" min={0} max={PLAN} step={1} value={planStep} disabled={!hasPlan} onChange={event => setInspection({
    plan: s.plan,
    step: Number(event.target.value)
  })} style={{
    display: "block",
    width: "100%",
    height: 32,
    margin: "4px 0 0",
    accentColor: ACCENT,
    cursor: hasPlan ? "pointer" : "default"
  }} />
            </label>
            <div style={{
    display: "flex",
    justifyContent: "space-between",
    ...label
  }}><span>0 · observed</span><span>16</span><span>32 · predicted</span></div>
            <p style={{
    ...label,
    marginTop: 6
  }}>{inspecting ? "Inspecting a prediction; scrubbing does not move the simulated arm." : "The cursor follows completed moves. Drag to explore any part of the plan."}</p>
          </div>
          {hasPlan && <div className="arm-joint-curves" style={{
    marginTop: 18
  }}>
            <div style={{
    fontWeight: 600,
    fontSize: "0.9rem"
  }}>Joint targets at move {planStep}</div>
            <p style={{
    ...label,
    marginTop: 4
  }}>Each curve shows a joint’s target angle over the plan, with its own labeled scale. Gripper: 0% closed, 100% open. A flat line means hold still.</p>
            <div style={{
    display: "flex",
    gap: 14,
    flexWrap: "wrap",
    ...label,
    marginTop: 8,
    marginBottom: 12
  }}>
              <span>Solid line: used by the arm</span>{planUsed < PLAN && <span>Dashed line: prediction only</span>}<span style={{
    color: "var(--arm-bin-right)"
  }}>Vertical marker: look again after {planUsed}</span>
            </div>
            {JOINTS.map((joint, j) => {
    const values = s.frames.map(frame => jointValue(frame.q, j));
    const min = Math.min(...values), max = Math.max(...values);
    const fixed = max - min < 0.01;
    const low = j === 5 ? 0 : Math.floor(min / 5) * 5 - (fixed ? 5 : 0);
    const high = j === 5 ? 100 : Math.ceil(max / 5) * 5 + (fixed ? 5 : 0);
    const y = value => 44 - (value - low) / Math.max(1, high - low) * 36;
    const points = (start, end) => values.slice(start, end + 1).map((value, i) => `${(start + i) * 10},${y(value)}`).join(" ");
    const format = value => `${j === 5 ? Math.round(value) : value.toFixed(1)}${j === 5 ? "%" : "°"}`;
    return <div key={joint} style={{
      display: "grid",
      gridTemplateColumns: narrow ? "80px 34px minmax(0, 1fr)" : "110px 40px minmax(0, 1fr)",
      gap: 6,
      alignItems: "center",
      borderTop: "1px solid var(--arm-border)",
      padding: "6px 0"
    }}>
                <div title={joint} style={{
      fontSize: "0.75rem"
    }}>
                  <div>{PLAIN[j]}</div><div style={{
      fontWeight: 600,
      fontVariantNumeric: "tabular-nums"
    }}>{format(values[planStep])}</div>
                  {fixed && <div style={label}>Fixed</div>}
                </div>
                <div aria-hidden="true" style={{
      ...label,
      height: 48,
      display: "flex",
      flexDirection: "column",
      justifyContent: "space-between",
      alignItems: "flex-end",
      fontSize: "0.65rem"
    }}><span>{high}{j === 5 ? "%" : "°"}</span><span>{low}{j === 5 ? "%" : "°"}</span></div>
                <svg viewBox="-2 -2 324 56" preserveAspectRatio="none" role="img" aria-label={`${PLAIN[j]}: ${format(values[planStep])} at move ${planStep}. ${fixed ? "Held constant throughout the plan." : `Range ${format(min)} to ${format(max)}.`}`} style={{
      display: "block",
      width: "100%",
      height: 56,
      overflow: "visible"
    }}>
                  <rect x={0} y={0} width={planUsed * 10} height={52} fill="var(--arm-panel)" />
                  {[0, 8, 16, 24, 32].map(step => <line key={step} x1={step * 10} x2={step * 10} y1={0} y2={52} stroke="var(--arm-border)" strokeDasharray="2 4" vectorEffect="non-scaling-stroke" />)}
                  <polyline points={points(0, planUsed)} fill="none" stroke={ACCENT} strokeWidth={2} vectorEffect="non-scaling-stroke" />
                  {planUsed < PLAN && <polyline points={points(planUsed, PLAN)} fill="none" stroke={ACCENT} strokeWidth={2} strokeDasharray="4 4" opacity={0.65} vectorEffect="non-scaling-stroke" />}
                  <line x1={planUsed * 10} x2={planUsed * 10} y1={0} y2={52} stroke="var(--arm-bin-right)" strokeWidth={2} vectorEffect="non-scaling-stroke" />
                  <line x1={planStep * 10} x2={planStep * 10} y1={0} y2={52} stroke={TEXT} strokeWidth={1} opacity={0.4} vectorEffect="non-scaling-stroke" />
                  <circle cx={planStep * 10} cy={y(values[planStep])} r={3} fill={TEXT} />
                </svg>
              </div>;
  })}
            <div style={{
    marginLeft: narrow ? 126 : 162,
    display: "flex",
    justifyContent: "space-between",
    ...label
  }}><span>Move 0</span><span>16</span><span>32</span></div>
            <p style={{
    ...label,
    marginTop: 10
  }}>The look-again marker is <code>n_action_steps</code>. Shortening it changes how much of the plan is executed; the model still predicts all 32 moves.</p>
          </div>}
        </>}
      </details>
    </section>;
};

Built on FLUX 3’s image and video training, FLUX 3 Action learns to predict
actions and their visual outcomes together. Given camera images, the current
state, and an instruction, it predicts a chunk of actions (32 for DROID and games, 42 for SO-101)
alongside future video latents.
DROID and game plans cover about two seconds at 15 Hz; SO-101 uses 30 Hz.
The current API returns actions; see [predicted video](/flux_3/flux3_action_inference#predicted-video)
for what is available at inference.

Start with a prepared robot checkpoint, or fine-tune the model on
demonstrations of your own controls and tasks.

[Run the model](/flux_3/flux3_action_inference) or
[train it for your task](/flux_3/flux3_action_finetuning).

<Tabs>
  <Tab title="Robot arm">
    <ActionExampleVideo title="Robot arm" src="https://cdn.sanity.io/files/2gpum2i6/production/77a33cf9da2bd7ed55de334b2c20c4802bbf3d01.mp4" />

    A pick-and-place skill learned from about 200 demonstrations. FLUX 3 Action controls the SO-101 arm; footage is shown at 4× speed with pauses between plans removed. [Teach your arm a task.](/flux_3/flux3_action_so101)
  </Tab>

  <Tab title="Video game">
    <ActionExampleVideo title="Video game" src="https://cdn.sanity.io/files/2gpum2i6/production/4e8fac32fc13bb25c50b364ef0d9241c37ca7f5d.mp4" />

    Learn to dodge and aim from a scripted bot. The teacher (left) and fine-tuned model (right) play the same seed. [Train a game-playing model.](/flux_3/flux3_action_games)
  </Tab>

  <Tab title="Drone">
    <ActionExampleVideo title="Drone" src="https://cdn.sanity.io/files/2gpum2i6/production/8ed16bd1ba321ac730f52217a0cf43bdc84c0b83.mp4" />

    "Fly under the coffee table and hover." FLUX 3 Action follows the instruction in Isaac Sim, on a layout excluded from fine-tuning. [Teach a simulated drone to follow instructions.](/flux_3/flux3_action_drone)
  </Tab>
</Tabs>

## Try the control loop

Given "put the red cube in the left bin," the model uses camera images and
joint positions to predict motor commands and their visual outcomes. The
application executes a set number of actions (`n_action_steps`), then uses
fresh observations to plan again.

Try it below: start a mission, drop a cube, and watch how the arm recovers.
Change how often it looks to see how that affects its response.

<ActionArmSim />

This scripted demo illustrates the control loop, cameras, and predicted
frames. The videos above show FLUX 3 Action running.

* Weights: [FLUX 3 Action collection](https://huggingface.co/collections/black-forest-labs/flux-3-action)
* Code and training recipe: [black-forest-labs/flux-action](https://github.com/black-forest-labs/flux-action)
* LeRobot integration: [LeRobot FLUX 3 integration](https://huggingface.co/docs/lerobot/main/en/flux3)
* License: [FLUX Kommunity License](https://huggingface.co/black-forest-labs/flux-3-action-droid/blob/main/LICENSE.md)

## What it runs on

An embodiment is the system being controlled: its cameras, its available
controls, and the values used to describe its current state.

| Embodiment         | Cameras                                   | Action                                | State             |
| ------------------ | ----------------------------------------- | ------------------------------------- | ----------------- |
| DROID (Franka arm) | 3, composited to a 736×544 canvas         | 8: seven joint positions plus gripper | same 8 values     |
| SO-101 (LeRobot)   | 2 side by side, 512×256                   | 6 joint commands                      | 6 joint positions |
| Video game         | 1 frame, 256×256 → 512×512 canvas         | 3 or 4 values in \[-1, 1]             | the last action   |
| Drone (Isaac Sim)  | 1 onboard frame, 256×256 → 512×512 canvas | `[forward, lateral, up, yaw]`         | the last action   |

Dimensions above are width × height; JSON `canvas_hw` uses height, width.

DROID and SO-101 have prepared checkpoints. The game and drone examples
fine-tune the shared model with new input and output layers for their
controls, using recorded demonstrations.

<CardGroup cols={2}>
  <Card title="Run the model" icon="robot" href="/flux_3/flux3_action_inference">
    Get your first action prediction, then connect the model to a control loop.
  </Card>

  <Card title="Fine-tune" icon="sliders" href="/flux_3/flux3_action_finetuning">
    Teach the model new controls and tasks with your own demonstrations.
  </Card>

  <Card title="SO-101 with LeRobot" icon="hand" href="/flux_3/flux3_action_so101">
    Train your SO-101 arm for a new task using LeRobot and a LoRA.
  </Card>

  <Card title="Example: a video game" icon="gamepad" href="/flux_3/flux3_action_games">
    Train on a scripted bot’s demonstrations, then test the model in a shooter and a racing game.
  </Card>

  <Card title="Example: a drone" icon="paper-plane" href="/flux_3/flux3_action_drone">
    Train a drone to follow flight instructions, then test it in new simulated rooms.
  </Card>
</CardGroup>
