TechTush commited on
Commit
5bf86f6
·
verified ·
1 Parent(s): a7bac0c

Rangrez dashboard

Browse files
.gitattributes CHANGED
@@ -46,3 +46,11 @@ src/rangrez/scenes/shadowy.jpg filter=lfs diff=lfs merge=lfs -text
46
  src/rangrez/scenes/tilted_phone.jpg filter=lfs diff=lfs merge=lfs -text
47
  src/rangrez/scenes/vertical_strip.jpg filter=lfs diff=lfs merge=lfs -text
48
  src/rangrez/scenes/with_target.jpg filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
46
  src/rangrez/scenes/tilted_phone.jpg filter=lfs diff=lfs merge=lfs -text
47
  src/rangrez/scenes/vertical_strip.jpg filter=lfs diff=lfs merge=lfs -text
48
  src/rangrez/scenes/with_target.jpg filter=lfs diff=lfs merge=lfs -text
49
+ src/rangrez/samples/kurta.jpg filter=lfs diff=lfs merge=lfs -text
50
+ src/rangrez/samples/runs/teal-kurta/dip01_photo.jpg filter=lfs diff=lfs merge=lfs -text
51
+ src/rangrez/samples/runs/teal-kurta/dip01_sam.jpg filter=lfs diff=lfs merge=lfs -text
52
+ src/rangrez/samples/runs/teal-kurta/dip02_photo.jpg filter=lfs diff=lfs merge=lfs -text
53
+ src/rangrez/samples/runs/teal-kurta/dip02_sam.jpg filter=lfs diff=lfs merge=lfs -text
54
+ src/rangrez/samples/runs/teal-kurta/preview.jpg filter=lfs diff=lfs merge=lfs -text
55
+ src/rangrez/samples/runs/teal-kurta/target.jpg filter=lfs diff=lfs merge=lfs -text
56
+ src/rangrez/static/dyehouse.jpg filter=lfs diff=lfs merge=lfs -text
README.md CHANGED
@@ -25,6 +25,10 @@ and you can read a target colour from your own photo ("match this kurta").
25
  Rangrez gives you a recipe to dye a matching cloth at home, in drops of blue, red and yellow
26
  household colour.
27
 
 
 
 
 
28
  One lab, shared by everyone viewing: if a campaign is running, you are watching it.
29
 
30
  Code, hardware, firmware and evaluation: https://github.com/TusharTechs/rangrez
 
25
  Rangrez gives you a recipe to dye a matching cloth at home, in drops of blue, red and yellow
26
  household colour.
27
 
28
+ With the passcode you can also paste the buyer's message (English or Hindi) for **Muse Spark** to
29
+ read into an order, run the lab **hands-free by voice** in English or Hindi (Muse Voice Transcribe),
30
+ and see the customer's garment in the approved shade (**Muse Image**, measured and corrected).
31
+
32
  One lab, shared by everyone viewing: if a campaign is running, you are watching it.
33
 
34
  Code, hardware, firmware and evaluation: https://github.com/TusharTechs/rangrez
src/rangrez/assisted.py CHANGED
@@ -22,6 +22,15 @@ from .profiles import PROFILE
22
  from .rig import DYES, UNIT, Inventory, PumpPlan, Recipe, RigLimits, ShotResult, now
23
 
24
  STEP_ML = PROFILE.step_ml
 
 
 
 
 
 
 
 
 
25
  MAX_SIDE_PX = 1920 # SAM 3.1 accepts up to about 4 MP; phones shoot 12+
26
 
27
 
@@ -77,10 +86,12 @@ class AssistedRig:
77
  "water_ml": round(float(water), 1),
78
  "dip_seconds": int(recipe.dip_seconds),
79
  "steps": [
 
80
  "Rinse the glass and the dropper or syringes.",
81
  *[self._amount(d, v, c) for d, v, c in zip(DYES, ml, recipe.conc) if v >= STEP_ML / 2],
82
  (f"Add 4 teaspoons (20 mL) of water and stir." if UNIT == "drops" else f"Add {water:.1f} mL of water and stir for 10 seconds."),
83
  f"Dip a fresh white cotton strip for {int(recipe.dip_seconds)} seconds.",
 
84
  "Squeeze it gently and dry it fully with the hair dryer (about 2 minutes).",
85
  "Lay it on a sheet of white paper (or next to the colour card) in the same spot and light as before, and send the photo.",
86
  "If you have the customer's cloth, lay it beside the strip: Rangrez then compares them side by side under the same light.",
@@ -90,7 +101,8 @@ class AssistedRig:
90
  @staticmethod
91
  def _amount(dye: str, ml: float, conc: float) -> str:
92
  if UNIT == "drops":
93
- return f"Add {round(conc)} drops of {dye}."
 
94
  return f"Add {ml:.1f} mL of {dye}."
95
 
96
  # -- rig protocol ----------------------------------------------------------
 
22
  from .rig import DYES, UNIT, Inventory, PumpPlan, Recipe, RigLimits, ShotResult, now
23
 
24
  STEP_ML = PROFILE.step_ml
25
+ # A dip is only comparable with the final cloth after the dye has fixed and the loose dye is rinsed out.
26
+ FIX_AND_RINSE = (["Wrap the wet strip in cling film and leave it 10 minutes so the dye fixes, then rinse it in cold "
27
+ "water until the water runs clear, and once in warm soapy water."]
28
+ if PROFILE.key == "reactive" else
29
+ ["Rinse the strip once in cold water to take off loose colour (household colours are not wash-fast)."])
30
+ # Shown before every dip: dye stains skin and lingers in kitchenware; reactive dye baths are alkaline.
31
+ SAFETY = ["Wear gloves, and use a glass and dropper kept for dyeing only, never again for food or drink."] + (
32
+ ["Soda ash (washing soda) irritates skin and eyes: no splashing, rinse at once if it touches skin."]
33
+ if PROFILE.key == "reactive" else [])
34
  MAX_SIDE_PX = 1920 # SAM 3.1 accepts up to about 4 MP; phones shoot 12+
35
 
36
 
 
86
  "water_ml": round(float(water), 1),
87
  "dip_seconds": int(recipe.dip_seconds),
88
  "steps": [
89
+ *SAFETY,
90
  "Rinse the glass and the dropper or syringes.",
91
  *[self._amount(d, v, c) for d, v, c in zip(DYES, ml, recipe.conc) if v >= STEP_ML / 2],
92
  (f"Add 4 teaspoons (20 mL) of water and stir." if UNIT == "drops" else f"Add {water:.1f} mL of water and stir for 10 seconds."),
93
  f"Dip a fresh white cotton strip for {int(recipe.dip_seconds)} seconds.",
94
+ *FIX_AND_RINSE,
95
  "Squeeze it gently and dry it fully with the hair dryer (about 2 minutes).",
96
  "Lay it on a sheet of white paper (or next to the colour card) in the same spot and light as before, and send the photo.",
97
  "If you have the customer's cloth, lay it beside the strip: Rangrez then compares them side by side under the same light.",
 
101
  @staticmethod
102
  def _amount(dye: str, ml: float, conc: float) -> str:
103
  if UNIT == "drops":
104
+ n = round(conc)
105
+ return f"Add {n} drop{'' if n == 1 else 's'} of {dye}."
106
  return f"Add {ml:.1f} mL of {dye}."
107
 
108
  # -- rig protocol ----------------------------------------------------------
src/rangrez/campaign.py CHANGED
@@ -192,10 +192,32 @@ class Campaign:
192
  self.shots.append(shot)
193
  self.emit("shot", index=shot.index, recipe=recipe.describe(), lab=m.lab.tolist(),
194
  hex=lab_to_hex(m.lab), delta_e=de, flags=m.flags, rationale=rationale)
195
- if m.ok and de <= self.tolerance:
 
196
  self.status = "matched"
197
- self.emit("status", status=self.status, shot=shot.index, delta_e=de)
198
- return self._shot_summary(shot) | {"matched": m.ok and de <= self.tolerance}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
 
200
  def _side_by_side(self, m) -> None:
201
  """The customer's cloth lies beside the strip: compare them under the same light.
 
192
  self.shots.append(shot)
193
  self.emit("shot", index=shot.index, recipe=recipe.describe(), lab=m.lab.tolist(),
194
  hex=lab_to_hex(m.lab), delta_e=de, flags=m.flags, rationale=rationale)
195
+ matched = m.ok and de <= self.tolerance and self._confirmed(shot)
196
+ if matched:
197
  self.status = "matched"
198
+ self.emit("status", status=self.status, shot=shot.index, delta_e=shot.delta_e)
199
+ return self._shot_summary(shot) | {"matched": matched}
200
+
201
+ def _confirmed(self, shot: Shot) -> bool:
202
+ """No single photo decides a pass: an automated rig photographs the strip again and both must agree."""
203
+ if hasattr(self.rig, "provide") or not hasattr(self.rig, "capture"):
204
+ return True # a person sent this photo; asking for another would only slow them down
205
+ try:
206
+ m2 = measure(self.rig.capture(), self.segmenter, seed=100 + shot.index)
207
+ except RuntimeError as exc:
208
+ self.note(f"Confirmation photo failed ({exc}); not calling it a match on one photo.", who="lab")
209
+ return False
210
+ if not m2.ok:
211
+ self.note("Confirmation photo was rejected; not calling it a match on one photo.", who="lab")
212
+ return False
213
+ de2 = float(delta_e_2000(m2.lab, self.target_lab))
214
+ if de2 > self.tolerance:
215
+ self.note(f"Dip {shot.index} read dE00 {shot.delta_e:.2f}, but a second photo reads {de2:.2f}: "
216
+ "not a match yet.", who="lab")
217
+ return False
218
+ shot.delta_e = (shot.delta_e + de2) / 2
219
+ self.note(f"Confirmed with a second photo: dE00 {shot.delta_e * 2 - de2:.2f} and {de2:.2f}.", who="lab")
220
+ return True
221
 
222
  def _side_by_side(self, m) -> None:
223
  """The customer's cloth lies beside the strip: compare them under the same light.
src/rangrez/colour.py CHANGED
@@ -82,6 +82,22 @@ def lab_to_hex(lab: np.ndarray) -> str:
82
  return "#" + "".join(f"{int(v):02x}" for v in np.clip(rgb, 0, 255))
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  def delta_e_2000(lab1: np.ndarray, lab2: np.ndarray) -> np.ndarray:
86
  """CIEDE2000 colour difference (Sharma, Wu & Dalal 2005), kL = kC = kH = 1."""
87
  lab1 = np.asarray(lab1, dtype=float)
 
82
  return "#" + "".join(f"{int(v):02x}" for v in np.clip(rgb, 0, 255))
83
 
84
 
85
+ def delta_e_cmc(reference, sample, l: float = 2.0, c: float = 1.0) -> float:
86
+ """CMC l:c colour difference of sample from reference (the textile industry's other standard; 2:1 for acceptability)."""
87
+ L1, a1, b1 = (float(v) for v in reference)
88
+ L2, a2, b2 = (float(v) for v in sample)
89
+ C1, C2 = np.hypot(a1, b1), np.hypot(a2, b2)
90
+ dL, dC = L1 - L2, C1 - C2
91
+ dH2 = max((a1 - a2) ** 2 + (b1 - b2) ** 2 - dC**2, 0.0)
92
+ H1 = np.degrees(np.arctan2(b1, a1)) % 360
93
+ F = np.sqrt(C1**4 / (C1**4 + 1900))
94
+ T = 0.56 + abs(0.2 * np.cos(np.radians(H1 + 168))) if 164 <= H1 <= 345 else 0.36 + abs(0.4 * np.cos(np.radians(H1 + 35)))
95
+ SL = 0.511 if L1 < 16 else 0.040975 * L1 / (1 + 0.01765 * L1)
96
+ SC = 0.0638 * C1 / (1 + 0.0131 * C1) + 0.638
97
+ SH = SC * (F * T + 1 - F)
98
+ return float(np.sqrt((dL / (l * SL)) ** 2 + (dC / (c * SC)) ** 2 + dH2 / SH**2))
99
+
100
+
101
  def delta_e_2000(lab1: np.ndarray, lab2: np.ndarray) -> np.ndarray:
102
  """CIEDE2000 colour difference (Sharma, Wu & Dalal 2005), kL = kC = kH = 1."""
103
  lab1 = np.asarray(lab1, dtype=float)
src/rangrez/esp32_emulator.py CHANGED
@@ -58,13 +58,18 @@ class VirtualESP32:
58
  self.bath_ml = 0.0
59
  self.dye_ml = np.zeros(3)
60
  self.estop = False
 
61
  self.light = False
 
62
  self.log: list[dict] = []
63
  self._write_video() # the camera shows the empty light box until the first dip
64
 
65
  # -- e-stop button ----------------------------------------------------------
66
  def press_estop(self) -> None:
67
- self.estop = True
 
 
 
68
 
69
  def _wait(self, seconds: float) -> bool:
70
  end = time.monotonic() + seconds * self.time_scale
@@ -88,6 +93,8 @@ class VirtualESP32:
88
  if cmd == "ping":
89
  return {"fw": "rangrez-1", "estop": self.estop, "bath_ml": self.bath_ml}
90
  if cmd == "reset":
 
 
91
  self.estop = False
92
  return self._reply(cmd)
93
  if cmd == "pump":
@@ -146,6 +153,8 @@ class VirtualESP32:
146
  pid, ms = int(doc.get("id", -1)), float(doc.get("ms_per_ml", 0))
147
  if not 0 <= pid <= 3 or not 100 <= ms <= 5000:
148
  return self._reply(cmd, "bad calibration")
 
 
149
  L["msPerMl"][pid] = ms
150
  return self._reply(cmd)
151
  return self._reply(cmd, "unknown command")
 
58
  self.bath_ml = 0.0
59
  self.dye_ml = np.zeros(3)
60
  self.estop = False
61
+ self.button_pressed = False # the physical e-stop button, separate from the latched flag
62
  self.light = False
63
+ self.compiled_ms_per_ml = list(self.limits["msPerMl"])
64
  self.log: list[dict] = []
65
  self._write_video() # the camera shows the empty light box until the first dip
66
 
67
  # -- e-stop button ----------------------------------------------------------
68
  def press_estop(self) -> None:
69
+ self.estop = self.button_pressed = True
70
+
71
+ def release_estop(self) -> None:
72
+ self.button_pressed = False
73
 
74
  def _wait(self, seconds: float) -> bool:
75
  end = time.monotonic() + seconds * self.time_scale
 
93
  if cmd == "ping":
94
  return {"fw": "rangrez-1", "estop": self.estop, "bath_ml": self.bath_ml}
95
  if cmd == "reset":
96
+ if self.button_pressed:
97
+ return self._reply(cmd, "e-stop still pressed: release it first")
98
  self.estop = False
99
  return self._reply(cmd)
100
  if cmd == "pump":
 
153
  pid, ms = int(doc.get("id", -1)), float(doc.get("ms_per_ml", 0))
154
  if not 0 <= pid <= 3 or not 100 <= ms <= 5000:
155
  return self._reply(cmd, "bad calibration")
156
+ if abs(ms - self.compiled_ms_per_ml[pid]) > 0.20 * self.compiled_ms_per_ml[pid]:
157
+ return self._reply(cmd, "calibration more than 20% from the compiled value: reflash to change it")
158
  L["msPerMl"][pid] = ms
159
  return self._reply(cmd)
160
  return self._reply(cmd, "unknown command")
src/rangrez/glimmer.py CHANGED
@@ -34,6 +34,20 @@ def prompt(lab) -> str:
34
  return f"Target L* {L:.1f}, a* {a:.1f}, b* {b:.1f} ({lab_to_hex(np.array([L, a, b]))}). First dip recipe?"
35
 
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  def answer(conc) -> str:
38
  return json.dumps({d: round(float(c), 2) for d, c in zip(PROFILE.names, conc)})
39
 
 
34
  return f"Target L* {L:.1f}, a* {a:.1f}, b* {b:.1f} ({lab_to_hex(np.array([L, a, b]))}). First dip recipe?"
35
 
36
 
37
+ def notebook_prompt(lab, neighbours) -> str:
38
+ """Glimmer v2: the target plus the most similar dips this lab has made (recipe -> measured colour)."""
39
+ lines = [prompt(lab).replace(" First dip recipe?", "")]
40
+ if neighbours:
41
+ lines.append("The lab's most similar past dips (recipe -> measured L*a*b*):")
42
+ for conc, nlab in neighbours:
43
+ recipe = ", ".join(f"{d} {float(c):g}" for d, c in zip(PROFILE.names, conc))
44
+ lines.append(f"- {recipe} -> {float(nlab[0]):.1f}, {float(nlab[1]):.1f}, {float(nlab[2]):.1f}")
45
+ else:
46
+ lines.append("The lab has no similar past dips yet.")
47
+ lines.append("First dip recipe?")
48
+ return "\n".join(lines)
49
+
50
+
51
  def answer(conc) -> str:
52
  return json.dumps({d: round(float(c), 2) for d, c in zip(PROFILE.names, conc)})
53
 
src/rangrez/order.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """From the buyer's message to a production plan.
2
+
3
+ Buyers send orders as chat messages ("need 500 kurtas in this teal, cotton poplin, by Friday,
4
+ buyer very strict on shade"). Muse Spark reads the message into a structured order; the
5
+ numbers that follow are plain arithmetic: how strict the shade must be sets the lab's pass
6
+ tolerance, and the approved dip's recipe scales to the order's fabric, dye bath and chemicals.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import time
14
+
15
+ from .profiles import PROFILE
16
+ from .rig import DYES
17
+
18
+ SPARK_MODEL = "muse-spark-1.3"
19
+ TOLERANCE = {"strict": 1.0, "normal": 1.5, "relaxed": 2.0} # CIEDE2000 pass threshold by how picky the buyer is
20
+ KG_PER_PIECE = {"kurta": 0.30, "shirt": 0.25, "t-shirt": 0.18, "saree": 0.55, "dupatta": 0.15, "kurti": 0.25,
21
+ "towel": 0.40, "bedsheet": 0.70, "trouser": 0.45, "dress": 0.35}
22
+ KG_PER_METRE = 0.14 # 110 GSM poplin, 1.1 m wide, plus cutting waste
23
+ LIQUOR_RATIO = 10 # litres of dye bath per kg of cotton
24
+ # Salt and soda ash rise with shade depth (% on weight of fabric): typical reactive-dye practice.
25
+ SALT_SODA_BY_DEPTH = [(0.5, 30, 10), (2.0, 50, 15), (4.0, 70, 20), (float("inf"), 80, 20)]
26
+
27
+ SCHEMA = {
28
+ "type": "object",
29
+ "properties": {
30
+ "garment": {"type": "string", "description": "what is being dyed, singular, e.g. kurta, saree, fabric"},
31
+ "quantity": {"type": "number"},
32
+ "quantity_unit": {"type": "string", "enum": ["pieces", "metres", "kg"]},
33
+ "fabric": {"type": "string"},
34
+ "deadline": {"type": "string", "description": "delivery date as YYYY-MM-DD, or empty if not given"},
35
+ "lab_dip_due": {"type": "string", "description": "when the buyer wants the lab dip, YYYY-MM-DD, or empty"},
36
+ "shade_strictness": {"type": "string", "enum": ["strict", "normal", "relaxed"]},
37
+ "notes": {"type": "string", "description": "anything else that matters for dyeing, short"},
38
+ },
39
+ "required": ["garment", "quantity", "quantity_unit", "fabric", "deadline", "lab_dip_due", "shade_strictness", "notes"],
40
+ "additionalProperties": False,
41
+ }
42
+
43
+
44
+ def parse_order(message: str, log=None) -> dict:
45
+ """Muse Spark reads a buyer's chat message into a structured order."""
46
+ from openai import OpenAI
47
+
48
+ from .vision import ApiCall
49
+
50
+ client = OpenAI(base_url="https://api.meta.ai/v1", api_key=os.environ["MODEL_API_KEY"], timeout=90, max_retries=1)
51
+ today = time.strftime("%A %Y-%m-%d")
52
+ started = time.monotonic()
53
+ try:
54
+ r = client.chat.completions.create(
55
+ model=SPARK_MODEL, reasoning_effort="low",
56
+ messages=[{"role": "system", "content": f"You read dye orders for a small cotton dye house. Today is {today}. "
57
+ "Resolve relative dates. Use only what the message says."},
58
+ {"role": "user", "content": message}],
59
+ response_format={"type": "json_schema", "json_schema": {"name": "order", "schema": SCHEMA, "strict": True}})
60
+ order = json.loads(r.choices[0].message.content)
61
+ except Exception as exc:
62
+ if log is not None:
63
+ log.add(ApiCall(SPARK_MODEL, "read the order", time.monotonic() - started, False, str(exc)[:200]))
64
+ raise RuntimeError(f"could not read the order: {exc}") from exc
65
+ if log is not None:
66
+ log.add(ApiCall(SPARK_MODEL, "read the order", time.monotonic() - started, True))
67
+ order["tolerance_de00"] = TOLERANCE.get(order.get("shade_strictness", "normal"), 1.5)
68
+ return order
69
+
70
+
71
+ def fabric_kg(order: dict) -> float:
72
+ q, unit = float(order.get("quantity") or 0), order.get("quantity_unit", "pieces")
73
+ if unit == "kg":
74
+ return q
75
+ if unit == "metres":
76
+ return q * KG_PER_METRE
77
+ garment = order.get("garment", "").lower().rstrip("s")
78
+ return q * KG_PER_PIECE.get(garment, 0.30)
79
+
80
+
81
+ def production_plan(order: dict, recipe_conc) -> dict | None:
82
+ """The approved dip's recipe, scaled to the order. Reactive dyes (g/L) only."""
83
+ if PROFILE.unit != "g/L":
84
+ return None
85
+ kg = fabric_kg(order)
86
+ litres = kg * LIQUOR_RATIO
87
+ depth = float(sum(recipe_conc)) * LIQUOR_RATIO / 10 # % on weight of fabric: g/L x L/kg / 10
88
+ salt, soda = next((s, a) for limit, s, a in SALT_SODA_BY_DEPTH if depth <= limit)
89
+ return {
90
+ "fabric_kg": round(kg, 1),
91
+ "bath_litres": round(litres),
92
+ "dye_g": {d: round(float(c) * litres) for d, c in zip(DYES, recipe_conc)},
93
+ "shade_depth_owf": round(depth, 2),
94
+ "salt_kg": round(salt * litres / 1000, 1),
95
+ "soda_ash_kg": round(soda * litres / 1000, 1),
96
+ "assumptions": f"{LIQUOR_RATIO}:1 liquor ratio; {KG_PER_PIECE.get(order.get('garment', '').lower().rstrip('s'), 0.30)} kg "
97
+ f"per {order.get('garment', 'piece')}; a {depth:.1f}% shade, so salt {salt} g/L and soda ash {soda} g/L, "
98
+ "typical for reactive dye at that depth. Indicative until calibrated with your own lab-to-bulk factor.",
99
+ }
100
+
101
+
102
+ def draft_reply(message: str, facts: dict, log=None) -> str:
103
+ """The message back to the buyer, in their language, using only the lab's numbers."""
104
+ from openai import OpenAI
105
+
106
+ from .vision import ApiCall
107
+
108
+ client = OpenAI(base_url="https://api.meta.ai/v1", api_key=os.environ["MODEL_API_KEY"], timeout=90, max_retries=1)
109
+ started = time.monotonic()
110
+ try:
111
+ r = client.chat.completions.create(
112
+ model=SPARK_MODEL, reasoning_effort="low",
113
+ messages=[{"role": "system", "content": (
114
+ "You write the dye house's short chat reply to a buyer about their lab dip. Reply in the same language and "
115
+ "script the buyer used (Hindi in Devanagari if they wrote Hindi, Hinglish if they wrote Hinglish). Use only "
116
+ "the facts given; never invent numbers or promises. At most 70 words, warm and plain, no markdown. Say the "
117
+ "lab-dip report is attached; if a preview exists, say a photo of their garment in the shade is attached.")},
118
+ {"role": "user", "content": f"Buyer's message:\n{message}\n\nFacts:\n{json.dumps(facts, ensure_ascii=False)}"}])
119
+ text = (r.choices[0].message.content or "").strip()
120
+ except Exception as exc:
121
+ if log is not None:
122
+ log.add(ApiCall(SPARK_MODEL, "reply to the buyer", time.monotonic() - started, False, str(exc)[:200]))
123
+ raise RuntimeError(f"could not draft the reply: {exc}") from exc
124
+ if log is not None:
125
+ log.add(ApiCall(SPARK_MODEL, "reply to the buyer", time.monotonic() - started, True))
126
+ return text
src/rangrez/replay.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Replay a recorded live run on the dashboard, for visitors without the live passcode.
2
+
3
+ Every event in a recording happened for real: each SAM 3.1, Muse Wonder, Muse Spark, Muse Image
4
+ or Muse Voice Transcribe call with its timing, each dip photo and SAM's masks, the notebook, the
5
+ report. A replay sends those events again in order (a little faster), and the dashboard labels
6
+ it as a recording with its date. Recordings shipped with the app live in samples/runs/.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import threading
13
+ import time
14
+ from pathlib import Path
15
+
16
+ SAMPLES = Path(__file__).parent / "samples"
17
+
18
+
19
+ def runs() -> list[dict]:
20
+ out = []
21
+ for d in sorted((SAMPLES / "runs").glob("*")):
22
+ meta = json.loads((d / "meta.json").read_text()) if (d / "meta.json").exists() else {}
23
+ out.append({"name": d.name, "title": meta.get("title", d.name), "started": meta.get("started", ""),
24
+ "agent": meta.get("agent", ""), "rig": meta.get("rig", "")})
25
+ return out
26
+
27
+
28
+ class Replay:
29
+ def __init__(self, name: str, publish, speed: float = 3.0, max_gap: float = 2.5) -> None:
30
+ self.dir = SAMPLES / "runs" / Path(name).name # no path tricks: a run name only
31
+ if not (self.dir / "events.jsonl").exists():
32
+ raise FileNotFoundError(name)
33
+ self.meta = json.loads((self.dir / "meta.json").read_text()) if (self.dir / "meta.json").exists() else {}
34
+ self.events = [json.loads(line) for line in (self.dir / "events.jsonl").read_text().splitlines() if line.strip()]
35
+ self.publish, self.speed, self.max_gap = publish, speed, max_gap
36
+ self.stopped = False
37
+ self.thread = threading.Thread(target=self._run, daemon=True)
38
+
39
+ def start(self) -> None:
40
+ self.thread.start()
41
+
42
+ def stop(self) -> None:
43
+ self.stopped = True
44
+
45
+ @property
46
+ def running(self) -> bool:
47
+ return self.thread.is_alive()
48
+
49
+ def file(self, name: str) -> bytes | None:
50
+ p = self.dir / Path(name).name
51
+ return p.read_bytes() if p.exists() else None
52
+
53
+ def _run(self) -> None:
54
+ recorded = self.meta.get("started", "")
55
+ last = None
56
+ for e in self.events:
57
+ if self.stopped:
58
+ return
59
+ at = e.get("at")
60
+ if last is not None and at is not None:
61
+ time.sleep(min(max(at - last, 0) / self.speed, self.max_gap))
62
+ last = at if at is not None else last
63
+ e = {**e, "at": time.time(), "replay": True}
64
+ if e.get("kind") == "start":
65
+ e["recorded"] = recorded
66
+ self.publish(e)
src/rangrez/report.py CHANGED
@@ -15,11 +15,12 @@ import time
15
 
16
  import numpy as np
17
 
18
- from .colour import delta_e_2000, lab_to_hex
19
  from .profiles import PROFILE
20
  from .rig import DYES, UNIT
21
 
22
  LIQUOR_RATIO = 10 # litres of dye bath per kg of cotton (1:10), typical for exhaust dyeing
 
23
 
24
 
25
  def difference(target, sample) -> dict:
@@ -50,17 +51,62 @@ def difference(target, sample) -> dict:
50
  "words": ", ".join(words) or "no visible difference"}
51
 
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  def _img(jpeg: bytes, width: int) -> str:
54
  return f'<img src="data:image/jpeg;base64,{base64.b64encode(jpeg).decode()}" width="{width}" alt="">'
55
 
56
 
57
  def render(campaign, target_photo: bytes | None = None, agent: str = "", api_calls: int | None = None,
58
- preview: dict | None = None) -> str:
59
  c = campaign
60
  best = c.best
61
  tol = c.tolerance
62
- passed = best is not None and best.delta_e <= tol
63
  diff = difference(c.target_lab, best.measurement.lab) if best else None
 
 
 
 
64
  when = time.strftime("%d %b %Y, %H:%M")
65
  ref = f"RZ-{c.campaign_id[-8:].upper()}"
66
  rows = []
@@ -74,9 +120,17 @@ def render(campaign, target_photo: bytes | None = None, agent: str = "", api_cal
74
  if best is not None:
75
  bath = best.recipe.as_array() / PROFILE.stock_per_l * 1000 # mL of stock per litre of bath
76
  per_kg = bath * LIQUOR_RATIO
77
- prod = "".join(f"<tr><td>{html.escape(n)}</td><td class='num'>{v:g} {UNIT}</td><td class='num'>{m:.0f} mL</td></tr>"
 
78
  for n, v, m in zip(DYES, best.recipe.conc, per_kg))
79
- verdict = ("PASS" if passed else "NOT YET") if best else "NO DIP"
 
 
 
 
 
 
 
80
  flags = sorted({f for s in c.shots for f in s.measurement.flags})
81
  notes = "".join(f"<li>{html.escape(f)}</li>" for f in flags) or "<li>No measurement warnings.</li>"
82
  return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
@@ -103,16 +157,26 @@ img {{ border-radius: 6px; max-width: 100%; height: auto; }} .sign {{ display:fl
103
  <b>Target</b> {lab_to_hex(c.target_lab)} · L*a*b* {", ".join(f"{v:.1f}" for v in c.target_lab)}
104
  {("<div>" + _img(target_photo, 300) + "</div>") if target_photo else ""}</div>
105
  <div><div class="chip" style="background:{lab_to_hex(best.measurement.lab) if best else "#fff"}"></div>
106
- <b>Submitted dip {best.index if best else ""}</b> {lab_to_hex(best.measurement.lab) if best else ""}
107
  {("· L*a*b* " + ", ".join(f"{v:.1f}" for v in best.measurement.lab)) if best else ""}</div>
108
  <div><div class="verdict">{verdict}</div>
109
- {f'<div>ΔE00 <b>{diff["dE00"]:.2f}</b> against a tolerance of {tol}</div><div>ΔL* {diff["dL"]:+.2f} · ΔC* {diff["dC"]:+.2f} · ΔH* {diff["dH"]:+.2f}</div><div>The dip is <b>{html.escape(diff["words"])}</b> than the target.</div>' if diff else ""}</div>
 
110
  </div>
111
 
112
- {f'''<h2>Recipe</h2>
113
- <table><tr><th>Dye</th><th class="num">Bath concentration</th><th class="num">Stock for 1 kg cotton (1:{LIQUOR_RATIO})</th></tr>{prod}</table>
 
 
 
 
 
114
  <p class="meta">Dip {best.recipe.dip_seconds:.0f} s. Stock at {PROFILE.stock_per_l:g} {UNIT if UNIT != "drops" else "drops"} per litre. Scale-up assumes the same liquor ratio, temperature and fixation as the lab dip; confirm with a bulk trial.</p>''' if best else ""}
115
 
 
 
 
 
116
  {f"""<h2>Bulk preview</h2>
117
  <div class="grid"><div>{_img(preview["jpeg"], 420)}</div>
118
  <div class="meta">The customer's photo, recoloured by Muse Image to dip {preview["dip"]}'s shade. An image model is not a colour
@@ -126,7 +190,9 @@ garment's pixels; it now measures {preview["measured_hex"]}, ΔE00 {preview["de0
126
  <h2>How it was measured</h2>
127
  <ul class="meta">
128
  <li>Each dip is dried and photographed on white paper; SAM 3.1 segments the strip and the 24-patch colour card, and a colour-correction matrix turns pixels into CIELAB (D65). Uneven light is evened out using the paper.</li>
129
- <li>Colour difference is CIEDE2000; ΔL*, ΔC*, ΔH* are CIELAB components (positive: lighter, brighter, counter-clockwise in hue).</li>
 
 
130
  <li>Campaign run by {html.escape(agent or "Rangrez")}{f", {api_calls} live Meta API calls" if api_calls is not None else ""}; {len(c.shots)} dips; status {html.escape(c.status)}.</li>
131
  {notes}
132
  </ul>
 
15
 
16
  import numpy as np
17
 
18
+ from .colour import delta_e_2000, delta_e_cmc, lab_to_hex
19
  from .profiles import PROFILE
20
  from .rig import DYES, UNIT
21
 
22
  LIQUOR_RATIO = 10 # litres of dye bath per kg of cotton (1:10), typical for exhaust dyeing
23
+ UNCERTAINTY = {"card": 0.7, "paper": 1.2} # median dE00 error on the 44-photo test bench (eval/accuracy.py)
24
 
25
 
26
  def difference(target, sample) -> dict:
 
51
  "words": ", ".join(words) or "no visible difference"}
52
 
53
 
54
+ def _impact_block(c) -> str:
55
+ """What this lab dip took, next to how lab dips usually go."""
56
+ if not c.shots:
57
+ return ""
58
+ stock_ml = sum(float(np.sum(s.recipe.as_array())) / PROFILE.stock_per_l * c.rig.limits.bath_ml for s in c.shots)
59
+ water_ml = len(c.shots) * c.rig.limits.bath_ml
60
+ minutes = (c.shots[-1].at - c.shots[0].at) / 60 if len(c.shots) > 1 else 0
61
+ took = f"{len(c.shots)} dip{'s' if len(c.shots) > 1 else ''}" + (f" over {minutes:.0f} minutes" if minutes >= 1 else "")
62
+ return f"""<h2>What it took</h2>
63
+ <div class="grid">
64
+ <div><div class="verdict" style="color:var(--ink)">{took}</div>
65
+ <div class="meta">{stock_ml:.1f} mL of dye stock, about {water_ml / 1000:.2f} L of water and {len(c.shots)} cotton strip{'s' if len(c.shots) > 1 else ''}.</div></div>
66
+ <div class="meta">How lab dips usually go: about 2 to 4 rounds, each several days of mill work plus courier and the buyer's review
67
+ (one retailer allows up to five business days per review). Sources:
68
+ <a href="https://ifactoryapp.com/industries/textile-manufacturing/textile-sample-development-cost-time-management">iFactory sample-development guide</a>,
69
+ <a href="https://www.thekrogerco.com/wp-content/uploads/2017/08/ale_gmsg.pdf">a retailer's supplier guide</a>,
70
+ <a href="https://cottonworks.com/en/topics/sourcing-manufacturing/dyeing/the-lab-dip-process">Cotton Incorporated</a>.</div>
71
+ </div>"""
72
+
73
+
74
+ def _order_block(order: dict, best) -> str:
75
+ from .order import production_plan
76
+
77
+ rows = "".join(f"<tr><td>{html.escape(k)}</td><td>{html.escape(str(v))}</td></tr>" for k, v in (
78
+ ("Garment", f"{order.get('quantity'):g} {order.get('quantity_unit')} of {order.get('garment')}"),
79
+ ("Fabric", order.get("fabric")), ("Lab dip due", order.get("lab_dip_due") or "not given"),
80
+ ("Delivery", order.get("deadline") or "not given"),
81
+ ("Shade", f"{order.get('shade_strictness')}: pass at ΔE00 ≤ {order.get('tolerance_de00')}"),
82
+ ("Notes", order.get("notes") or "")))
83
+ plan = production_plan(order, best.recipe.conc) if best else None
84
+ plan_html = ""
85
+ if plan:
86
+ dyes = "".join(f"<tr><td>{html.escape(d)}</td><td class='num'>{g:,} g</td></tr>" for d, g in plan["dye_g"].items())
87
+ plan_html = f"""<table><tr><th>For the order</th><th class="num"></th></tr>
88
+ <tr><td>Cotton</td><td class="num">{plan["fabric_kg"]:,} kg</td></tr><tr><td>Dye bath</td><td class="num">{plan["bath_litres"]:,} L</td></tr>
89
+ {dyes}<tr><td>Salt</td><td class="num">{plan["salt_kg"]:,} kg</td></tr><tr><td>Soda ash</td><td class="num">{plan["soda_ash_kg"]:,} kg</td></tr></table>
90
+ <p class="meta">{html.escape(plan["assumptions"])}</p>"""
91
+ return f"""<h2>Order and production plan</h2>
92
+ <p class="meta">Read from the buyer's message by Muse Spark; quantities are plain arithmetic on the approved dip's recipe.</p>
93
+ <div class="grid"><div><table>{rows}</table></div><div>{plan_html}</div></div>"""
94
+
95
+
96
  def _img(jpeg: bytes, width: int) -> str:
97
  return f'<img src="data:image/jpeg;base64,{base64.b64encode(jpeg).decode()}" width="{width}" alt="">'
98
 
99
 
100
  def render(campaign, target_photo: bytes | None = None, agent: str = "", api_calls: int | None = None,
101
+ preview: dict | None = None, order: dict | None = None) -> str:
102
  c = campaign
103
  best = c.best
104
  tol = c.tolerance
 
105
  diff = difference(c.target_lab, best.measurement.lab) if best else None
106
+ paper = best is not None and any("white-paper balance" in f for f in best.measurement.flags)
107
+ u = UNCERTAINTY["paper" if paper else "card"]
108
+ passed = best is not None and best.delta_e <= tol
109
+ certain = best is not None and best.delta_e + u <= tol
110
  when = time.strftime("%d %b %Y, %H:%M")
111
  ref = f"RZ-{c.campaign_id[-8:].upper()}"
112
  rows = []
 
120
  if best is not None:
121
  bath = best.recipe.as_array() / PROFILE.stock_per_l * 1000 # mL of stock per litre of bath
122
  per_kg = bath * LIQUOR_RATIO
123
+ owf = (lambda v: f"<td class='num'>{v * LIQUOR_RATIO / 10:.2f}%</td>") if UNIT == "g/L" else (lambda v: "")
124
+ prod = "".join(f"<tr><td>{html.escape(n)}</td><td class='num'>{v:g} {UNIT}</td>{owf(v)}<td class='num'>{m:.0f} mL</td></tr>"
125
  for n, v, m in zip(DYES, best.recipe.conc, per_kg))
126
+ verdict = ("PASS" if certain else "BORDERLINE" if passed else "NOT YET") if best else "NO DIP"
127
+ options = sorted((s for s in c.shots if s.measurement.ok), key=lambda s: s.delta_e)[:3]
128
+ option_rows = "".join(
129
+ f"<tr><td><b>{tag}</b> · dip {s.index}</td><td><span class='sw' style='background:{lab_to_hex(s.measurement.lab)}'></span>"
130
+ f"{lab_to_hex(s.measurement.lab)}</td><td class='num'>{s.delta_e:.2f}</td>"
131
+ f"<td class='num'>{delta_e_cmc(c.target_lab, s.measurement.lab):.2f}</td>"
132
+ f"<td>{' · '.join(f'{v:g}' for v in s.recipe.conc)}</td></tr>"
133
+ for tag, s in zip("ABC", options))
134
  flags = sorted({f for s in c.shots for f in s.measurement.flags})
135
  notes = "".join(f"<li>{html.escape(f)}</li>" for f in flags) or "<li>No measurement warnings.</li>"
136
  return f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
 
157
  <b>Target</b> {lab_to_hex(c.target_lab)} · L*a*b* {", ".join(f"{v:.1f}" for v in c.target_lab)}
158
  {("<div>" + _img(target_photo, 300) + "</div>") if target_photo else ""}</div>
159
  <div><div class="chip" style="background:{lab_to_hex(best.measurement.lab) if best else "#fff"}"></div>
160
+ <b>Option A: dip {best.index if best else ""}</b> {lab_to_hex(best.measurement.lab) if best else ""}
161
  {("· L*a*b* " + ", ".join(f"{v:.1f}" for v in best.measurement.lab)) if best else ""}</div>
162
  <div><div class="verdict">{verdict}</div>
163
+ {f'<div>ΔE00 <b>{best.delta_e:.2f}</b> ± {u} against a tolerance of {tol} · CMC(2:1) {delta_e_cmc(c.target_lab, best.measurement.lab):.2f}</div><div>ΔL* {diff["dL"]:+.2f} · ΔC* {diff["dC"]:+.2f} · ΔH* {diff["dH"]:+.2f}</div><div>The dip is <b>{html.escape(diff["words"])}</b> than the target.</div>' if diff else ""}
164
+ <div class="meta">{"Within tolerance even allowing for the camera's measurement error." if certain else f"Within tolerance, but the camera's measurement error (±{u}) could take it over: confirm on a spectrophotometer." if passed else ""}</div></div>
165
  </div>
166
 
167
+ {f'''<h2>Options for the buyer</h2>
168
+ <table><tr><th>Option</th><th>Measured</th><th class="num">ΔE00</th><th class="num">CMC(2:1)</th><th>Recipe ({" · ".join(DYES)}, {UNIT})</th></tr>{option_rows}</table>
169
+ <p class="meta">Dye houses usually send two or three dips; these are the closest. Substrate: {html.escape((order or {}).get("fabric") or "white cotton strip")}.
170
+ Standard: {"the customer's photo" if target_photo else "the target colour given"} ({lab_to_hex(c.target_lab)}).</p>
171
+
172
+ <h2>Recipe</h2>
173
+ <table><tr><th>Dye</th><th class="num">Bath concentration</th>{"<th class='num'>% on weight of fabric (1:" + str(LIQUOR_RATIO) + ")</th>" if UNIT == "g/L" else ""}<th class="num">Stock for 1 kg cotton (1:{LIQUOR_RATIO})</th></tr>{prod}</table>
174
  <p class="meta">Dip {best.recipe.dip_seconds:.0f} s. Stock at {PROFILE.stock_per_l:g} {UNIT if UNIT != "drops" else "drops"} per litre. Scale-up assumes the same liquor ratio, temperature and fixation as the lab dip; confirm with a bulk trial.</p>''' if best else ""}
175
 
176
+ {_impact_block(c)}
177
+
178
+ {_order_block(order, best) if order else ""}
179
+
180
  {f"""<h2>Bulk preview</h2>
181
  <div class="grid"><div>{_img(preview["jpeg"], 420)}</div>
182
  <div class="meta">The customer's photo, recoloured by Muse Image to dip {preview["dip"]}'s shade. An image model is not a colour
 
190
  <h2>How it was measured</h2>
191
  <ul class="meta">
192
  <li>Each dip is dried and photographed on white paper; SAM 3.1 segments the strip and the 24-patch colour card, and a colour-correction matrix turns pixels into CIELAB (D65). Uneven light is evened out using the paper.</li>
193
+ <li>Colour difference is CIEDE2000 (and CMC 2:1 alongside); ΔL*, ΔC*, ΔH* are CIELAB components (positive: lighter, brighter, counter-clockwise in hue).</li>
194
+ <li>Measurement error: about ±{UNCERTAINTY["card"]} ΔE00 with the printed colour card and ±{UNCERTAINTY["paper"]} with white paper only (median error on Rangrez's 44-photo test bench, simulated photos). A phone camera sees one light: check metamerism in a light booth under D65 and TL84 before bulk.</li>
195
+ <li>Rangrez is a pre-screen and correction tool that gets a dye house to a good lab dip in fewer rounds; final approval stays with the buyer's instrument or light booth.</li>
196
  <li>Campaign run by {html.escape(agent or "Rangrez")}{f", {api_calls} live Meta API calls" if api_calls is not None else ""}; {len(c.shots)} dips; status {html.escape(c.status)}.</li>
197
  {notes}
198
  </ul>
src/rangrez/samples/kurta.jpg ADDED

Git LFS Details

  • SHA256: ed36e7d4f0797dadd5d628edd3544e21e294a8d4eea6ae2fc7a294547994ba3a
  • Pointer size: 131 Bytes
  • Size of remote file: 341 kB
src/rangrez/samples/kurta.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "lab": [
3
+ 57.64,
4
+ -15.84,
5
+ -12.65
6
+ ],
7
+ "hex": "#5893a0",
8
+ "calibration": "card",
9
+ "concept": "kurta",
10
+ "home_recipe": {
11
+ "drops": {
12
+ "blue": 22,
13
+ "red": 1,
14
+ "yellow": 8
15
+ },
16
+ "water_ml": 20,
17
+ "teaspoons": 4,
18
+ "predicted_de00": 0.3,
19
+ "reachable": true,
20
+ "closest_hex": "#59939f"
21
+ }
22
+ }
src/rangrez/samples/order.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ Hi bhaiya, need 500 kurtas in exactly this teal, cotton poplin, delivery by Friday 14th. Buyer is very strict on shade. Can you send the lab dip by tomorrow?
src/rangrez/samples/runs/teal-kurta/dip01_photo.jpg ADDED

Git LFS Details

  • SHA256: ae5ea41b610215b04deec6af821c06a485790b416d7fbef43a44e9683726455d
  • Pointer size: 131 Bytes
  • Size of remote file: 185 kB
src/rangrez/samples/runs/teal-kurta/dip01_sam.jpg ADDED

Git LFS Details

  • SHA256: ecde7ce655a1f9e43c7991e331ce7005008be524a816ffadd8447686f8c110ef
  • Pointer size: 131 Bytes
  • Size of remote file: 193 kB
src/rangrez/samples/runs/teal-kurta/dip02_photo.jpg ADDED

Git LFS Details

  • SHA256: 92801786e93d1e3f10aeb156d703c237a5cfdb14350b9c0377fe7e97ce276e08
  • Pointer size: 131 Bytes
  • Size of remote file: 171 kB
src/rangrez/samples/runs/teal-kurta/dip02_sam.jpg ADDED

Git LFS Details

  • SHA256: 08a817b17e3b82b3a70abec79a3eebef87167100aff8d8fbc6126ebdaf6bfb2d
  • Pointer size: 131 Bytes
  • Size of remote file: 181 kB
src/rangrez/samples/runs/teal-kurta/events.jsonl ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'kurta'", "seconds": 2.67, "ok": true, "detail": "", "at": 1793621762.353266}
2
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'white paper'", "seconds": 2.63, "ok": true, "detail": "", "at": 1793621766.479362}
3
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'color checker card'", "seconds": 2.71, "ok": true, "detail": "", "at": 1793621770.985225}
4
+ {"kind": "api", "model": "muse-spark-1.3", "purpose": "read the order", "seconds": 4.46, "ok": true, "detail": "", "at": 1793621775.833088}
5
+ {"kind": "start", "target_lab": [57.64, -15.84, -12.65], "target_hex": "#5893a0", "tolerance": 1.0, "agent": "muse-wonder-1.0", "rig": "VirtualRig", "memory_dips": 29, "memory_campaigns": 15, "at": 1793621775.928276}
6
+ {"kind": "api", "model": "muse-wonder-1.0", "purpose": "plan next step", "seconds": 2.43, "ok": true, "detail": "2068 tokens", "at": 1793621778.355718}
7
+ {"kind": "thought", "at": 1793621778.3567061, "text": "Target #5893a0 (Lab 57.64, -15.84, -12.65), tolerance dE00 1.0 \u2014 starting with model suggestions for the first dip."}
8
+ {"kind": "tool", "at": 1793621778.3570712, "name": "get_state", "args": {}}
9
+ {"kind": "api", "model": "muse-wonder-1.0", "purpose": "plan next step", "seconds": 2.76, "ok": true, "detail": "1856 tokens", "at": 1793621781.1219652}
10
+ {"kind": "tool", "at": 1793621781.12275, "name": "suggest_recipes", "args": {}}
11
+ {"kind": "api", "model": "muse-wonder-1.0", "purpose": "plan next step", "seconds": 3.94, "ok": true, "detail": "2384 tokens", "at": 1793621785.1281128}
12
+ {"kind": "thought", "at": 1793621785.128912, "text": "No glimmer offered. Lab_memory's remembered dip was dE00 7.6 away \u2014 outside the ~6 threshold \u2014 so I'll trust model_inverse for the far-from-target first dip: turquoise 3.616, fuchsia 1.173, lemon yellow 1.669."}
13
+ {"kind": "tool", "at": 1793621785.129199, "name": "write_note", "args": {"text": "Shot 1: Hypothesis model_inverse gives close start despite lot error. No glimmer; lab_memory source dip dE00 7.6 (>~6) so not used. Changed: first dip turquoise 3.616, fuchsia 1.173, lemon yellow 1.669. Expect near target but residual lot/saturation error, then fit_model."}}
14
+ {"kind": "note", "at": 1793621785.129335, "who": "agent", "text": "Shot 1: Hypothesis model_inverse gives close start despite lot error. No glimmer; lab_memory source dip dE00 7.6 (>~6) so not used. Changed: first dip turquoise 3.616, fuchsia 1.173, lemon yellow 1.669. Expect near target but residual lot/saturation error, then fit_model."}
15
+ {"kind": "api", "model": "muse-wonder-1.0", "purpose": "plan next step", "seconds": 1.91, "ok": true, "detail": "2255 tokens", "at": 1793621787.042033}
16
+ {"kind": "tool", "at": 1793621787.042832, "name": "run_shot", "args": {"recipe": {"turquoise": 3.616, "fuchsia": 1.173, "lemon yellow": 1.669}, "rationale": "First dip: use model_inverse per rule; lab_memory remembered dip was 7.6 dE00 away (>~6 threshold), no glimmer offered."}}
17
+ {"kind": "dispense", "at": 1793621787.0433102, "recipe": "turquoise 3.62 g/L, fuchsia 1.17 g/L, lemon yellow 1.67 g/L, dip 90 s", "index": 1, "rationale": "First dip: use model_inverse per rule; lab_memory remembered dip was 7.6 dE00 away (>~6 threshold), no glimmer offered."}
18
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'color checker card'", "seconds": 2.02, "ok": true, "detail": "", "at": 1793621789.455463}
19
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'white paper'", "seconds": 0.97, "ok": true, "detail": "", "at": 1793621790.687516}
20
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'fabric'", "seconds": 2.01, "ok": true, "detail": "", "at": 1793621794.4337459}
21
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'fabric swatch'", "seconds": 2.15, "ok": true, "detail": "", "at": 1793621797.301422}
22
+ {"kind": "shot", "at": 1793621797.9141939, "index": 1, "recipe": "turquoise 3.62 g/L, fuchsia 1.17 g/L, lemon yellow 1.67 g/L, dip 90 s", "lab": [58.823361050996795, -14.364486445012759, -10.185166255851286], "hex": "#63969f", "delta_e": 1.9690501181668465, "flags": ["NOTE: evened out uneven light (4.9x across the paper)"], "rationale": "First dip: use model_inverse per rule; lab_memory remembered dip was 7.6 dE00 away (>~6 threshold), no glimmer offered."}
23
+ {"kind": "api", "model": "muse-wonder-1.0", "purpose": "plan next step", "seconds": 2.68, "ok": true, "detail": "2603 tokens", "at": 1793621800.592704}
24
+ {"kind": "thought", "at": 1793621800.593299, "text": "First dip dE00 1.97 \u2014 measured 58.82, -14.36, -10.19 vs target 57.64, -15.84, -12.65: too light, less blue/green. Refitting model to this real lot before correcting."}
25
+ {"kind": "tool", "at": 1793621800.593459, "name": "fit_model", "args": {}}
26
+ {"kind": "model", "at": 1793621800.6247702, "gains_before": {"turquoise": 1.21, "fuchsia": 0.605, "lemon yellow": 0.916}, "gains_after": {"turquoise": 1.202, "fuchsia": 0.604, "lemon yellow": 0.916}, "model_fit_error_de00": 2.46, "shots_used": 30}
27
+ {"kind": "api", "model": "muse-wonder-1.0", "purpose": "plan next step", "seconds": 1.92, "ok": true, "detail": "2620 tokens", "at": 1793621802.545054}
28
+ {"kind": "tool", "at": 1793621802.545749, "name": "get_state", "args": {}}
29
+ {"kind": "api", "model": "muse-wonder-1.0", "purpose": "plan next step", "seconds": 7.11, "ok": true, "detail": "3090 tokens", "at": 1793621809.6604218}
30
+ {"kind": "tool", "at": 1793621809.661202, "name": "suggest_recipes", "args": {}}
31
+ {"kind": "api", "model": "muse-wonder-1.0", "purpose": "plan next step", "seconds": 3.77, "ok": true, "detail": "3575 tokens", "at": 1793621813.4884312}
32
+ {"kind": "thought", "at": 1793621813.4892669, "text": "Best dip is now dE00 1.97 (<6), so per instructions I'll prefer colourist_correction over model_inverse \u2014 it corrects the real dip's measured error."}
33
+ {"kind": "tool", "at": 1793621813.489579, "name": "write_note", "args": {"text": "Shot 2: Hypothesis colourist correction from real dip captures lot error model misses. Changed: use colourist_correction turquoise 4.296, fuchsia 1.084, lemon yellow 1.632 (up turquoise vs shot1 to darken, add green/blue). Expect dE00 move from 1.97 toward <1.0."}}
34
+ {"kind": "note", "at": 1793621813.489713, "who": "agent", "text": "Shot 2: Hypothesis colourist correction from real dip captures lot error model misses. Changed: use colourist_correction turquoise 4.296, fuchsia 1.084, lemon yellow 1.632 (up turquoise vs shot1 to darken, add green/blue). Expect dE00 move from 1.97 toward <1.0."}
35
+ {"kind": "api", "model": "muse-wonder-1.0", "purpose": "plan next step", "seconds": 1.73, "ok": true, "detail": "3499 tokens", "at": 1793621815.2218208}
36
+ {"kind": "tool", "at": 1793621815.222573, "name": "run_shot", "args": {"recipe": {"turquoise": 4.296, "fuchsia": 1.084, "lemon yellow": 1.632}, "rationale": "Best dip within 6 dE00 (1.97), prefer colourist_correction learned from real dip over model_inverse."}}
37
+ {"kind": "dispense", "at": 1793621815.2230558, "recipe": "turquoise 4.30 g/L, fuchsia 1.08 g/L, lemon yellow 1.63 g/L, dip 90 s", "index": 2, "rationale": "Best dip within 6 dE00 (1.97), prefer colourist_correction learned from real dip over model_inverse."}
38
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'color checker card'", "seconds": 1.65, "ok": true, "detail": "", "at": 1793621817.065462}
39
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'white paper'", "seconds": 2.08, "ok": true, "detail": "", "at": 1793621819.398997}
40
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'fabric'", "seconds": 0.75, "ok": true, "detail": "", "at": 1793621821.878986}
41
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'fabric swatch'", "seconds": 1.85, "ok": true, "detail": "", "at": 1793621824.4457579}
42
+ {"kind": "shot", "at": 1793621825.0547872, "index": 2, "recipe": "turquoise 4.30 g/L, fuchsia 1.08 g/L, lemon yellow 1.63 g/L, dip 90 s", "lab": [58.34909827704456, -14.93625994840475, -11.815768969991014], "hex": "#5e95a0", "delta_e": 0.9520448628209843, "flags": ["NOTE: evened out uneven light (4.9x across the paper)"], "rationale": "Best dip within 6 dE00 (1.97), prefer colourist_correction learned from real dip over model_inverse."}
43
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'color checker card'", "seconds": 1.27, "ok": true, "detail": "", "at": 1793621826.485831}
44
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'white paper'", "seconds": 0.88, "ok": true, "detail": "", "at": 1793621827.6305852}
45
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'fabric'", "seconds": 1.88, "ok": true, "detail": "", "at": 1793621831.265545}
46
+ {"kind": "note", "at": 1793621831.979785, "who": "lab", "text": "Confirmed with a second photo: dE00 0.95 and 0.89."}
47
+ {"kind": "status", "at": 1793621831.9799628, "status": "matched", "shot": 2, "delta_e": 0.9201863208202254}
48
+ {"kind": "api", "model": "muse-image-1.0", "purpose": "bulk preview", "seconds": 20.32, "ok": true, "detail": "", "at": 1793621853.0177689}
49
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'kurta'", "seconds": 2.46, "ok": true, "detail": "", "at": 1793621855.67038}
50
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'fabric'", "seconds": 2.05, "ok": true, "detail": "", "at": 1793621857.717909}
51
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'white paper'", "seconds": 1.95, "ok": true, "detail": "", "at": 1793621863.6589718}
52
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'color checker card'", "seconds": 1.3, "ok": true, "detail": "", "at": 1793621866.73721}
53
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'kurta'", "seconds": 2.42, "ok": true, "detail": "", "at": 1793621869.760772}
54
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'white paper'", "seconds": 0.98, "ok": true, "detail": "", "at": 1793621872.9022121}
55
+ {"kind": "api", "model": "sam-3.1", "purpose": "segment 'color checker card'", "seconds": 0.97, "ok": true, "detail": "", "at": 1793621875.646933}
56
+ {"kind": "api", "model": "muse-spark-1.3", "purpose": "reply to the buyer", "seconds": 4.37, "ok": true, "detail": "", "at": 1793621880.415}
src/rangrez/samples/runs/teal-kurta/meta.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "rig": "VirtualRig",
3
+ "scene": "lamp_desk",
4
+ "dyes": [
5
+ "turquoise",
6
+ "fuchsia",
7
+ "lemon yellow"
8
+ ],
9
+ "unit": "g/L",
10
+ "agent": "muse-wonder-1.0",
11
+ "tolerance": 1.0,
12
+ "target_lab": [
13
+ 57.64,
14
+ -15.84,
15
+ -12.65
16
+ ],
17
+ "target_hex": "#5893a0",
18
+ "started": "2026-11-02 17:46:15",
19
+ "title": "Teal kurta for a strict buyer: live SAM 3.1, Muse Spark, Muse Wonder, Muse Image"
20
+ }
src/rangrez/samples/runs/teal-kurta/order.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "message": "Hi bhaiya, need 500 kurtas in exactly this teal, cotton poplin, delivery by Friday 14th. Buyer is very strict on shade. Can you send the lab dip by tomorrow?",
3
+ "order": {
4
+ "deadline": "2026-11-14",
5
+ "fabric": "cotton poplin",
6
+ "garment": "kurta",
7
+ "lab_dip_due": "2026-11-03",
8
+ "notes": "exact teal shade",
9
+ "quantity": 500,
10
+ "quantity_unit": "pieces",
11
+ "shade_strictness": "strict",
12
+ "tolerance_de00": 1.0
13
+ }
14
+ }
src/rangrez/samples/runs/teal-kurta/preview.jpg ADDED

Git LFS Details

  • SHA256: 2fe9a8d5565585de7336bd5ce5f1e6175ff5a8af2853a98eb68d10ac6a8bc82e
  • Pointer size: 131 Bytes
  • Size of remote file: 424 kB
src/rangrez/samples/runs/teal-kurta/reply.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ Hi bhaiya, aapke 500 kurtas cotton poplin wale exact teal ke liye lab-dip report attached hai, saath me usi shade me garment ki photo bhi attached hai. Shade approved hai tolerance me, Delta 0.92 hai limit 1.0 ke andar, target se slightly lighter slightly duller hai. Humne 2 dips banaye the.
src/rangrez/samples/runs/teal-kurta/report.html ADDED
The diff for this file is too large to render. See raw diff
 
src/rangrez/samples/runs/teal-kurta/state.json ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "target_lab": [
3
+ 57.64,
4
+ -15.84,
5
+ -12.65
6
+ ],
7
+ "target_hex": "#5893a0",
8
+ "tolerance_de00": 1.0,
9
+ "shots_used": 2,
10
+ "shots_left": 8,
11
+ "best": {
12
+ "shot": 2,
13
+ "recipe": {
14
+ "turquoise": 4.296,
15
+ "fuchsia": 1.084,
16
+ "lemon yellow": 1.632
17
+ },
18
+ "measured_lab": [
19
+ 58.35,
20
+ -14.94,
21
+ -11.82
22
+ ],
23
+ "lab_sd": [
24
+ 0.13,
25
+ 0.05,
26
+ 0.06
27
+ ],
28
+ "delta_e00": 0.92,
29
+ "flags": [
30
+ "NOTE: evened out uneven light (4.9x across the paper)"
31
+ ]
32
+ },
33
+ "history": [
34
+ {
35
+ "shot": 1,
36
+ "recipe": {
37
+ "turquoise": 3.616,
38
+ "fuchsia": 1.173,
39
+ "lemon yellow": 1.669
40
+ },
41
+ "measured_lab": [
42
+ 58.82,
43
+ -14.36,
44
+ -10.19
45
+ ],
46
+ "lab_sd": [
47
+ 0.18,
48
+ 0.05,
49
+ 0.07
50
+ ],
51
+ "delta_e00": 1.97,
52
+ "flags": [
53
+ "NOTE: evened out uneven light (4.9x across the paper)"
54
+ ]
55
+ },
56
+ {
57
+ "shot": 2,
58
+ "recipe": {
59
+ "turquoise": 4.296,
60
+ "fuchsia": 1.084,
61
+ "lemon yellow": 1.632
62
+ },
63
+ "measured_lab": [
64
+ 58.35,
65
+ -14.94,
66
+ -11.82
67
+ ],
68
+ "lab_sd": [
69
+ 0.13,
70
+ 0.05,
71
+ 0.06
72
+ ],
73
+ "delta_e00": 0.92,
74
+ "flags": [
75
+ "NOTE: evened out uneven light (4.9x across the paper)"
76
+ ]
77
+ }
78
+ ],
79
+ "model_gains": {
80
+ "turquoise": 1.202,
81
+ "fuchsia": 0.604,
82
+ "lemon yellow": 0.916
83
+ },
84
+ "stock_ml": {
85
+ "turquoise": 242.1,
86
+ "fuchsia": 247.7,
87
+ "lemon yellow": 246.7
88
+ },
89
+ "dyer_notes": [],
90
+ "status": "matched"
91
+ }
src/rangrez/samples/runs/teal-kurta/target.jpg ADDED

Git LFS Details

  • SHA256: ed36e7d4f0797dadd5d628edd3544e21e294a8d4eea6ae2fc7a294547994ba3a
  • Pointer size: 131 Bytes
  • Size of remote file: 341 kB
src/rangrez/samples/runs/teal-kurta/target.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "lab": [
3
+ 57.64,
4
+ -15.84,
5
+ -12.65
6
+ ],
7
+ "hex": "#5893a0",
8
+ "calibration": "card",
9
+ "concept": "kurta",
10
+ "home_recipe": {
11
+ "drops": {
12
+ "blue": 22,
13
+ "red": 1,
14
+ "yellow": 8
15
+ },
16
+ "water_ml": 20,
17
+ "teaspoons": 4,
18
+ "predicted_de00": 0.3,
19
+ "reachable": true,
20
+ "closest_hex": "#59939f"
21
+ }
22
+ }
src/rangrez/server.py CHANGED
@@ -19,6 +19,7 @@ import numpy as np
19
  from dotenv import load_dotenv
20
  from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
21
  from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse
 
22
  from pydantic import BaseModel
23
 
24
  from .agent import WONDER_MODEL, run_deterministic, run_with_wonder
@@ -35,6 +36,7 @@ load_dotenv()
35
  STATIC = Path(__file__).parent / "static"
36
 
37
  app = FastAPI(title="Rangrez")
 
38
 
39
 
40
  class Lab:
@@ -52,6 +54,9 @@ class Lab:
52
  self.target_photo: bytes | None = None # the customer's photo the current target came from
53
  self.target_concept = "garment"
54
  self.preview: dict | None = None # Muse Image's bulk preview, measured and corrected
 
 
 
55
  self.log.listeners.append(lambda call: self.publish({
56
  "kind": "api", "model": call.model, "purpose": call.purpose,
57
  "seconds": round(call.seconds, 2), "ok": call.ok, "detail": call.detail, "at": time.time(),
@@ -127,7 +132,8 @@ class Lab:
127
  c = self.campaign
128
  calls = sum(1 for e in self.history if e.get("kind") == "api")
129
  agent = next((e.get("agent") for e in self.history if e.get("kind") == "start"), "")
130
- return render(c, target_photo=self.target_photo, agent=agent, api_calls=calls, preview=self.preview)
 
131
 
132
  def subscribe(self) -> queue.Queue:
133
  """A new page (a reload, the phone joining) first gets the campaign so far."""
@@ -144,6 +150,9 @@ class Lab:
144
  live = self.online if live is None else live
145
  if self.thread and self.thread.is_alive():
146
  raise HTTPException(409, "a campaign is already running")
 
 
 
147
  with self.lock: # keep only the API calls made since the last start (e.g. reading the target photo)
148
  self.history = [e for e in self.history if e.get("kind") == "api" and e.get("at", 0) >= self.since]
149
  self.since = time.time()
@@ -326,18 +335,19 @@ def assist_webcam() -> dict:
326
 
327
 
328
  @app.post("/api/voice")
329
- async def voice(audio: UploadFile = File(...), passcode: str = Form("")) -> dict:
330
  """One spoken turn from the hands-free dashboard: transcribe it, then do at most one safe thing."""
331
- from .voice import intent, transcribe
332
 
333
  if not LAB.live(passcode):
334
  raise HTTPException(403, "voice needs the Meta models: set MODEL_API_KEY, or enter the live passcode")
 
335
  wav = await audio.read()
336
  try:
337
- text = await asyncio.to_thread(transcribe, wav, LAB.log)
338
  except RuntimeError as exc:
339
  raise HTTPException(502, str(exc))
340
- act = intent(text)
341
  c, result = LAB.campaign, ""
342
  rig = getattr(c, "rig", None)
343
  waiting = getattr(rig, "waiting", None)
@@ -345,25 +355,65 @@ async def voice(audio: UploadFile = File(...), passcode: str = Form("")) -> dict
345
  if waiting and hasattr(rig, "capture"):
346
  try:
347
  rig.provide(await asyncio.to_thread(rig.capture))
348
- result = "Photo taken. Measuring."
349
- except (RuntimeError, ValueError) as exc:
350
- result = f"Could not take the photo ({exc}). Send one from your phone."
351
  else:
352
- result = "Nothing to photograph right now."
353
  elif act == "stop":
354
  stop()
355
- result = "Stopped."
356
  elif act == "repeat":
357
- result = speakable(waiting) if waiting else "No dip is waiting."
358
  elif act == "note" and c is not None:
359
  c.note(text, who="dyer")
360
- result = "Noted for the colourist."
361
  return {"transcript": text, "intent": act, "result": result}
362
 
363
 
364
- def speakable(w: dict) -> str:
365
- amounts = ", ".join(f"{v} {w['unit']} of {d}" for d, v in w["amounts"].items() if float(v) > 0)
366
- return f"Dip {w['index']}. {amounts}. Dip for {w['dip_seconds']} seconds. Say done when the strip is dry and on the paper."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
 
368
 
369
  @app.post("/api/preview")
@@ -388,6 +438,8 @@ async def preview(passcode: str = Form("")) -> dict:
388
 
389
  @app.get("/api/preview.jpg")
390
  def preview_image() -> Response:
 
 
391
  if not LAB.preview:
392
  raise HTTPException(404)
393
  return Response(LAB.preview["jpeg"], media_type="image/jpeg")
@@ -395,6 +447,8 @@ def preview_image() -> Response:
395
 
396
  @app.post("/api/stop")
397
  def stop() -> dict:
 
 
398
  c = LAB.campaign
399
  if c is None:
400
  return {"ok": True}
@@ -413,13 +467,82 @@ def state() -> dict:
413
 
414
  @app.get("/api/report.html")
415
  def report() -> HTMLResponse:
 
 
416
  if LAB.campaign is None or not LAB.campaign.shots:
417
  raise HTTPException(404, "no dips yet")
418
  return HTMLResponse(LAB.report())
419
 
420
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
421
  @app.get("/api/shot/{index}.jpg")
422
  def shot_image(index: int, raw: bool = False) -> Response:
 
 
 
 
 
423
  c = LAB.campaign
424
  if not c or not 1 <= index <= len(c.shots):
425
  raise HTTPException(404)
 
19
  from dotenv import load_dotenv
20
  from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
21
  from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse
22
+ from fastapi.staticfiles import StaticFiles
23
  from pydantic import BaseModel
24
 
25
  from .agent import WONDER_MODEL, run_deterministic, run_with_wonder
 
36
  STATIC = Path(__file__).parent / "static"
37
 
38
  app = FastAPI(title="Rangrez")
39
+ app.mount("/static", StaticFiles(directory=STATIC), name="static")
40
 
41
 
42
  class Lab:
 
54
  self.target_photo: bytes | None = None # the customer's photo the current target came from
55
  self.target_concept = "garment"
56
  self.preview: dict | None = None # Muse Image's bulk preview, measured and corrected
57
+ self.order: dict | None = None # the buyer's order, read by Muse Spark
58
+ self.order_message = ""
59
+ self.replay = None # a recorded live run being shown again
60
  self.log.listeners.append(lambda call: self.publish({
61
  "kind": "api", "model": call.model, "purpose": call.purpose,
62
  "seconds": round(call.seconds, 2), "ok": call.ok, "detail": call.detail, "at": time.time(),
 
132
  c = self.campaign
133
  calls = sum(1 for e in self.history if e.get("kind") == "api")
134
  agent = next((e.get("agent") for e in self.history if e.get("kind") == "start"), "")
135
+ return render(c, target_photo=self.target_photo, agent=agent, api_calls=calls, preview=self.preview,
136
+ order=self.order)
137
 
138
  def subscribe(self) -> queue.Queue:
139
  """A new page (a reload, the phone joining) first gets the campaign so far."""
 
150
  live = self.online if live is None else live
151
  if self.thread and self.thread.is_alive():
152
  raise HTTPException(409, "a campaign is already running")
153
+ if self.replay is not None:
154
+ self.replay.stop()
155
+ self.replay = None
156
  with self.lock: # keep only the API calls made since the last start (e.g. reading the target photo)
157
  self.history = [e for e in self.history if e.get("kind") == "api" and e.get("at", 0) >= self.since]
158
  self.since = time.time()
 
335
 
336
 
337
  @app.post("/api/voice")
338
+ async def voice(audio: UploadFile = File(...), passcode: str = Form(""), lang: str = Form("en")) -> dict:
339
  """One spoken turn from the hands-free dashboard: transcribe it, then do at most one safe thing."""
340
+ from .voice import SAY, instructions, intent, transcribe
341
 
342
  if not LAB.live(passcode):
343
  raise HTTPException(403, "voice needs the Meta models: set MODEL_API_KEY, or enter the live passcode")
344
+ lang = lang if lang in SAY else "en"
345
  wav = await audio.read()
346
  try:
347
+ text = await asyncio.to_thread(transcribe, wav, LAB.log, lang)
348
  except RuntimeError as exc:
349
  raise HTTPException(502, str(exc))
350
+ act, say = intent(text), SAY[lang]
351
  c, result = LAB.campaign, ""
352
  rig = getattr(c, "rig", None)
353
  waiting = getattr(rig, "waiting", None)
 
355
  if waiting and hasattr(rig, "capture"):
356
  try:
357
  rig.provide(await asyncio.to_thread(rig.capture))
358
+ result = say["photo"]
359
+ except (RuntimeError, ValueError):
360
+ result = say["photo_failed"]
361
  else:
362
+ result = say["no_photo"]
363
  elif act == "stop":
364
  stop()
365
+ result = say["stopped"]
366
  elif act == "repeat":
367
+ result = instructions(waiting, lang) if waiting else say["no_dip"]
368
  elif act == "note" and c is not None:
369
  c.note(text, who="dyer")
370
+ result = say["noted"]
371
  return {"transcript": text, "intent": act, "result": result}
372
 
373
 
374
+ @app.post("/api/order")
375
+ async def read_order(message: str = Form(...), passcode: str = Form("")) -> dict:
376
+ """The buyer's chat message, read by Muse Spark: what, how much, by when, how strict the shade."""
377
+ from .order import parse_order
378
+
379
+ if not LAB.live(passcode):
380
+ raise HTTPException(403, "reading an order needs the Meta models: set MODEL_API_KEY, or enter the live passcode")
381
+ try:
382
+ LAB.order = await asyncio.to_thread(parse_order, message[:2000], LAB.log)
383
+ except RuntimeError as exc:
384
+ raise HTTPException(502, str(exc))
385
+ LAB.order_message = message[:2000]
386
+ LAB.rec.stash("order.json", {"message": message[:2000], "order": LAB.order})
387
+ return LAB.order
388
+
389
+
390
+ @app.post("/api/reply")
391
+ async def reply(passcode: str = Form("")) -> dict:
392
+ """Draft the chat reply to the buyer, in their language, from the lab's own numbers."""
393
+ from .order import draft_reply, production_plan
394
+ from .report import difference
395
+
396
+ c = LAB.campaign
397
+ if c is None or c.best is None or not LAB.order_message:
398
+ raise HTTPException(409, "needs the buyer's message and a campaign with at least one dip")
399
+ if not LAB.live(passcode):
400
+ raise HTTPException(403, "drafting needs the Meta models: set MODEL_API_KEY, or enter the live passcode")
401
+ best = c.best
402
+ d = difference(c.target_lab, best.measurement.lab)
403
+ plan = production_plan(LAB.order, best.recipe.conc) if LAB.order else None
404
+ facts = {
405
+ "result": "approved shade, within tolerance" if c.status == "matched" else f"closest dip so far ({c.status})",
406
+ "delta_e00": round(best.delta_e, 2), "tolerance_de00": c.tolerance, "dip_vs_target": d["words"],
407
+ "dips_made": len(c.shots), "order": LAB.order,
408
+ "production_plan": plan and {"cotton_kg": plan["fabric_kg"], "dye_bath_litres": plan["bath_litres"]},
409
+ "preview_photo_attached": bool(LAB.preview),
410
+ }
411
+ try:
412
+ text = await asyncio.to_thread(draft_reply, LAB.order_message, facts, LAB.log)
413
+ except RuntimeError as exc:
414
+ raise HTTPException(502, str(exc))
415
+ LAB.rec.save("reply.txt", text)
416
+ return {"reply": text}
417
 
418
 
419
  @app.post("/api/preview")
 
438
 
439
  @app.get("/api/preview.jpg")
440
  def preview_image() -> Response:
441
+ if LAB.replay is not None and (data := LAB.replay.file("preview.jpg")):
442
+ return Response(data, media_type="image/jpeg")
443
  if not LAB.preview:
444
  raise HTTPException(404)
445
  return Response(LAB.preview["jpeg"], media_type="image/jpeg")
 
447
 
448
  @app.post("/api/stop")
449
  def stop() -> dict:
450
+ if LAB.replay is not None:
451
+ LAB.replay.stop()
452
  c = LAB.campaign
453
  if c is None:
454
  return {"ok": True}
 
467
 
468
  @app.get("/api/report.html")
469
  def report() -> HTMLResponse:
470
+ if LAB.replay is not None and (page := LAB.replay.file("report.html")):
471
+ return HTMLResponse(page)
472
  if LAB.campaign is None or not LAB.campaign.shots:
473
  raise HTTPException(404, "no dips yet")
474
  return HTMLResponse(LAB.report())
475
 
476
 
477
+ @app.get("/api/samples")
478
+ def samples() -> dict:
479
+ """What a first-time visitor can try without bringing anything: a photo, a buyer's message, recorded runs."""
480
+ from .replay import SAMPLES, runs
481
+
482
+ order = SAMPLES / "order.txt"
483
+ read = SAMPLES / "kurta.json" # how live SAM 3.1 read the sample photo, for visitors without the passcode
484
+ return {"photo": "/api/samples/kurta.jpg", "concept": "kurta",
485
+ "read": json.loads(read.read_text()) if read.exists() else None,
486
+ "order": order.read_text().strip() if order.exists() else "", "runs": runs()}
487
+
488
+
489
+ @app.get("/api/samples/kurta.jpg")
490
+ def sample_photo() -> Response:
491
+ from .replay import SAMPLES
492
+
493
+ return Response((SAMPLES / "kurta.jpg").read_bytes(), media_type="image/jpeg")
494
+
495
+
496
+ @app.post("/api/replay/{name}")
497
+ def replay(name: str) -> dict:
498
+ """Show a recorded live run again: every Meta API call in it happened for real."""
499
+ from .replay import Replay
500
+
501
+ if LAB.thread and LAB.thread.is_alive():
502
+ raise HTTPException(409, "a campaign is running: watch it, or stop it first")
503
+ if LAB.replay is not None:
504
+ LAB.replay.stop()
505
+ try:
506
+ r = Replay(name, LAB.publish)
507
+ except FileNotFoundError:
508
+ raise HTTPException(404, "no such recorded run")
509
+ with LAB.lock:
510
+ LAB.history = []
511
+ LAB.campaign = None
512
+ LAB.replay = r
513
+ r.start()
514
+ return {"ok": True, "recorded": r.meta.get("started", "")}
515
+
516
+
517
+ @app.get("/api/replay-file/{name}")
518
+ def replay_file(name: str) -> Response:
519
+ """What else a recorded run produced: the buyer's order and the reply Muse Spark drafted."""
520
+ if LAB.replay is None or name not in ("order.json", "reply.txt", "target.json"):
521
+ raise HTTPException(404)
522
+ data = LAB.replay.file(name)
523
+ if data is None:
524
+ raise HTTPException(404)
525
+ return Response(data, media_type="application/json" if name.endswith(".json") else "text/plain; charset=utf-8")
526
+
527
+
528
+ @app.get("/api/memory")
529
+ def memory() -> dict:
530
+ """How the lab has done with this dye lot, campaign by campaign (the night shift, live)."""
531
+ path = default_path()
532
+ m = LabMemory(path) if path is not None else None
533
+ if m is None:
534
+ return {"dips": 0, "campaigns": []}
535
+ keep = ("status", "dips", "first_de00", "best_de00")
536
+ return {"dips": len(m.dips), "campaigns": [{k: c.get(k) for k in keep} for c in m.campaigns[-60:]]}
537
+
538
+
539
  @app.get("/api/shot/{index}.jpg")
540
  def shot_image(index: int, raw: bool = False) -> Response:
541
+ if LAB.replay is not None:
542
+ data = LAB.replay.file(f"dip{index:02d}_{'photo' if raw else 'sam'}.jpg")
543
+ if data is None:
544
+ raise HTTPException(404)
545
+ return Response(data, media_type="image/jpeg")
546
  c = LAB.campaign
547
  if not c or not 1 <= index <= len(c.shots):
548
  raise HTTPException(404)
src/rangrez/static/dyehouse.jpg ADDED

Git LFS Details

  • SHA256: 18d9dad0b9eac08ff9409e12937f74c9f33cff5a5807015fb5959840d7bb3899
  • Pointer size: 131 Bytes
  • Size of remote file: 194 kB
src/rangrez/static/index.html CHANGED
@@ -3,260 +3,669 @@
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>Rangrez — self-driving dye lab</title>
 
 
 
 
7
  <style>
8
  :root {
9
- --bg: #0f1012; --panel: #17191c; --line: #2a2d32; --ink: #eceae6; --mute: #9a968f;
10
- --accent: #e8a33d; --ok: #3fb97f; --warn: #e2b43c; --bad: #e0614f; --cyan: #33d6e8;
 
11
  }
12
  * { box-sizing: border-box; }
13
- body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
14
- header { display: flex; align-items: baseline; gap: 14px; padding: 16px 22px; border-bottom: 1px solid var(--line); flex-wrap: wrap; }
15
- header h1 { margin: 0; font-size: 22px; letter-spacing: .5px; }
16
- header .sub { color: var(--mute); }
17
- .pill { padding: 3px 10px; border-radius: 99px; border: 1px solid var(--line); font-size: 12px; color: var(--mute); }
18
- .pill.running { color: var(--accent); border-color: var(--accent); }
19
- .pill.matched { color: var(--ok); border-color: var(--ok); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  .pill.out_of_gamut { color: var(--warn); border-color: var(--warn); }
21
  .pill.escalated, .pill.error { color: var(--bad); border-color: var(--bad); }
22
- .spacer { flex: 1; }
23
- main { display: grid; grid-template-columns: 300px minmax(0, 1fr) 360px; gap: 14px; padding: 14px 22px; }
24
- @media (max-width: 1100px) { main { grid-template-columns: 1fr; } }
25
- section { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px; min-width: 0; }
26
- h2 { margin: 0 0 10px; font-size: 12px; text-transform: uppercase; letter-spacing: 1px; color: var(--mute); font-weight: 600; }
27
- .stack > * + * { margin-top: 14px; }
28
- label { display: block; color: var(--mute); font-size: 12px; margin-bottom: 4px; }
29
- input[type=text], input[type=number] { width: 100%; background: #0c0d0f; color: var(--ink); border: 1px solid var(--line); border-radius: 6px; padding: 7px 9px; font: inherit; }
30
- input[type=color] { width: 100%; height: 44px; border: 1px solid var(--line); border-radius: 6px; background: none; padding: 2px; }
31
- button { width: 100%; background: var(--accent); color: #1a1206; border: 0; border-radius: 7px; padding: 10px; font-weight: 700; font-size: 14px; cursor: pointer; }
32
- button.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); font-weight: 500; }
33
- button:disabled { opacity: .5; cursor: default; }
34
- .row { display: flex; gap: 8px; }
35
- .compare { display: grid; grid-template-columns: 1fr auto 1fr; gap: 14px; align-items: stretch; }
36
- .chip { border-radius: 10px; min-height: 150px; border: 1px solid var(--line); display: flex; flex-direction: column; justify-content: flex-end; padding: 10px; transition: background .6s; }
37
- .chip span { background: rgba(0,0,0,.55); padding: 3px 8px; border-radius: 5px; align-self: flex-start; font-size: 12px; font-variant-numeric: tabular-nums; }
38
- .de { display: flex; flex-direction: column; align-items: center; justify-content: center; min-width: 120px; }
39
- .de b { font-size: 44px; font-variant-numeric: tabular-nums; line-height: 1; }
40
- .de small { color: var(--mute); margin-top: 6px; text-align: center; }
41
- .de.pass b { color: var(--ok); }
42
- #previewImg { width: 100%; border-radius: 8px; border: 1px solid var(--line); margin-top: 8px; }
43
- #frame { width: 100%; border-radius: 8px; border: 1px solid var(--line); background: #000; aspect-ratio: 16/9; object-fit: contain; }
44
- svg { width: 100%; height: 170px; display: block; }
45
- table { width: 100%; border-collapse: collapse; font-size: 12px; font-variant-numeric: tabular-nums; }
46
- td, th { padding: 5px 4px; border-bottom: 1px solid var(--line); text-align: left; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  th { color: var(--mute); font-weight: 500; }
48
- .hint .sw { display: inline-block; width: 12px; height: 12px; border-radius: 3px; vertical-align: -2px; margin: 0 4px; border: 1px solid #0006; }
49
- td .sw { display: inline-block; width: 14px; height: 14px; border-radius: 3px; vertical-align: -3px; margin-right: 6px; border: 1px solid #0006; }
50
- .feed { max-height: 280px; overflow: auto; font-size: 12.5px; }
51
  .feed div { padding: 6px 0; border-bottom: 1px dashed var(--line); }
52
- .feed .who { color: var(--accent); font-weight: 600; margin-right: 6px; }
53
  .feed .tool { color: var(--cyan); }
54
  .feed .gate { color: var(--bad); }
55
  .feed .muted { color: var(--mute); }
56
  .api div { display: grid; grid-template-columns: 1fr auto; gap: 8px; }
57
- .api .m { color: var(--cyan); }
58
- .hint { color: var(--mute); font-size: 12px; }
59
- .assist { border-color: var(--accent); }
60
- .assist[hidden] { display: none; }
61
- .amounts { display: flex; gap: 10px; flex-wrap: wrap; margin: 4px 0 12px; }
62
- .amount { flex: 1; min-width: 90px; border: 1px solid var(--line); border-radius: 8px; padding: 10px; text-align: center; }
63
- .amount b { display: block; font-size: 30px; font-variant-numeric: tabular-nums; }
64
- .amount small { color: var(--mute); }
65
- .assist ol { margin: 0 0 12px; padding-left: 20px; }
66
- .assist li { margin: 3px 0; }
67
- .pill.waiting { color: var(--cyan); border-color: var(--cyan); }
68
- .pill.stopped { color: var(--mute); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  </style>
70
  </head>
71
  <body>
72
  <header>
73
- <h1>Rangrez</h1>
74
- <span class="sub">a self-driving dye lab on Meta models</span>
 
 
 
75
  <span class="spacer"></span>
76
- <span class="pill" id="agent">agent: …</span>
77
- <span class="pill" id="rig">rig: …</span>
78
- <span class="pill" id="memory">lab memory</span>
79
  <span class="pill" id="status">idle</span>
 
80
  </header>
81
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  <main>
83
- <div class="stack">
84
- <section>
85
- <h2>Target shade</h2>
86
- <label for="picker">Pick a colour</label>
87
- <input type="color" id="picker" value="#4b909d">
88
- <div style="height:10px"></div>
89
- <label for="photo">…or match an object in a photo</label>
90
- <input type="file" id="photo" accept="image/*">
91
- <div style="height:6px"></div>
92
- <input type="text" id="concept" value="kurta" aria-label="Object to match in the photo">
93
- <div style="height:8px"></div>
94
- <button class="ghost" id="usePhoto">Read colour with SAM 3.1</button>
 
95
  <p class="hint" id="photoNote"></p>
96
  <p class="hint" id="homeRecipe" hidden></p>
97
- <label for="tol">Pass tolerance (CIEDE2000)</label>
98
- <input type="number" id="tol" value="1.5" step="0.1" min="0.5" max="5">
99
- <div style="height:12px"></div>
100
- <div id="liveBox" hidden>
101
- <label for="passcode">Live passcode (runs SAM 3.1 and Muse Wonder; without it, the offline policy)</label>
102
- <input type="password" id="passcode" autocomplete="off">
103
- <div style="height:12px"></div>
104
- </div>
105
- <button id="start">Start campaign</button>
106
- <div style="height:8px"></div>
107
- <button class="ghost" id="stop">Stop</button>
108
- <div style="height:8px"></div>
109
- <button class="ghost" id="handsFree" aria-pressed="false">Hands-free voice: off</button>
110
- <p class="hint" id="voiceNote">Say "done" when the strip is dry, "repeat", or "stop". Anything else goes to the colourist's notebook.</p>
 
 
 
 
 
 
111
  </section>
 
112
  <section>
113
- <h2>Meta API calls</h2>
114
- <div class="feed api" id="api"><div class="muted">No calls yet.</div></div>
115
  </section>
116
  </div>
117
 
118
- <div class="stack">
119
  <section class="assist" id="assist" hidden>
120
  <h2 id="assistTitle">Your turn: dip 1</h2>
121
  <div class="amounts" id="amounts"></div>
122
  <ol id="assistSteps"></ol>
123
- <label for="assistPhoto">Photo of the dried strip next to the colour card or on white paper</label>
124
  <input type="file" id="assistPhoto" accept="image/*" capture="environment">
125
- <div style="height:8px"></div>
126
- <div class="row"><button id="sendPhoto">Send photo</button><button class="ghost" id="useWebcam">Use webcam</button></div>
127
  <p class="hint" id="assistNote"></p>
128
  </section>
129
- <section>
130
- <h2>Latest dip vs target</h2>
131
- <div class="compare">
132
- <div class="chip" id="targetChip"><span id="targetLabel">target</span></div>
133
- <div class="de" id="deBox"><b id="de">—</b><small id="deNote">CIEDE2000</small></div>
134
- <div class="chip" id="dipChip"><span id="dipLabel">no dip yet</span></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  </div>
136
- <p class="hint"><a id="reportLink" href="/api/report.html" target="_blank" rel="noopener" hidden>Lab-dip report for the buyer ↗</a></p>
137
- <button class="ghost" id="previewBtn" hidden>Preview the customer's garment in this shade (Muse Image)</button>
138
  <p class="hint" id="previewNote"></p>
139
  <img id="previewImg" hidden alt="The customer's garment recoloured to the dip's shade">
140
- </section>
141
- <section>
142
- <h2>Camera — SAM 3.1 masks: card (yellow), strip (cyan)</h2>
143
- <img id="frame" hidden alt="Latest camera frame from the rig with segmentation boxes">
144
  </section>
145
  </div>
146
 
147
- <div class="stack">
148
- <section>
149
- <h2>Convergence</h2>
150
- <svg id="chart" viewBox="0 0 340 170" role="img" aria-label="Colour difference per dip"></svg>
 
 
 
 
 
 
 
 
151
  </section>
152
- <section>
153
- <h2>Dips</h2>
154
- <table><thead><tr><th>#</th><th>recipe (<span id="dyeHead">dyes</span>)</th><th>ΔE00</th></tr></thead><tbody id="shots"></tbody></table>
 
 
155
  </section>
156
- <section>
157
- <h2>Lab notebook</h2>
158
- <div class="feed" id="notebook"><div class="muted">The agent writes here.</div></div>
 
 
 
 
 
 
 
 
159
  </section>
160
  </div>
161
  </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
  <script>
164
  const $ = (id) => document.getElementById(id);
165
- let tolerance = 1.5, shots = [];
166
-
167
- function labToHex([L, a, b]) {
168
- const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200, d = 6 / 29;
169
- const f = (t) => (t > d ? t ** 3 : 3 * d * d * (t - 4 / 29));
170
- const X = 0.95047 * f(fx), Y = f(fy), Z = 1.08883 * f(fz);
171
- let r = 3.2404542 * X - 1.5371385 * Y - 0.4985314 * Z;
172
- let g = -0.969266 * X + 1.8760108 * Y + 0.041556 * Z;
173
- let bl = 0.0556434 * X - 0.2040259 * Y + 1.0572252 * Z;
174
- const gam = (c) => Math.round(255 * Math.min(1, Math.max(0, c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055)));
175
- return "#" + [r, g, bl].map(gam).map((v) => v.toString(16).padStart(2, "0")).join("");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  }
177
 
178
  fetch("/api/config").then((r) => r.json()).then((c) => {
 
179
  $("agent").textContent = "agent: " + c.agent;
180
- $("rig").textContent = "rig: " + ({ sim: "simulator", serial: "hardware", assisted: "assisted (your hands)" }[c.rig] || c.rig);
181
  $("dyeHead").textContent = c.dyes.join(" · ") + " " + c.unit;
182
  $("liveBox").hidden = !c.passcode;
183
- if (c.public_reads_left > 0) $("photoNote").textContent = "Try it: photograph any outfit and get its colour, plus a recipe to dye it at home.";
184
  });
 
 
 
 
 
 
 
 
 
 
185
 
186
  function setStatus(s) {
187
- const el = $("status");
188
- el.textContent = s;
189
- el.className = "pill " + s;
190
  $("start").disabled = s === "running" || s === "waiting";
191
  }
192
-
193
  function feed(id, html) {
194
  const box = $(id);
195
  if (box.firstElementChild && box.firstElementChild.classList.contains("muted") && box.children.length === 1) box.innerHTML = "";
196
- const div = document.createElement("div");
197
- div.innerHTML = html;
198
- box.prepend(div);
199
  }
200
- const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
201
 
202
  function drawChart() {
203
- const svg = $("chart"), w = 340, h = 170, pad = 28;
204
  const max = Math.max(8, ...shots.map((s) => s.delta_e || 0));
205
  const x = (i) => pad + (i / Math.max(1, Math.max(5, shots.length) - 1)) * (w - pad - 10);
206
  const y = (v) => h - pad + 4 - (Math.min(v, max) / max) * (h - pad - 14);
207
- let g = `<line x1="${pad}" y1="${y(tolerance)}" x2="${w - 10}" y2="${y(tolerance)}" stroke="#3fb97f" stroke-dasharray="4 4"/>`;
208
- g += `<text x="${w - 12}" y="${y(tolerance) - 5}" fill="#3fb97f" font-size="10" text-anchor="end">pass ${tolerance}</text>`;
209
- g += `<text x="${pad - 6}" y="${y(max) + 4}" fill="#9a968f" font-size="10" text-anchor="end">${max.toFixed(0)}</text>`;
210
- g += `<text x="${pad - 6}" y="${y(0) + 4}" fill="#9a968f" font-size="10" text-anchor="end">0</text>`;
211
  const pts = shots.map((s, i) => [x(i), y(s.delta_e)]);
212
- if (pts.length > 1) g += `<polyline fill="none" stroke="#e8a33d" stroke-width="2" points="${pts.map((p) => p.join(",")).join(" ")}"/>`;
213
  shots.forEach((s, i) => {
214
- g += `<circle cx="${pts[i][0]}" cy="${pts[i][1]}" r="5" fill="${s.hex}" stroke="#eceae6" stroke-width="1.5"/>`;
215
- g += `<text x="${pts[i][0]}" y="${h - 8}" fill="#9a968f" font-size="10" text-anchor="middle">${i + 1}</text>`;
216
  });
 
217
  svg.innerHTML = g;
218
  }
219
 
 
 
 
 
 
 
 
 
 
 
220
  const es = new EventSource("/api/events");
221
  es.onmessage = (msg) => {
222
  const e = JSON.parse(msg.data);
223
  if (e.kind === "replayed") { replaying = false; return; }
224
- if (e.kind === "reset") { // the server replays this campaign from the start
225
- replaying = true;
226
- shots = [];
227
- for (const id of ["shots", "notebook", "api"]) $(id).innerHTML = "";
228
- $("reportLink").hidden = true; $("previewBtn").hidden = true; $("previewImg").hidden = true; $("previewNote").textContent = "";
229
- $("assist").hidden = true; $("frame").hidden = true; drawChart(); setStatus("idle");
230
- } else if (e.kind === "start") {
231
  $("agent").textContent = "agent: " + e.agent;
232
  $("memory").textContent = `lab memory: ${e.memory_dips || 0} dips · ${e.memory_campaigns || 0} campaigns`;
233
- shots = []; tolerance = e.tolerance;
234
- $("shots").innerHTML = ""; $("notebook").innerHTML = ""; drawChart();
235
- $("targetChip").style.background = e.target_hex;
236
- $("targetLabel").textContent = `target ${e.target_hex} · L*a*b* ${e.target_lab.map((v) => v.toFixed(1)).join(", ")}`;
237
- $("dipChip").style.background = "transparent"; $("dipLabel").textContent = "no dip yet";
238
- $("de").textContent = "—"; $("deBox").classList.remove("pass");
239
- setStatus("running");
240
  } else if (e.kind === "assist") {
241
- if (handsFree && !replaying) say(`Dip ${e.index}. ` + Object.entries(e.amounts).filter(([, v]) => +v > 0).map(([d, v]) => `${v} ${e.unit} of ${d}`).join(", ") + `. Dip for ${e.dip_seconds} seconds. Say done when the strip is dry and on the paper.`);
242
- $("assist").hidden = false;
243
- $("assistTitle").textContent = `Your turn: dip ${e.index}`;
244
  $("amounts").innerHTML = Object.entries(e.amounts).map(([d, v]) => `<div class="amount"><b>${v}</b><small>${e.unit} ${esc(d)}</small></div>`).join("");
245
  $("assistSteps").innerHTML = e.steps.map((s) => `<li>${esc(s)}</li>`).join("");
246
- $("assistNote").textContent = "";
247
- $("assistPhoto").value = "";
248
- setStatus("waiting");
249
  } else if (e.kind === "shot") {
250
  $("assist").hidden = true;
251
  if ($("status").textContent === "waiting") setStatus("running");
252
- shots.push(e); drawChart();
253
- $("dipChip").style.background = e.hex;
254
- $("dipLabel").textContent = `dip ${e.index} · ${e.hex}`;
255
- $("de").textContent = isFinite(e.delta_e) ? e.delta_e.toFixed(2) : "—";
256
- $("deBox").classList.toggle("pass", e.delta_e <= tolerance);
257
- $("deNote").textContent = e.delta_e <= tolerance ? "CIEDE2000 · matched" : "CIEDE2000";
258
- $("frame").src = `/api/shot/${e.index}.jpg?t=${Date.now()}`; $("frame").hidden = false;
259
- const rec = e.recipe.match(/[\d.]+(?= (g\/L|%))/g) || [];
260
  $("shots").insertAdjacentHTML("beforeend", `<tr><td>${e.index}</td><td><span class="sw" style="background:${e.hex}"></span>${rec.join(" · ")}</td><td>${isFinite(e.delta_e) ? e.delta_e.toFixed(2) : "rejected"}</td></tr>`);
261
  if (e.flags && e.flags.length) feed("notebook", `<span class="gate">${esc(e.flags.join("; "))}</span>`);
262
  } else if (e.kind === "note") {
@@ -266,50 +675,112 @@ es.onmessage = (msg) => {
266
  } else if (e.kind === "tool") {
267
  feed("notebook", `<span class="tool">→ ${esc(e.name)}</span> <span class="muted">${esc(JSON.stringify(e.args)).slice(0, 160)}</span>`);
268
  } else if (e.kind === "gate") {
269
- feed("notebook", `<span class="gate">gate refused: ${esc(e.refused)}</span> <span class="muted">${esc(e.recipe)}</span>`);
270
  } else if (e.kind === "dispense") {
271
  feed("notebook", `<span class="muted">dispensing dip ${e.index}: ${esc(e.recipe)}</span>`);
 
272
  } else if (e.kind === "model") {
273
  feed("notebook", `<span class="tool">model refit</span> <span class="muted">uptake ${esc(JSON.stringify(e.gains_after))}, fit error ${e.model_fit_error_de00}</span>`);
274
  } else if (e.kind === "status") {
275
  setStatus(e.status);
276
- $("reportLink").hidden = ["running", "waiting"].includes(e.status) || !shots.length;
277
- $("previewBtn").hidden = $("reportLink").hidden || !targetFromPhoto;
278
- if (!["running", "waiting"].includes(e.status)) $("assist").hidden = true;
279
  if (e.reason) feed("notebook", `<span class="gate">${esc(e.status)}: ${esc(e.reason)}</span>`);
280
  } else if (e.kind === "api") {
 
281
  feed("api", `<span><span class="m">${esc(e.model)}</span> ${esc(e.purpose)}</span><span class="${e.ok ? "" : "gate"}">${e.seconds}s</span>`);
282
  }
283
  };
284
 
285
- $("picker").addEventListener("input", (ev) => { $("targetChip").style.background = ev.target.value; });
286
- $("targetChip").style.background = $("picker").value;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
 
288
- $("usePhoto").onclick = async () => {
289
- const file = $("photo").files[0];
290
- if (!file) { $("photoNote").textContent = "Choose a photo first."; return; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
  const form = new FormData();
292
- form.append("photo", file); form.append("concept", $("concept").value || "fabric"); form.append("passcode", $("passcode").value);
293
- $("photoNote").textContent = "Segmenting…";
294
  const r = await fetch("/api/target-from-photo", { method: "POST", body: form });
295
  const j = await r.json();
296
- if (!r.ok) { $("photoNote").textContent = j.detail || "Could not read the photo."; return; }
297
- $("picker").value = j.hex; $("targetChip").style.background = j.hex; targetFromPhoto = true;
298
- const h = j.home_recipe;
299
- if (h) {
300
- const drops = Object.entries(h.drops).filter(([, n]) => n > 0).map(([d, n]) => `${n} drop${n === 1 ? "" : "s"} ${esc(d)}`).join(" · ");
301
- $("homeRecipe").hidden = false;
302
- $("homeRecipe").innerHTML = h.reachable
303
- ? `<b>Dye it at home:</b> ${drops || "plain water"} in ${h.teaspoons} teaspoons of water. A first-dip estimate for household colours; brands vary, so dip a white cotton strip and let Rangrez correct it.`
304
- : `<b>At home:</b> household blue, red and yellow can't quite reach this shade. Closest: ${drops} <span class="sw" style="background:${h.closest_hex}"></span>${h.closest_hex}.`;
 
 
 
 
 
 
 
 
 
 
 
305
  }
306
- $("photoNote").textContent = {
307
- card: `Calibrated against the colour card: ${j.hex}`,
308
- paper: `Balanced against the white paper: ${j.hex}. Photograph every dip in the same spot and light.`,
309
- none: `Read ${j.hex} from the photo, uncalibrated: put white paper or the colour card in the shot.`,
310
- }[j.calibration];
 
311
  };
312
-
313
  $("start").onclick = async () => {
314
  const r = await fetch("/api/campaign", {
315
  method: "POST", headers: { "Content-Type": "application/json" },
@@ -325,14 +796,33 @@ async function sendAssist(url, body) {
325
  $("sendPhoto").onclick = () => {
326
  const file = $("assistPhoto").files[0];
327
  if (!file) { $("assistNote").textContent = "Take or choose a photo first."; return; }
328
- const form = new FormData(); form.append("photo", file);
329
- sendAssist("/api/assist/photo", form);
330
  };
331
  $("useWebcam").onclick = () => sendAssist("/api/assist/webcam");
332
  $("stop").onclick = () => fetch("/api/stop", { method: "POST" });
333
 
334
- // -- hands-free: Muse Voice Transcribe hears the dyer; the browser reads instructions aloud ----------
335
- let handsFree = false, replaying = false, mic = null, targetFromPhoto = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  $("previewBtn").onclick = async () => {
337
  const form = new FormData(); form.append("passcode", $("passcode").value);
338
  $("previewNote").textContent = "Muse Image is recolouring the garment; SAM 3.1 will check it…";
@@ -340,16 +830,25 @@ $("previewBtn").onclick = async () => {
340
  const j = await r.json();
341
  if (!r.ok) { $("previewNote").textContent = j.detail || "Preview failed."; return; }
342
  $("previewImg").src = `/api/preview.jpg?t=${Date.now()}`; $("previewImg").hidden = false;
343
- $("previewNote").textContent = `Dip ${j.dip}'s shade on the customer's garment. Muse Image's first attempt was ΔE00 ${j.raw_de00} off; measured and corrected, it is now ${j.de00}. For the eye: the dip is the evidence.`;
344
  };
 
 
 
 
 
 
 
 
 
345
  function say(text) {
346
  if (!("speechSynthesis" in window)) return;
347
  speechSynthesis.cancel();
348
- speechSynthesis.speak(new SpeechSynthesisUtterance(text));
349
  }
350
- function wav16k(samples, rate) {
351
- const ratio = rate / 16000, n = Math.floor(samples.length / ratio), pcm = new Int16Array(n);
352
- for (let i = 0; i < n; i++) pcm[i] = Math.max(-1, Math.min(1, samples[Math.floor(i * ratio)])) * 0x7fff;
353
  const buf = new ArrayBuffer(44 + pcm.length * 2), v = new DataView(buf);
354
  const str = (o, t) => [...t].forEach((ch, i) => v.setUint8(o + i, ch.charCodeAt(0)));
355
  str(0, "RIFF"); v.setUint32(4, 36 + pcm.length * 2, true); str(8, "WAVEfmt "); v.setUint32(16, 16, true);
@@ -360,7 +859,7 @@ function wav16k(samples, rate) {
360
  }
361
  async function sendTurn(blob) {
362
  const form = new FormData();
363
- form.append("audio", blob, "turn.wav"); form.append("passcode", $("passcode").value);
364
  $("voiceNote").textContent = "Transcribing with Muse Voice Transcribe…";
365
  const r = await fetch("/api/voice", { method: "POST", body: form });
366
  const j = await r.json();
@@ -396,11 +895,93 @@ $("handsFree").onclick = async () => {
396
  $("handsFree").setAttribute("aria-pressed", handsFree);
397
  $("handsFree").textContent = `Hands-free voice: ${handsFree ? "listening" : "off"}`;
398
  if (handsFree) {
399
- try { await startListening(); $("voiceNote").textContent = "Listening. Say \"done\", \"repeat\" or \"stop\"."; }
400
  catch (err) { handsFree = false; $("handsFree").textContent = "Hands-free voice: off"; $("voiceNote").textContent = "Microphone unavailable: " + err.message; }
401
  } else if (mic) { mic.stream.getTracks().forEach((t) => t.stop()); mic.ctx.close(); mic = null; }
402
  };
403
- drawChart();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  </script>
405
  </body>
406
  </html>
 
3
  <head>
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Rangrez · a self-driving dye lab</title>
7
+ <link rel="icon" type="image/svg+xml" href="/static/logo.svg">
8
+ <link rel="preconnect" href="https://fonts.googleapis.com">
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
10
+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Inter:wght@400;500;600;700&family=Tiro+Devanagari+Hindi&display=swap" rel="stylesheet">
11
  <style>
12
  :root {
13
+ --bg: #f5efe4; --panel: #fffcf6; --panel2: #fbf6ec; --ink: #241f1a; --mute: #7a7166; --line: #e7dccb;
14
+ --dye: #2f5d8a; --ok: #2d7a52; --warn: #b07a12; --bad: #b3432d; --cyan: #1b8aa0; --shadow: 0 1px 2px #3a2a1214, 0 8px 24px #3a2a120f;
15
+ --serif: "Fraunces", Georgia, serif; --sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
16
  }
17
  * { box-sizing: border-box; }
18
+ body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.5 var(--sans); }
19
+ a { color: var(--dye); }
20
+ button { font: inherit; cursor: pointer; border-radius: 10px; border: 1px solid var(--line); background: var(--panel); color: var(--ink); padding: 9px 14px; font-weight: 600; }
21
+ button.primary { background: var(--ink); color: #fffcf6; border-color: var(--ink); }
22
+ button.dye { background: var(--dye); color: #fff; border-color: transparent; }
23
+ button:disabled { opacity: .45; cursor: default; }
24
+ button.small { padding: 5px 10px; font-size: 12.5px; font-weight: 500; }
25
+ textarea, input[type=text], input[type=number], input[type=password], select { width: 100%; background: #fff; color: var(--ink); border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; font: inherit; }
26
+ label { display: block; color: var(--mute); font-size: 12px; margin: 10px 0 4px; }
27
+ .hint { color: var(--mute); font-size: 12.5px; margin: 6px 0 0; }
28
+ [hidden] { display: none !important; }
29
+
30
+ /* ---- header ---- */
31
+ header { display: flex; align-items: center; gap: 16px; padding: 14px 24px; flex-wrap: wrap; }
32
+ .brand { display: flex; align-items: baseline; gap: 10px; }
33
+ .brand .hi { font-family: "Tiro Devanagari Hindi", serif; font-size: 22px; color: var(--dye); line-height: 1; transition: color .8s; }
34
+ .brand h1 { font-family: var(--serif); font-size: 26px; margin: 0; letter-spacing: .2px; }
35
+ .brand .sub { color: var(--mute); font-size: 13px; }
36
+ .tabs { display: flex; gap: 4px; background: var(--panel2); border: 1px solid var(--line); border-radius: 12px; padding: 3px; }
37
+ .tabs button { border: 0; background: transparent; padding: 6px 14px; font-weight: 600; color: var(--mute); }
38
+ .tabs button[aria-selected=true] { background: var(--panel); color: var(--ink); box-shadow: var(--shadow); }
39
+ .spacer { flex: 1; }
40
+ .pill { padding: 4px 11px; border-radius: 99px; border: 1px solid var(--line); font-size: 12px; color: var(--mute); background: var(--panel); white-space: nowrap; }
41
+ .pill.running { color: var(--dye); border-color: var(--dye); }
42
+ .pill.waiting { color: var(--cyan); border-color: var(--cyan); }
43
+ .pill.matched { color: #fff; background: var(--ok); border-color: var(--ok); }
44
  .pill.out_of_gamut { color: var(--warn); border-color: var(--warn); }
45
  .pill.escalated, .pill.error { color: var(--bad); border-color: var(--bad); }
46
+
47
+ /* ---- Meta model chips ---- */
48
+ .labbar { display: flex; align-items: center; gap: 12px; padding: 0 24px 12px; flex-wrap: wrap; }
49
+ .models { display: flex; gap: 8px; flex-wrap: wrap; flex: 1; }
50
+ .facts { color: var(--mute); font-size: 12px; white-space: nowrap; }
51
+ header { border-bottom: 1px solid var(--line); margin-bottom: 12px; background: linear-gradient(180deg, #fffaf1, var(--bg)); }
52
+ #tourBtn { box-shadow: 0 6px 18px #24201b33; }
53
+ .model { display: flex; align-items: center; gap: 7px; padding: 6px 11px; border-radius: 99px; background: var(--panel); border: 1px solid var(--line); font-size: 12.5px; color: var(--mute); transition: all .25s; }
54
+ .model b { color: var(--ink); font-weight: 600; }
55
+ .model i { width: 8px; height: 8px; border-radius: 50%; background: var(--line); transition: background .25s, box-shadow .25s; }
56
+ .model .n { font-variant-numeric: tabular-nums; color: var(--mute); }
57
+ .model.live { border-color: var(--dye); color: var(--ink); }
58
+ .model.live i { background: var(--dye); box-shadow: 0 0 0 4px color-mix(in srgb, var(--dye) 25%, transparent); }
59
+
60
+ .banner { margin: 0 24px 10px; padding: 9px 14px; border-radius: 10px; background: #fff4d6; border: 1px solid #ecd28a; color: #6b4e00; font-size: 13px; }
61
+
62
+ /* ---- the tape: every dip is a segment of cotton tape ---- */
63
+ .tape-wrap { margin: 0 24px 14px; background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px 10px; box-shadow: var(--shadow); }
64
+ .tape-wrap h2 { margin: 0 0 8px; }
65
+ .tape { display: flex; align-items: flex-end; gap: 2px; min-height: 104px; overflow-x: auto; padding: 4px 2px 6px; }
66
+ .seg { position: relative; flex: 0 0 132px; display: flex; flex-direction: column; justify-content: flex-end; }
67
+ .seg.new { animation: soak 1.4s ease-out, slidein .5s ease-out; }
68
+ .seg .cloth { height: 74px; background-color: var(--c);
69
+ background-image: linear-gradient(180deg, #ffffff30 0, transparent 22%, transparent 70%, #00000024 100%),
70
+ repeating-linear-gradient(45deg, #ffffff12 0 1.5px, transparent 1.5px 4px), repeating-linear-gradient(-45deg, #0000000d 0 1px, transparent 1px 5px);
71
+ box-shadow: 0 6px 14px -6px #3a2a1255, inset 0 0 0 1px #00000014; }
72
+ .seg:first-child .cloth { border-radius: 6px 0 0 6px; }
73
+ .seg small { display: flex; justify-content: space-between; align-items: baseline; font-size: 11.5px; color: var(--mute); padding: 6px 4px 0; font-variant-numeric: tabular-nums; }
74
+ .seg small b { font: 700 16px var(--serif); color: var(--ink); }
75
+ .seg.pass small b { color: var(--ok); }
76
+ .seg.pass small b::after { content: " ✓"; font-size: 13px; }
77
+ .arrow { flex: 0 0 auto; align-self: center; margin: 0 10px 18px; color: var(--mute); font-size: 20px; }
78
+ .seg.target { flex-basis: 150px; }
79
+ .seg.target .cloth { border-radius: 0 10px 10px 0; box-shadow: 0 0 0 3px var(--panel), 0 0 0 5px var(--c), 0 8px 18px -6px #3a2a1266; }
80
+ .seg.empty { flex: 1 1 auto; }
81
+ .seg.empty .cloth { background-color: #fbf8f2; border-radius: 6px 0 0 6px; }
82
+ @keyframes slidein { from { transform: translateX(-24px); opacity: 0; } to { transform: none; opacity: 1; } }
83
+ @keyframes soak { from { filter: saturate(0) brightness(1.4); } to { filter: none; } }
84
+
85
+ /* ---- layout ---- */
86
+ main { display: grid; grid-template-columns: 330px minmax(0, 1fr) 330px; gap: 14px; padding: 0 24px 24px; }
87
+ @media (max-width: 1180px) { main { grid-template-columns: 1fr 1fr; } .col-right { grid-column: span 2; } }
88
+ @media (max-width: 760px) { .model .job, .brand .sub { display: none; } .facts { white-space: normal; } .seg { flex-basis: 96px; } }
89
+ @media (max-width: 760px) { main { grid-template-columns: 1fr; } .col-right { grid-column: auto; } header { padding: 12px 16px; } .models, .tape-wrap, main { padding-left: 16px; padding-right: 16px; } .tape-wrap { margin-left: 16px; margin-right: 16px; } }
90
+ .col { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
91
+ section { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 16px; box-shadow: var(--shadow); min-width: 0; }
92
+ h2 { margin: 0 0 10px; font: 600 11.5px var(--sans); text-transform: uppercase; letter-spacing: 1.1px; color: var(--mute); }
93
+ h3 { font-family: var(--serif); font-size: 22px; margin: 0 0 8px; letter-spacing: -.2px; }
94
+ .tape-wrap h2 { display: flex; justify-content: space-between; }
95
+
96
+ /* start card */
97
+ .quick { display: grid; gap: 8px; }
98
+ .presets { display: flex; gap: 8px; margin-top: 10px; }
99
+ .presets button { flex: 1; height: 38px; padding: 0; border-radius: 9px; border: 2px solid #fff; box-shadow: 0 0 0 1px var(--line); }
100
+ .presets button[aria-pressed=true] { box-shadow: 0 0 0 2px var(--ink); }
101
+ .pick { display: flex; gap: 8px; align-items: center; margin-top: 10px; }
102
+ .pick input[type=color] { width: 44px; height: 38px; border: 1px solid var(--line); border-radius: 8px; padding: 2px; background: #fff; }
103
+ details { border-top: 1px solid var(--line); margin-top: 14px; padding-top: 10px; }
104
+ summary { cursor: pointer; color: var(--mute); font-weight: 600; font-size: 12.5px; }
105
+ .row { display: flex; gap: 8px; align-items: center; }
106
+ .row > * { flex: 1; }
107
+
108
+ /* hero: camera + gauge */
109
+ .camera { position: relative; border-radius: 12px; overflow: hidden; background: #ece4d6; aspect-ratio: 3/2; display: flex; align-items: center; justify-content: center; }
110
+ .camera img { width: 100%; height: 100%; object-fit: contain; background: #1b1916; }
111
+ .camera .empty { color: var(--mute); text-align: center; padding: 20px; max-width: 360px; }
112
+ .camera .empty b { display: block; font-family: var(--serif); font-size: 28px; color: var(--ink); margin-bottom: 8px; letter-spacing: -.3px; }
113
+ .camera .empty .row > * { flex: 0 0 auto; }
114
+ .caption { font-size: 12.5px; color: var(--mute); margin-top: 8px; }
115
+ .gauge-box { display: flex; flex-direction: column; align-items: center; gap: 4px; }
116
+ .gauge { position: relative; width: 190px; height: 190px; }
117
+ .gauge svg { width: 100%; height: 100%; transform: rotate(-90deg); }
118
+ .gauge .val { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; }
119
+ .gauge .val b { font: 700 46px var(--serif); font-variant-numeric: tabular-nums; line-height: 1; }
120
+ .gauge .val small { color: var(--mute); font-size: 12px; margin-top: 4px; }
121
+ .gauge.pass .val b { color: var(--ok); }
122
+ .gauge.pass { animation: pulse 1.4s ease-out; }
123
+ @keyframes pulse { 0% { transform: scale(1); } 30% { transform: scale(1.05); } 100% { transform: scale(1); } }
124
+ .split { display: grid; grid-template-columns: 1fr 1fr; width: 100%; border-radius: 12px; overflow: hidden; height: 84px; margin-top: 8px; box-shadow: inset 0 0 0 1px #00000014; }
125
+ .split div { display: flex; align-items: flex-end; padding: 6px 8px; font-size: 11.5px; transition: background .8s; }
126
+ .split span { background: #fffcf6e6; padding: 2px 7px; border-radius: 5px; font-variant-numeric: tabular-nums; }
127
+ .verdict { text-align: center; font-size: 13px; color: var(--mute); min-height: 20px; }
128
+ .outcome { border: 2px solid var(--ok); }
129
+ .outcome h3 { color: var(--ok); }
130
+
131
+ /* assist */
132
+ .assist { border: 2px solid var(--dye); }
133
+ .amounts { display: flex; gap: 10px; flex-wrap: wrap; margin: 6px 0 12px; }
134
+ .amount { flex: 1; min-width: 86px; border: 1px solid var(--line); border-radius: 10px; padding: 10px; text-align: center; background: var(--panel2); }
135
+ .amount b { display: block; font: 700 30px var(--serif); font-variant-numeric: tabular-nums; }
136
+ .amount small { color: var(--mute); }
137
+ .assist ol { margin: 0 0 12px; padding-left: 20px; } .assist li { margin: 3px 0; }
138
+
139
+ /* charts, tables, feeds */
140
+ svg.chart { width: 100%; height: 160px; display: block; }
141
+ table { width: 100%; border-collapse: collapse; font-size: 12.5px; font-variant-numeric: tabular-nums; }
142
+ td, th { padding: 6px 4px; border-bottom: 1px solid var(--line); text-align: left; }
143
  th { color: var(--mute); font-weight: 500; }
144
+ .sw { display: inline-block; width: 14px; height: 14px; border-radius: 3px; vertical-align: -3px; margin-right: 6px; box-shadow: inset 0 0 0 1px #0002; }
145
+ .feed { max-height: 300px; overflow: auto; font-size: 12.5px; }
 
146
  .feed div { padding: 6px 0; border-bottom: 1px dashed var(--line); }
147
+ .feed .who { color: var(--dye); font-weight: 700; margin-right: 6px; }
148
  .feed .tool { color: var(--cyan); }
149
  .feed .gate { color: var(--bad); }
150
  .feed .muted { color: var(--mute); }
151
  .api div { display: grid; grid-template-columns: 1fr auto; gap: 8px; }
152
+ .api .m { color: var(--cyan); font-weight: 600; }
153
+ .log summary { font: 600 11.5px var(--sans); text-transform: uppercase; letter-spacing: 1.1px; }
154
+ .log[open] summary { margin-bottom: 10px; }
155
+ #previewImg { width: 100%; border-radius: 10px; margin-top: 8px; }
156
+
157
+ /* ---- motion ---- */
158
+ section, .tape-wrap { animation: rise .6s ease-out both; }
159
+ .col > section:nth-child(2) { animation-delay: .08s; } .col > section:nth-child(3) { animation-delay: .16s; }
160
+ .col-right > section { animation-delay: .12s; }
161
+ @keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
162
+ @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } }
163
+
164
+ .dyeing { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 22px; background: radial-gradient(circle at 50% 60%, #fffaf0, #efe5d3); flex-direction: column; }
165
+ .dyeing svg { width: 220px; height: 220px; overflow: visible; }
166
+ .dyeing .label { font: 600 15px var(--sans); color: var(--ink); }
167
+ .dyeing .label small { display: block; font-weight: 400; color: var(--mute); font-size: 12.5px; text-align: center; margin-top: 2px; }
168
+ .drop { animation: fall 1.1s cubic-bezier(.5, 0, .9, .5) infinite; opacity: 0; }
169
+ @keyframes fall { 0% { transform: translateY(-40px); opacity: 0; } 15% { opacity: 1; } 75% { transform: translateY(72px); opacity: 1; } 76%, 100% { transform: translateY(72px); opacity: 0; } }
170
+ .ripple { transform-origin: 0 128px; animation: ripple 1.1s ease-out infinite; opacity: 0; }
171
+ @keyframes ripple { 0%, 70% { transform: scale(.2); opacity: 0; } 75% { opacity: .7; } 100% { transform: scale(1.6); opacity: 0; } }
172
+ .liquid { transition: fill 2.4s ease; }
173
+ .strip { animation: dip 4.2s ease-in-out infinite; }
174
+ @keyframes dip { 0%, 12% { transform: translateY(-95px); } 38%, 62% { transform: translateY(0); } 88%, 100% { transform: translateY(-95px); } }
175
+ .strip .dyed { transition: fill 1.6s ease .9s; }
176
+ .burst { position: absolute; left: 50%; top: 50%; width: 9px; height: 12px; border-radius: 50% 50% 50% 50% / 60% 60% 40% 40%; pointer-events: none; animation: burst 1.3s ease-out forwards; }
177
+ @keyframes burst { from { transform: translate(-50%, -50%) scale(.4); opacity: 1; } to { transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) scale(1) rotate(var(--r)); opacity: 0; } }
178
+
179
+ .hero-logo { display: block; margin: 0 auto 12px; overflow: visible; }
180
+ .hl-drop { transform-origin: 32px 60px; animation: breathe 3.2s ease-in-out infinite; }
181
+ @keyframes breathe { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.05) translateY(-1px); } }
182
+ .hl-tape rect { transform-box: fill-box; transform-origin: left center; animation: walk 4.8s ease-out infinite both; }
183
+ .hl-tape .t2 { animation-delay: .5s; } .hl-tape .t3 { animation-delay: 1s; }
184
+ @keyframes walk { 0% { transform: scaleX(0); opacity: 0; } 12% { transform: scaleX(1); opacity: 1; } 85% { opacity: 1; } 100% { opacity: 0; } }
185
+ .brand img { transition: transform .5s cubic-bezier(.3, 1.6, .5, 1); }
186
+ .brand:hover img { transform: rotate(-12deg) scale(1.08); }
187
+
188
+ /* ---- the case ---- */
189
+ .case-hero { margin: 18px 0 0; border-radius: 18px; overflow: hidden; position: relative; aspect-ratio: 21 / 8; background: #2a2018; }
190
+ .case-hero img { width: 100%; height: 100%; object-fit: cover; animation: pan 26s ease-in-out infinite alternate; transform-origin: 40% 60%; }
191
+ @keyframes pan { from { transform: scale(1.04) translateX(0); } to { transform: scale(1.14) translateX(-3%); } }
192
+ .case-hero figcaption { position: absolute; left: 14px; bottom: 12px; font-size: 12px; color: #fff; background: rgba(20, 14, 8, .55); padding: 4px 10px; border-radius: 999px; backdrop-filter: blur(4px); }
193
+ .case { max-width: 1060px; margin: 0 auto; padding: 4px 24px 60px; }
194
+ .case h2.big { font: 700 34px/1.15 var(--serif); text-transform: none; letter-spacing: 0; color: var(--ink); margin: 18px 0 10px; }
195
+ .case h3 { margin-top: 34px; font-size: 24px; }
196
+ .case p { font-size: 15px; max-width: 760px; }
197
+ .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin: 12px 0; }
198
+ .card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 14px; box-shadow: var(--shadow); }
199
+ .card .num { font: 700 30px var(--serif); color: var(--dye); }
200
+ .card .label { color: var(--mute); font-size: 13px; }
201
+ .compare td, .compare th { font-size: 13.5px; padding: 8px 6px; vertical-align: top; }
202
+ .yes { color: var(--ok); font-weight: 700; } .no { color: var(--bad); } .part { color: var(--warn); }
203
+ .limits li { margin: 6px 0; }
204
+
205
+ /* ---- tour ---- */
206
+ .tour-spot { position: fixed; z-index: 50; border-radius: 14px; box-shadow: 0 0 0 9999px #1c140acc; transition: all .35s ease; pointer-events: none; }
207
+ .tour-card { position: fixed; z-index: 51; width: min(380px, calc(100vw - 32px)); background: var(--panel); border-radius: 14px; padding: 18px; box-shadow: 0 20px 60px #0008; transition: top .35s, left .35s; }
208
+ .tour-card h3 { margin: 0 0 6px; }
209
+ .tour-card p { margin: 0 0 14px; color: #3d362e; }
210
+ .tour-card .steps { color: var(--mute); font-size: 12px; }
211
+ .tour-card .row { margin-top: 4px; }
212
+ .tour-card .row > * { flex: 0 0 auto; }
213
  </style>
214
  </head>
215
  <body>
216
  <header>
217
+ <div class="brand"><img src="/static/logo.svg" alt="" width="40" height="40" style="align-self:center"><h1>Rangrez</h1><span class="hi" aria-hidden="true">रंगरेज़</span><span class="sub">a self-driving dye lab on Meta models</span></div>
218
+ <div class="tabs" role="tablist">
219
+ <button role="tab" id="tabLab" aria-selected="true">The lab</button>
220
+ <button role="tab" id="tabCase" aria-selected="false">The case</button>
221
+ </div>
222
  <span class="spacer"></span>
 
 
 
223
  <span class="pill" id="status">idle</span>
224
+ <button class="primary" id="tourBtn">▶ Take the 2-minute tour</button>
225
  </header>
226
 
227
+ <div class="labbar">
228
+ <div class="models" id="models" aria-label="Meta models, lighting up as they are called"></div>
229
+ <div class="facts"><span id="rig">rig: …</span> · <span id="agent">agent: …</span> · <span id="memory">lab memory</span></div>
230
+ </div>
231
+ <div class="banner" id="replayBanner" hidden></div>
232
+
233
+ <div id="labView">
234
+ <div class="tape-wrap" id="tapeWrap">
235
+ <h2><span>The tape: every dip, dyed and measured, ending at the target</span><span id="tapeNote"></span></h2>
236
+ <div class="tape" id="tape"></div>
237
+ </div>
238
+
239
  <main>
240
+ <div class="col">
241
+ <section id="startCard">
242
+ <h3>Match a colour</h3>
243
+ <div class="quick">
244
+ <button class="dye" id="sampleBtn">Match this kurta (sample photo)</button>
245
+ <button id="replayBtn">Watch a recorded live run</button>
246
+ </div>
247
+ <div class="presets" id="presets" aria-label="Preset shades"></div>
248
+ <div class="pick">
249
+ <input type="color" id="picker" value="#4b909d" aria-label="Pick a target colour">
250
+ <button class="primary" id="start" style="flex:1">Start the lab</button>
251
+ <button class="small" id="stop">Stop</button>
252
+ </div>
253
  <p class="hint" id="photoNote"></p>
254
  <p class="hint" id="homeRecipe" hidden></p>
255
+ <details id="advanced">
256
+ <summary>Your own photo, the buyer's message, voice, settings</summary>
257
+ <label for="photo">A photo of the garment to match (with the colour card or white paper in view)</label>
258
+ <input type="file" id="photo" accept="image/*">
259
+ <div class="row" style="margin-top:6px"><input type="text" id="concept" value="kurta" aria-label="Object to match in the photo"><button class="small" id="usePhoto">Read with SAM 3.1</button></div>
260
+ <label for="orderMsg">The buyer's message (English or Hindi)</label>
261
+ <textarea id="orderMsg" rows="3" placeholder="e.g. need 500 kurtas in this teal, cotton poplin, by Friday. Buyer is strict on shade."></textarea>
262
+ <button class="small" id="readOrder" style="margin-top:6px">Read the order with Muse Spark</button>
263
+ <p class="hint" id="orderNote"></p>
264
+ <label for="tol">Pass tolerance, CIEDE2000 (smaller is stricter; 1.0 strict, 1.5 normal, 2.0 relaxed)</label>
265
+ <input type="number" id="tol" value="1.5" step="0.1" min="0.5" max="5">
266
+ <div id="liveBox" hidden>
267
+ <label for="passcode">Live passcode (runs the Meta models live; without it, the offline policy)</label>
268
+ <input type="password" id="passcode" autocomplete="off">
269
+ </div>
270
+ <label>Hands-free (the dyer's gloves are wet)</label>
271
+ <div class="row"><button class="small" id="handsFree" aria-pressed="false">Hands-free voice: off</button>
272
+ <select id="lang" aria-label="Voice language"><option value="en">English</option><option value="hi">हिन्दी</option></select></div>
273
+ <p class="hint" id="voiceNote">Say "done" when the strip is dry, "repeat", or "stop" ("हो गया", "फिर से", "बंद करो"). Anything else goes to the colourist's notebook.</p>
274
+ </details>
275
  </section>
276
+
277
  <section>
278
+ <h2>Convergence</h2>
279
+ <svg class="chart" id="chart" viewBox="0 0 340 160" role="img" aria-label="Colour difference per dip"></svg>
280
  </section>
281
  </div>
282
 
283
+ <div class="col">
284
  <section class="assist" id="assist" hidden>
285
  <h2 id="assistTitle">Your turn: dip 1</h2>
286
  <div class="amounts" id="amounts"></div>
287
  <ol id="assistSteps"></ol>
288
+ <label for="assistPhoto">Photo of the dried strip on white paper or next to the colour card</label>
289
  <input type="file" id="assistPhoto" accept="image/*" capture="environment">
290
+ <div class="row" style="margin-top:8px"><button class="dye" id="sendPhoto">Send photo</button><button id="useWebcam">Use webcam</button></div>
 
291
  <p class="hint" id="assistNote"></p>
292
  </section>
293
+
294
+ <section id="cameraCard">
295
+ <h2>What the camera sees · SAM 3.1 masks: colour card (yellow), strip (cyan)</h2>
296
+ <div class="camera" id="camera">
297
+ <div class="empty" id="cameraEmpty">
298
+ <!-- the logo, alive: the drop breathes and the tape of dips walks towards the shade, again and again -->
299
+ <svg class="hero-logo" viewBox="0 0 64 64" width="96" height="96" aria-hidden="true">
300
+ <defs><linearGradient id="hl-dye" x1="20" y1="4" x2="46" y2="58" gradientUnits="userSpaceOnUse">
301
+ <stop offset="0" stop-color="#12a5bd"/><stop offset="0.5" stop-color="#d9378a"/><stop offset="1" stop-color="#f6b81c"/>
302
+ <animateTransform attributeName="gradientTransform" type="rotate" values="-25 32 36;25 32 36;-25 32 36" dur="7s" repeatCount="indefinite"/></linearGradient>
303
+ <clipPath id="hl-drop"><path d="M32 2C32 2 10 25 10 40a22 22 0 0 0 44 0C54 25 32 2 32 2Z"/></clipPath></defs>
304
+ <g class="hl-drop"><path d="M32 2C32 2 10 25 10 40a22 22 0 0 0 44 0C54 25 32 2 32 2Z" fill="url(#hl-dye)"/>
305
+ <ellipse cx="24" cy="27" rx="4" ry="7" transform="rotate(28 24 27)" fill="#fff" opacity=".22"/></g>
306
+ <g class="hl-tape"><rect class="t1" x="0" y="41" width="12" height="9" rx="2" fill="#c8648f"/><rect class="t2" x="13" y="41" width="9" height="9" fill="#7d6aa6"/>
307
+ <rect class="t3" x="22" y="41" width="42" height="9" rx="2" fill="#2f7f8f"/>
308
+ <path d="M0 43.5h64M0 47.5h64" stroke="#fff" stroke-opacity=".22" stroke-width=".8"/></g>
309
+ </svg>
310
+ <b>Show Rangrez a colour.</b>It picks a recipe, a strip is dyed, a phone photographs it, and it corrects the recipe until the cloth matches.
311
+ <div class="row" style="margin-top:16px;justify-content:center"><button class="dye" onclick="$('sampleBtn').click()">Match a kurta</button><button onclick="$('replayBtn').click()">Watch a live run</button></div></div>
312
+ <img id="frame" hidden alt="The latest dip photo with SAM 3.1's masks">
313
+ <div class="dyeing" id="dyeing" hidden aria-live="polite">
314
+ <svg viewBox="0 0 220 220" aria-hidden="true">
315
+ <g id="drops"></g>
316
+ <!-- the cotton strip, dipped into the bath and lifted out dyed -->
317
+ <g class="strip"><rect x="98" y="62" width="24" height="100" rx="2" fill="#fbf8f1" stroke="#d8ccb8"/>
318
+ <rect class="dyed" id="stripDye" x="98" y="122" width="24" height="40" rx="2" fill="#fbf8f1"/></g>
319
+ <!-- the glass -->
320
+ <path d="M58 110 L66 196 Q68 206 78 206 L142 206 Q152 206 154 196 L162 110" fill="#ffffff" fill-opacity=".55" stroke="#c9bba5" stroke-width="2.5"/>
321
+ <path class="liquid" id="liquid" d="M61 128 L66 196 Q68 204 78 204 L142 204 Q152 204 154 196 L159 128 Z" fill="#eaf3f5" fill-opacity=".9"/>
322
+ <ellipse cx="110" cy="128" rx="49" ry="6" fill="#ffffff" fill-opacity=".35"/>
323
+ </svg>
324
+ <div class="label" id="dyeingLabel">Dyeing…</div>
325
+ </div>
326
+ </div>
327
+ <div class="caption" id="frameCaption"></div>
328
+ </section>
329
+
330
+ <section class="outcome" id="outcome" hidden>
331
+ <h3 id="outcomeTitle">Matched</h3>
332
+ <p class="hint" id="outcomeText"></p>
333
+ <div class="row" style="margin-top:10px; flex-wrap: wrap">
334
+ <a id="reportLink" href="/api/report.html" target="_blank" rel="noopener"><button class="primary" style="width:100%">Open the lab-dip report</button></a>
335
+ <button id="previewBtn" hidden>See the garment in this shade (Muse Image)</button>
336
+ <button id="replyBtn" hidden>Draft the reply to the buyer (Muse Spark)</button>
337
  </div>
 
 
338
  <p class="hint" id="previewNote"></p>
339
  <img id="previewImg" hidden alt="The customer's garment recoloured to the dip's shade">
340
+ <textarea id="replyText" rows="5" hidden aria-label="Reply to the buyer" style="margin-top:8px"></textarea>
341
+ <p class="hint" id="replyNote"></p>
 
 
342
  </section>
343
  </div>
344
 
345
+ <div class="col col-right">
346
+ <section id="gaugeCard">
347
+ <h2>Latest dip against the target</h2>
348
+ <div class="gauge-box">
349
+ <div class="gauge" id="gauge">
350
+ <svg viewBox="0 0 120 120"><circle cx="60" cy="60" r="52" fill="none" stroke="#efe6d8" stroke-width="10"/>
351
+ <circle id="gaugeArc" cx="60" cy="60" r="52" fill="none" stroke="var(--dye)" stroke-width="10" stroke-linecap="round" stroke-dasharray="326.7" stroke-dashoffset="326.7" style="transition: stroke-dashoffset .9s, stroke .4s"/></svg>
352
+ <div class="val"><b id="de">—</b><small id="deNote">ΔE00 (CIEDE2000)</small></div>
353
+ </div>
354
+ <div class="verdict" id="verdict">Below the tolerance is a match.</div>
355
+ <div class="split"><div id="targetChip"><span id="targetLabel">target</span></div><div id="dipChip"><span id="dipLabel">no dip yet</span></div></div>
356
+ </div>
357
  </section>
358
+
359
+ <section id="learningCard">
360
+ <h2>The lab learning this dye lot</h2>
361
+ <svg class="chart" id="learning" viewBox="0 0 340 150" role="img" aria-label="Dips per campaign and first-dip error, campaign by campaign"></svg>
362
+ <p class="hint" id="learningNote">Each campaign starts from everything the lab remembers about this dye lot.</p>
363
  </section>
364
+
365
+ <section id="logCard">
366
+ <details class="log" id="logDetails">
367
+ <summary>Lab log: Wonder's notebook, dips, Meta API calls</summary>
368
+ <h2>Notebook</h2>
369
+ <div class="feed" id="notebook"><div class="muted">Muse Wonder writes its reasoning here.</div></div>
370
+ <h2 style="margin-top:14px">Dips</h2>
371
+ <table><thead><tr><th>#</th><th>recipe (<span id="dyeHead">dyes</span>)</th><th>ΔE00</th></tr></thead><tbody id="shots"></tbody></table>
372
+ <h2 style="margin-top:14px">Meta API calls</h2>
373
+ <div class="feed api" id="api"><div class="muted">No calls yet.</div></div>
374
+ </details>
375
  </section>
376
  </div>
377
  </main>
378
+ </div>
379
+
380
+ <div id="caseView" class="case" hidden>
381
+ <figure class="case-hero"><img src="/static/dyehouse.jpg" alt="" loading="lazy">
382
+ <figcaption>The world Rangrez is for: a small cotton dye house. An illustration made with Muse Image, not a photo.</figcaption></figure>
383
+ <h2 class="big">A dye house's lab dip, in minutes instead of days, from a phone.</h2>
384
+ <p>Before a garment order is dyed, the dye house dyes a small swatch, a <b>lab dip</b>, and sends it to the buyer for approval. A rejected dip means correct, re-dye, re-send: usually 2 to 4 rounds of several days each. Large mills shorten this with spectrophotometers and colour-matching software. The thousands of small dye houses and job workers in India's textile towns mostly rely on a colourist's eye and repeated trials.</p>
385
+ <p>Rangrez runs the colourist's loop itself: pick a recipe, dye a strip, photograph it, measure it, correct it, until the cloth matches. Six Meta models each do one job in that loop.</p>
386
+
387
+ <h3>Six Meta models, six real jobs</h3>
388
+ <div class="cards">
389
+ <div class="card"><b>SAM 3.1</b><div class="label">A measuring instrument: segments the colour card, the white paper and the dyed strip in every photo, so pixels become calibrated CIELAB.</div></div>
390
+ <div class="card"><b>Muse Wonder</b><div class="label">The colourist: runs each campaign through tools (suggest, dip, refit, escalate) and writes the lab notebook. It never drives a pump directly.</div></div>
391
+ <div class="card"><b>Muse Glimmer 30B</b><div class="label">Open weights, fine-tuned on the lab's own dips to propose the first recipe; v2 reads the lab's notebook. Weights published.</div></div>
392
+ <div class="card"><b>Muse Spark</b><div class="label">Reads the buyer's chat message (English or Hindi) into an order, sets the pass tolerance, and drafts the reply in the buyer's language.</div></div>
393
+ <div class="card"><b>Muse Image</b><div class="label">Shows the customer's garment in the approved shade; SAM measures and corrects it, because an image model is not a colour instrument.</div></div>
394
+ <div class="card"><b>Muse Voice Transcribe</b><div class="label">Hands-free for a dyer with wet gloves: "done", "repeat", "stop", or "हो गया", "फिर से", "बंद करो".</div></div>
395
+ </div>
396
+
397
+ <h3>What it has shown so far</h3>
398
+ <p class="hint">All on the simulator: hidden dye chemistry and real-looking phone photos with known true colours. No real dye on real cloth yet.</p>
399
+ <div class="cards">
400
+ <div class="card"><div class="num">0.66</div><div class="label">median ΔE00 measuring a strip from a phone photo with the printed colour card (live SAM 3.1, 22 photos); 1.17 with white paper only</div></div>
401
+ <div class="card"><div class="num">24 / 24</div><div class="label">shades matched in a median of 2 dips on the robot rig's reactive dyes, every dip photographed in a different room</div></div>
402
+ <div class="card"><div class="num">3.81 → 1.40</div><div class="label">the first dip's error over a 40-campaign night shift, as lab memory learns the dye lot; dips per match 2.35 → 1.65</div></div>
403
+ <div class="card"><div class="num">4.10 → 0.92</div><div class="label">first-dip error, supplier's model vs fine-tuned Glimmer; a Gaussian process on the same dips reaches 0.77, and we say so</div></div>
404
+ <div class="card"><div class="num">8 / 8 vs 5 / 8</div><div class="label">live campaigns matched by Muse Wonder vs a fixed policy with the same tools and safety rules: when a correction overshot, Wonder changed method and recovered</div></div>
405
+ <div class="card"><div class="num">124</div><div class="label">offline tests: colour science, safety gate, firmware emulator, voice, reports, every photo realistic</div></div>
406
+ </div>
407
+
408
+ <h3>What only Rangrez does</h3>
409
+ <table class="compare">
410
+ <thead><tr><th></th><th>Colourist by eye</th><th>Spectrophotometer + matching software</th><th>A general AI chat</th><th>Rangrez</th></tr></thead>
411
+ <tbody>
412
+ <tr><td>Equipment</td><td class="yes">none</td><td class="part">an instrument and software licence, quote-based</td><td class="yes">none</td><td class="yes">a phone and a printed card; a $100 rig to automate</td></tr>
413
+ <tr><td>Measures the dyed cloth</td><td class="no">by eye</td><td class="yes">precisely</td><td class="no">no</td><td class="part">from a phone photo, ±0.7 ΔE00 with the card</td></tr>
414
+ <tr><td>Proposes and corrects the recipe</td><td class="part">by experience</td><td class="yes">yes</td><td class="no">guesses</td><td class="yes">yes, from a model refitted to real dips</td></tr>
415
+ <tr><td>Runs the loop itself (recipe, dip, measure, correct)</td><td class="no">no</td><td class="no">no</td><td class="no">no</td><td class="yes">yes, with a safety gate and a human escalation</td></tr>
416
+ <tr><td>Learns this dye lot across campaigns</td><td class="part">in one head</td><td class="part">some correction features</td><td class="no">no</td><td class="yes">lab memory, plus a fine-tuned open model</td></tr>
417
+ <tr><td>Reads the buyer's WhatsApp order, replies in their language</td><td class="no">no</td><td class="no">no</td><td class="part">if asked</td><td class="yes">Muse Spark, English or Hindi</td></tr>
418
+ <tr><td>Hands-free in Hindi</td><td class="yes">it's a person</td><td class="no">no</td><td class="no">no</td><td class="yes">Muse Voice Transcribe</td></tr>
419
+ <tr><td>Buyer-ready lab-dip report</td><td class="no">no</td><td class="yes">yes</td><td class="no">no</td><td class="yes">ΔE00 and CMC 2:1, options A/B/C, recipe in % owf, production plan</td></tr>
420
+ <tr><td>A home recipe for anyone's outfit</td><td class="no">no</td><td class="no">no</td><td class="no">no</td><td class="yes">photograph it, get drops of household colour</td></tr>
421
+ </tbody>
422
+ </table>
423
+
424
+ <h3>Where it stands, honestly</h3>
425
+ <ul class="limits">
426
+ <li><b>Simulated, not yet dyed for real.</b> The physics, photos and chemistry are simulated. The first real campaign, household colours on a cotton strip with a phone, is the next step.</li>
427
+ <li><b>The robot rig is designed, not built.</b> Firmware, wiring and a virtual ESP32 that runs the real driver are done; the parts are about ₹8,000.</li>
428
+ <li><b>A camera is a pre-screen, not a certificate.</b> ±0.7 ΔE00 with the card is good enough to correct fast, not to certify a strict 1.0 pass; it cannot check metamerism. Final approval stays with the buyer's instrument or light booth.</li>
429
+ <li><b>A lab dip must follow the bulk process.</b> Fixing, rinsing and soaping are in the protocol; transferring a recipe to bulk still needs the dye house's own lab-to-bulk factor.</li>
430
+ <li><b>Household colours are for learning and play.</b> Food colours and inks are not wash-fast; dye houses use reactive dyes.</li>
431
+ <li><b>Glimmer is not magic.</b> On the same data a Gaussian process matches it; Glimmer earns its place by reading the lab's notebook and working with language.</li>
432
+ </ul>
433
+
434
+ <h3>Why it can work</h3>
435
+ <div class="cards">
436
+ <div class="card"><b>Technically</b><div class="label">Runs on a laptop and a phone; the measuring, chemistry and safety are plain tested code, and the Meta models do what they are best at. Every campaign is recorded and replayable.</div></div>
437
+ <div class="card"><b>For the business</b><div class="label">Each avoided round saves days of a buyer's lead time and a courier; the report is the document the buyer already asks for. Per-dip or monthly pricing for small dye houses and sourcing agents.</div></div>
438
+ <div class="card"><b>For the people using it</b><div class="label">Phone first, Hindi, hands-free, WhatsApp-shaped. No new instrument to learn: photograph, dip, send.</div></div>
439
+ <div class="card"><b>The adoption path</b><div class="label">1) assisted mode with a phone, zero capex; 2) the $100 rig for unattended dips; 3) a handheld spectrophotometer in place of the camera for strict shades, same agent and memory.</div></div>
440
+ </div>
441
+
442
+ <h3>What it could become</h3>
443
+ <ul class="limits">
444
+ <li>Real-dye pilots with job-work dye houses in Tiruppur and Erode.</li>
445
+ <li>Spectral camera calibration, and profiles for polyester (disperse dyes) and wool.</li>
446
+ <li>Glimmer reading the dip photo itself, not just numbers.</li>
447
+ <li>Lab memory shared across dye houses that use the same dye lots, without sharing their recipes.</li>
448
+ </ul>
449
+ <p style="margin-top:28px"><button class="primary" id="caseToLab">Back to the lab</button> <a href="https://github.com/TusharTechs/rangrez" target="_blank" rel="noopener">Code, hardware and evaluation on GitHub</a></p>
450
+ </div>
451
+
452
+ <div id="tour" hidden><div class="tour-spot" id="tourSpot"></div><div class="tour-card" id="tourCard" role="dialog" aria-live="polite">
453
+ <div class="steps" id="tourStep"></div><h3 id="tourTitle"></h3><p id="tourText"></p>
454
+ <div class="row"><button class="small" id="tourSkip">Skip</button><span class="spacer"></span><button class="small" id="tourBack">Back</button><button class="primary small" id="tourNext">Next</button></div>
455
+ </div></div>
456
 
457
  <script>
458
  const $ = (id) => document.getElementById(id);
459
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
460
+ let tolerance = 1.5, shots = [], config = {}, samples = null;
461
+ let handsFree = false, replaying = false, mic = null, targetFromPhoto = false, orderRead = false, liveRun = false;
462
+
463
+ // ---- Meta model chips ------------------------------------------------------------------
464
+ const MODELS = [
465
+ ["sam", "SAM 3.1", "measures every photo"], ["wonder", "Muse Wonder", "runs the campaign"],
466
+ ["glimmer", "Muse Glimmer", "first-dip recipe (open weights)"], ["spark", "Muse Spark", "reads orders, drafts replies"],
467
+ ["image", "Muse Image", "garment preview"], ["voice", "Muse Voice", "hands-free, English and Hindi"],
468
+ ];
469
+ const calls = {};
470
+ $("models").innerHTML = MODELS.map(([k, n, job]) => `<span class="model" id="m-${k}" title="${esc(job)}"><i></i><b>${n}</b><span class="job">${esc(job)}</span><span class="n" id="n-${k}"></span></span>`).join("");
471
+ function modelKey(model) {
472
+ const m = model.toLowerCase();
473
+ return m.startsWith("sam") ? "sam" : m.includes("wonder") ? "wonder" : m.includes("glimmer") ? "glimmer" : m.includes("spark") ? "spark" : m.includes("image") ? "image" : m.includes("voice") ? "voice" : null;
474
+ }
475
+ function flash(model) {
476
+ const k = modelKey(model); if (!k) return;
477
+ calls[k] = (calls[k] || 0) + 1; $("n-" + k).textContent = `× ${calls[k]}`;
478
+ const el = $("m-" + k); el.classList.add("live"); clearTimeout(el._t); el._t = setTimeout(() => el.classList.remove("live"), 1400);
479
+ }
480
+
481
+ // ---- the dyeing animation: drops of each dye fall in, the bath tints, a cotton strip dips ------
482
+ const DYE_HEX = { turquoise: "#13a1b6", fuchsia: "#d23a86", "lemon yellow": "#f4c21c", blue: "#2f5fbf", red: "#d0353d", yellow: "#f2c21b" };
483
+ const DYE_ABS = { turquoise: [1.0, 0.18, 0.08], fuchsia: [0.08, 0.95, 0.35], "lemon yellow": [0.02, 0.06, 0.9], blue: [0.9, 0.45, 0.06], red: [0.05, 0.85, 0.75], yellow: [0.02, 0.08, 0.85] };
484
+ function bathColour(amounts) { // a rough Beer-Lambert mix, for the eye only
485
+ const t = [1, 1, 1], scale = config.unit === "drops" ? 0.06 : 0.25;
486
+ for (const [d, c] of amounts) (DYE_ABS[d] || [0, 0, 0]).forEach((k, i) => { t[i] *= Math.exp(-k * c * scale); });
487
+ return "#" + t.map((v) => Math.round(255 * Math.max(0.04, v)).toString(16).padStart(2, "0")).join("");
488
+ }
489
+ function showDyeing(e) {
490
+ const dyes = (config.dyes || []).map((d) => [d, parseFloat((e.recipe.match(new RegExp(d + "\\s+([\\d.]+)")) || [])[1] || 0)]).filter(([, c]) => c > 0.01);
491
+ const colour = bathColour(dyes);
492
+ const xs = [76, 144, 88]; // either side of the strip
493
+ $("drops").innerHTML = dyes.map(([d], i) => { const delay = `animation-delay:${(i * 0.37).toFixed(2)}s`;
494
+ return `<g transform="translate(${xs[i % 3]} 0)"><path class="drop" style="${delay}" d="M0 40 C-6 50 -6 56 0 58 C6 56 6 50 0 40 Z" fill="${DYE_HEX[d] || "#888"}"/>`
495
+ + `<ellipse class="ripple" style="${delay}" cx="0" cy="128" rx="12" ry="3" fill="none" stroke="#fff" stroke-width="1.6"/></g>`; }).join("");
496
+ $("liquid").setAttribute("fill", "#eaf3f5");
497
+ $("stripDye").setAttribute("fill", "#fbf8f1");
498
+ requestAnimationFrame(() => setTimeout(() => { $("liquid").setAttribute("fill", colour); $("stripDye").setAttribute("fill", colour); }, 60));
499
+ $("dyeingLabel").innerHTML = `Dyeing dip ${e.index}<small>${esc(e.recipe)}</small>`;
500
+ $("dyeing").hidden = false; $("cameraEmpty").hidden = true; dyeingSince = performance.now();
501
+ }
502
+ let dyeingSince = 0;
503
+ function afterDyeing(fn) { // a sped-up replay still lets the strip finish its dip before the photo
504
+ const wait = replaying ? 0 : Math.max(0, 2800 - (performance.now() - dyeingSince));
505
+ wait ? setTimeout(fn, wait) : fn();
506
+ }
507
+
508
+ // ---- gauge counts down to each new reading; a match bursts into dye drops -------------------
509
+ let shownDe = NaN;
510
+ let tweenId = 0;
511
+ function tweenGauge(to) {
512
+ const id = ++tweenId, from = isFinite(shownDe) ? shownDe : Math.max(to, 12), start = performance.now(), dur = 900;
513
+ const step = (now) => {
514
+ if (id !== tweenId) return;
515
+ const k = Math.min(1, (now - start) / dur), v = from + (to - from) * (1 - Math.pow(1 - k, 3));
516
+ $("de").textContent = v.toFixed(2);
517
+ if (k < 1) requestAnimationFrame(step);
518
+ };
519
+ shownDe = to;
520
+ requestAnimationFrame(step);
521
+ }
522
+ function burst() {
523
+ const box = $("gauge"), colours = ["#13a1b6", "#d23a86", "#f4c21c", getComputedStyle(document.documentElement).getPropertyValue("--dye")];
524
+ for (let i = 0; i < 36; i++) {
525
+ const p = document.createElement("span"), a = (i / 36) * Math.PI * 2, r = 110 + Math.random() * 110;
526
+ p.className = "burst"; p.style.background = colours[i % colours.length];
527
+ p.style.setProperty("--dx", `${Math.cos(a) * r}px`); p.style.setProperty("--dy", `${Math.sin(a) * r}px`); p.style.setProperty("--r", `${Math.random() * 360}deg`);
528
+ box.appendChild(p); setTimeout(() => p.remove(), 1400);
529
+ }
530
+ }
531
+
532
+ // ---- colour theme: the target colour dyes the page --------------------------------------
533
+ function setDye(hex) {
534
+ const r = parseInt(hex.slice(1, 3), 16), g = parseInt(hex.slice(3, 5), 16), b = parseInt(hex.slice(5, 7), 16);
535
+ const lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
536
+ // Keep text readable: very light shades are darkened for accents.
537
+ const k = lum > 0.62 ? 0.62 / lum : 1;
538
+ document.documentElement.style.setProperty("--dye", `rgb(${Math.round(r * k)}, ${Math.round(g * k)}, ${Math.round(b * k)})`);
539
+ $("targetChip").style.background = hex;
540
+ }
541
+
542
+ // ---- the tape -----------------------------------------------------------------------------
543
+ let targetHex = null;
544
+ function drawTape(fresh) { // fresh: the dip just measured, which soaks in
545
+ const segs = shots.map((s) => `<div class="seg ${s.delta_e <= tolerance ? "pass" : ""} ${s.index === fresh && !replaying ? "new" : ""}" style="--c:${s.hex}"><div class="cloth"></div><small><span>dip ${s.index}</span><b>${isFinite(s.delta_e) ? s.delta_e.toFixed(2) : "—"}</b></small></div>`);
546
+ if (!shots.length) segs.push(`<div class="seg empty"><div class="cloth"></div><small><span>Undyed cotton: each dip becomes a segment of this tape, measured and corrected until it matches the target</span></small></div>`);
547
+ const target = targetHex ? `<span class="arrow">→</span><div class="seg target" style="--c:${targetHex}"><div class="cloth"></div><small><span>target</span><span>${targetHex}</span></small></div>` : "";
548
+ $("tape").innerHTML = segs.join("") + target;
549
+ $("tape").scrollLeft = 1e6;
550
+ }
551
+
552
+ // ---- gauge ----------------------------------------------------------------------------------
553
+ function setGauge(de) {
554
+ const full = 326.7, frac = isFinite(de) ? Math.min(de, 12) / 12 : 0;
555
+ $("gaugeArc").style.strokeDashoffset = String(full * frac);
556
+ $("gaugeArc").style.stroke = isFinite(de) && de <= tolerance ? "var(--ok)" : "var(--dye)";
557
+ if (!isFinite(de)) { $("de").textContent = "—"; shownDe = NaN; }
558
+ else if (replaying) { $("de").textContent = de.toFixed(2); shownDe = de; }
559
+ else if (de !== shownDe) tweenGauge(de);
560
+ $("gauge").classList.toggle("pass", isFinite(de) && de <= tolerance);
561
+ $("verdict").textContent = !isFinite(de) ? "Below the tolerance is a match." : de <= tolerance ? `Within the tolerance of ${tolerance.toFixed(1)}: a match.` : `Tolerance ${tolerance.toFixed(1)}: ${(de - tolerance).toFixed(2)} to go.`;
562
  }
563
 
564
  fetch("/api/config").then((r) => r.json()).then((c) => {
565
+ config = c;
566
  $("agent").textContent = "agent: " + c.agent;
567
+ $("rig").textContent = "rig: " + ({ sim: "simulator", serial: "robot rig", assisted: "assisted (your hands)" }[c.rig] || c.rig);
568
  $("dyeHead").textContent = c.dyes.join(" · ") + " " + c.unit;
569
  $("liveBox").hidden = !c.passcode;
570
+ if (c.public_reads_left > 0) $("photoNote").textContent = "Try it: photograph any outfit (under Your own photo) and get its colour, plus a recipe to dye it at home.";
571
  });
572
+ fetch("/api/samples").then((r) => r.json()).then((s) => { samples = s; if (s.order) $("orderMsg").placeholder = s.order; if (!s.runs.length) $("replayBtn").hidden = true; }).catch(() => {});
573
+
574
+ // Preset shades a dye house is often asked for.
575
+ const PRESETS = [["#3d7a89", "teal"], ["#b5546f", "rose"], ["#c9a227", "mustard"], ["#4a4f8c", "indigo"], ["#6d7a3c", "olive"]];
576
+ $("presets").innerHTML = PRESETS.map(([h, n]) => `<button style="background:${h}" title="${n}" aria-label="${n}" data-hex="${h}"></button>`).join("");
577
+ $("presets").onclick = (ev) => {
578
+ const h = ev.target.dataset && ev.target.dataset.hex; if (!h) return;
579
+ $("picker").value = h; setDye(h); targetHex = h; drawTape(); targetFromPhoto = false;
580
+ [...$("presets").children].forEach((b) => b.setAttribute("aria-pressed", b.dataset.hex === h));
581
+ };
582
 
583
  function setStatus(s) {
584
+ const el = $("status"); el.textContent = s.replace(/_/g, " "); el.className = "pill " + s;
 
 
585
  $("start").disabled = s === "running" || s === "waiting";
586
  }
 
587
  function feed(id, html) {
588
  const box = $(id);
589
  if (box.firstElementChild && box.firstElementChild.classList.contains("muted") && box.children.length === 1) box.innerHTML = "";
590
+ const div = document.createElement("div"); div.innerHTML = html; box.prepend(div);
 
 
591
  }
592
+
593
+ async function drawLearning() {
594
+ const m = await fetch("/api/memory").then((r) => r.json()).catch(() => null);
595
+ if (!m) return;
596
+ const cs = m.campaigns, svg = $("learning"), w = 340, h = 150, pad = 26;
597
+ if (!cs.length) { svg.innerHTML = `<text x="${w / 2}" y="${h / 2}" fill="#7a7166" font-size="11" text-anchor="middle">No campaigns remembered yet: run one</text>`; return; }
598
+ const n = Math.max(cs.length, 2), bw = (w - pad - 8) / n, maxD = Math.max(4, ...cs.map((c) => c.dips)), maxE = 12;
599
+ let g = "";
600
+ cs.forEach((c, i) => {
601
+ const bh = (c.dips / maxD) * (h - pad - 14);
602
+ g += `<rect x="${pad + i * bw + 1}" y="${h - pad - bh}" width="${Math.max(2, bw - 2)}" height="${bh}" rx="2" fill="${c.status === "matched" ? "#2d7a52" : "#b9ae9e"}" opacity="0.45"/>`;
603
+ });
604
+ const pts = cs.map((c, i) => c.first_de00 == null ? null : [pad + i * bw + bw / 2, h - pad - (Math.min(c.first_de00, maxE) / maxE) * (h - pad - 14)]).filter(Boolean);
605
+ if (pts.length > 1) g += `<polyline fill="none" stroke="var(--dye)" stroke-width="2.5" points="${pts.map((p) => p.join(",")).join(" ")}"/>`;
606
+ g += `<text x="${pad}" y="12" fill="#2d7a52" font-size="10">bars: dips to finish</text><text x="${w - 8}" y="12" fill="var(--dye)" font-size="10" text-anchor="end">line: first dip's ΔE00</text>`;
607
+ g += `<text x="${w / 2}" y="${h - 8}" fill="#7a7166" font-size="10" text-anchor="middle">campaign ${cs.length > 1 ? "1 → " + cs.length : "1"}</text>`;
608
+ svg.innerHTML = g;
609
+ $("learningNote").textContent = `${m.dips} dips remembered from ${cs.length} campaigns with this dye lot.`;
610
+ }
611
+ drawLearning();
612
 
613
  function drawChart() {
614
+ const svg = $("chart"), w = 340, h = 160, pad = 28;
615
  const max = Math.max(8, ...shots.map((s) => s.delta_e || 0));
616
  const x = (i) => pad + (i / Math.max(1, Math.max(5, shots.length) - 1)) * (w - pad - 10);
617
  const y = (v) => h - pad + 4 - (Math.min(v, max) / max) * (h - pad - 14);
618
+ let g = `<line x1="${pad}" y1="${y(tolerance)}" x2="${w - 10}" y2="${y(tolerance)}" stroke="#2d7a52" stroke-dasharray="4 4"/>`;
619
+ g += `<text x="${w - 12}" y="${y(tolerance) - 5}" fill="#2d7a52" font-size="10" text-anchor="end">pass ${tolerance}</text>`;
620
+ g += `<text x="${pad - 6}" y="${y(max) + 4}" fill="#7a7166" font-size="10" text-anchor="end">${max.toFixed(0)}</text><text x="${pad - 6}" y="${y(0) + 4}" fill="#7a7166" font-size="10" text-anchor="end">0</text>`;
 
621
  const pts = shots.map((s, i) => [x(i), y(s.delta_e)]);
622
+ if (pts.length > 1) g += `<polyline fill="none" stroke="var(--dye)" stroke-width="2.5" points="${pts.map((p) => p.join(",")).join(" ")}"/>`;
623
  shots.forEach((s, i) => {
624
+ g += `<circle cx="${pts[i][0]}" cy="${pts[i][1]}" r="6" fill="${s.hex}" stroke="#fffcf6" stroke-width="2"/>`;
625
+ g += `<text x="${pts[i][0]}" y="${h - 8}" fill="#7a7166" font-size="10" text-anchor="middle">${i + 1}</text>`;
626
  });
627
+ if (!shots.length) g += `<text x="${w / 2}" y="${h / 2}" fill="#7a7166" font-size="11" text-anchor="middle">The error falls with each dip</text>`;
628
  svg.innerHTML = g;
629
  }
630
 
631
+ function resetView() {
632
+ shots = []; liveRun = false;
633
+ for (const id of ["shots", "notebook", "api"]) $(id).innerHTML = "";
634
+ for (const id of ["outcome", "assist", "frame", "replyText", "previewImg", "previewBtn", "replyBtn", "replayBanner", "dyeing"]) $(id).hidden = true;
635
+ shownDe = NaN;
636
+ $("cameraEmpty").hidden = false; $("frameCaption").textContent = ""; $("previewNote").textContent = ""; $("replyNote").textContent = "";
637
+ Object.keys(calls).forEach((k) => { calls[k] = 0; $("n-" + k).textContent = ""; });
638
+ drawChart(); drawTape(); setGauge(NaN);
639
+ }
640
+
641
  const es = new EventSource("/api/events");
642
  es.onmessage = (msg) => {
643
  const e = JSON.parse(msg.data);
644
  if (e.kind === "replayed") { replaying = false; return; }
645
+ if (e.kind === "reset") { replaying = true; resetView(); setStatus("idle"); return; }
646
+ if (e.kind === "start") {
647
+ resetView(); liveRun = !e.replay;
 
 
 
 
648
  $("agent").textContent = "agent: " + e.agent;
649
  $("memory").textContent = `lab memory: ${e.memory_dips || 0} dips · ${e.memory_campaigns || 0} campaigns`;
650
+ tolerance = e.tolerance; targetHex = e.target_hex; setDye(e.target_hex);
651
+ $("targetLabel").textContent = `target ${e.target_hex}`; $("dipChip").style.background = "transparent"; $("dipLabel").textContent = "no dip yet";
652
+ if (e.replay) { $("replayBanner").hidden = false; $("replayBanner").textContent = `Replaying a recorded live run${e.recorded ? " from " + e.recorded : ""}: every Meta API call shown here happened for real (sped up).`; }
653
+ drawTape(); drawChart(); setGauge(NaN); setStatus("running");
 
 
 
654
  } else if (e.kind === "assist") {
655
+ if (handsFree && !replaying) say(instructions(e));
656
+ $("assist").hidden = false; $("assistTitle").textContent = `Your turn: dip ${e.index}`;
 
657
  $("amounts").innerHTML = Object.entries(e.amounts).map(([d, v]) => `<div class="amount"><b>${v}</b><small>${e.unit} ${esc(d)}</small></div>`).join("");
658
  $("assistSteps").innerHTML = e.steps.map((s) => `<li>${esc(s)}</li>`).join("");
659
+ $("assistNote").textContent = ""; $("assistPhoto").value = ""; setStatus("waiting");
 
 
660
  } else if (e.kind === "shot") {
661
  $("assist").hidden = true;
662
  if ($("status").textContent === "waiting") setStatus("running");
663
+ shots.push(e); drawChart(); drawTape(e.index); setGauge(e.delta_e);
664
+ $("dipChip").style.background = e.hex; $("dipLabel").textContent = `dip ${e.index} · ${e.hex}`;
665
+ const src = `/api/shot/${e.index}.jpg?t=${Date.now()}`;
666
+ afterDyeing(() => { $("frame").src = src; $("frame").hidden = false; $("cameraEmpty").hidden = true; $("dyeing").hidden = true; });
667
+ $("frameCaption").textContent = `Dip ${e.index}: measured ${e.hex}, ΔE00 ${isFinite(e.delta_e) ? e.delta_e.toFixed(2) : "rejected"}.` + (e.flags && e.flags.length ? " " + e.flags.map((f) => f.replace(/^(NOTE|WARN): /, "")).join(" · ") : "");
668
+ const rec = e.recipe.match(/[\d.]+(?= (g\/L|%|drops))/g) || [];
 
 
669
  $("shots").insertAdjacentHTML("beforeend", `<tr><td>${e.index}</td><td><span class="sw" style="background:${e.hex}"></span>${rec.join(" · ")}</td><td>${isFinite(e.delta_e) ? e.delta_e.toFixed(2) : "rejected"}</td></tr>`);
670
  if (e.flags && e.flags.length) feed("notebook", `<span class="gate">${esc(e.flags.join("; "))}</span>`);
671
  } else if (e.kind === "note") {
 
675
  } else if (e.kind === "tool") {
676
  feed("notebook", `<span class="tool">→ ${esc(e.name)}</span> <span class="muted">${esc(JSON.stringify(e.args)).slice(0, 160)}</span>`);
677
  } else if (e.kind === "gate") {
678
+ feed("notebook", `<span class="gate">safety gate refused: ${esc(e.refused)}</span> <span class="muted">${esc(e.recipe)}</span>`);
679
  } else if (e.kind === "dispense") {
680
  feed("notebook", `<span class="muted">dispensing dip ${e.index}: ${esc(e.recipe)}</span>`);
681
+ if (!replaying) showDyeing(e);
682
  } else if (e.kind === "model") {
683
  feed("notebook", `<span class="tool">model refit</span> <span class="muted">uptake ${esc(JSON.stringify(e.gains_after))}, fit error ${e.model_fit_error_de00}</span>`);
684
  } else if (e.kind === "status") {
685
  setStatus(e.status);
686
+ const done = !["running", "waiting"].includes(e.status);
687
+ if (done && !replaying) setTimeout(drawLearning, 500);
688
+ if (done) { $("assist").hidden = true; afterDyeing(() => { $("dyeing").hidden = true; }); showOutcome(e); if (e.status === "matched" && !replaying) setTimeout(burst, 500); }
689
  if (e.reason) feed("notebook", `<span class="gate">${esc(e.status)}: ${esc(e.reason)}</span>`);
690
  } else if (e.kind === "api") {
691
+ flash(e.model);
692
  feed("api", `<span><span class="m">${esc(e.model)}</span> ${esc(e.purpose)}</span><span class="${e.ok ? "" : "gate"}">${e.seconds}s</span>`);
693
  }
694
  };
695
 
696
+ function showOutcome(e) {
697
+ if (!shots.length) return;
698
+ // A match is confirmed by a second photo; the status event carries the confirmed reading.
699
+ const confirmed = shots.find((s) => s.index === e.shot);
700
+ if (confirmed && isFinite(e.delta_e)) { confirmed.delta_e = e.delta_e; drawTape(); setGauge(e.delta_e); }
701
+ const best = shots.filter((s) => isFinite(s.delta_e)).sort((a, b) => a.delta_e - b.delta_e)[0];
702
+ const titles = { matched: "Matched on cotton", out_of_gamut: "Out of these dyes' reach", escalated: "Handed to a human", finished: "Budget spent", stopped: "Stopped" };
703
+ $("outcome").hidden = false;
704
+ $("outcome").style.borderColor = e.status === "matched" ? "var(--ok)" : "var(--warn)";
705
+ $("outcomeTitle").textContent = titles[e.status] || e.status;
706
+ $("outcomeTitle").style.color = e.status === "matched" ? "var(--ok)" : "var(--warn)";
707
+ $("outcomeText").textContent = best ? `Best: dip ${best.index}, ΔE00 ${best.delta_e.toFixed(2)} against a tolerance of ${tolerance.toFixed(1)}, after ${shots.length} dip${shots.length > 1 ? "s" : ""}. The report is ready for the buyer.` : "";
708
+ $("previewBtn").hidden = !(liveRun && targetFromPhoto);
709
+ $("replyBtn").hidden = !(liveRun && orderRead);
710
+ if (!liveRun) { // a recorded run: show what else it produced
711
+ fetch("/api/preview.jpg").then((r) => { if (r.ok) { $("previewImg").src = "/api/preview.jpg"; $("previewImg").hidden = false; $("previewNote").textContent = "The customer's garment in the approved shade: Muse Image recoloured it, SAM 3.1 checked it."; } });
712
+ fetch("/api/replay-file/order.json").then((r) => r.ok ? r.json() : null).then((j) => {
713
+ if (!j) return;
714
+ const o = j.order;
715
+ $("orderNote").textContent = `Muse Spark read the buyer's message: ${o.quantity} ${o.quantity_unit} of ${o.garment}, ${o.fabric}, by ${o.deadline}; shade ${o.shade_strictness}, so pass at ΔE00 ≤ ${o.tolerance_de00}.`;
716
+ $("orderMsg").value = j.message;
717
+ $("outcomeText").textContent += ` The buyer's order: ${o.quantity} ${o.garment}s, shade ${o.shade_strictness}.`;
718
+ }).catch(() => {});
719
+ fetch("/api/replay-file/reply.txt").then((r) => r.ok ? r.text() : null).then((t) => {
720
+ if (!t) return;
721
+ $("replyText").value = t; $("replyText").hidden = false;
722
+ $("replyNote").textContent = "The reply Muse Spark drafted, in the buyer's own Hinglish. Rangrez never sends messages itself.";
723
+ }).catch(() => {});
724
+ }
725
+ }
726
 
727
+ $("picker").addEventListener("input", (ev) => { setDye(ev.target.value); targetHex = ev.target.value; drawTape(); targetFromPhoto = false; });
728
+
729
+ function showHomeRecipe(h) {
730
+ if (!h) return;
731
+ const drops = Object.entries(h.drops).filter(([, n]) => n > 0).map(([d, n]) => `${n} drop${n === 1 ? "" : "s"} ${esc(d)}`).join(" · ");
732
+ $("homeRecipe").hidden = false;
733
+ $("homeRecipe").innerHTML = h.reachable
734
+ ? `<b>Dye it at home:</b> ${drops || "plain water"} in ${h.teaspoons} teaspoons of water. A first-dip estimate for household colours; brands vary, so dip a white cotton strip and let Rangrez correct it.`
735
+ : `<b>At home:</b> household blue, red and yellow can't quite reach this shade. Closest: ${drops} <span class="sw" style="background:${h.closest_hex}"></span>${h.closest_hex}.`;
736
+ }
737
+ function showRead(j, note) {
738
+ $("picker").value = j.hex; setDye(j.hex); targetHex = j.hex; drawTape(); targetFromPhoto = true;
739
+ showHomeRecipe(j.home_recipe);
740
+ $("photoNote").textContent = note || {
741
+ card: `SAM 3.1 read the ${j.concept || "garment"} as ${j.hex}, calibrated against the colour card.`,
742
+ paper: `SAM 3.1 read ${j.hex}, balanced against the white paper. Photograph every dip in the same spot and light.`,
743
+ none: `Read ${j.hex}, uncalibrated: put white paper or the colour card in the shot.`,
744
+ }[j.calibration];
745
+ }
746
+ function showPhoto(src, caption) {
747
+ $("frame").src = src; $("frame").hidden = false; $("cameraEmpty").hidden = true; $("frameCaption").textContent = caption;
748
+ }
749
+
750
+ async function readPhoto(blob, name) {
751
  const form = new FormData();
752
+ form.append("photo", blob, name); form.append("concept", $("concept").value || "fabric"); form.append("passcode", $("passcode").value);
753
+ $("photoNote").textContent = "SAM 3.1 is finding the garment and the colour card…";
754
  const r = await fetch("/api/target-from-photo", { method: "POST", body: form });
755
  const j = await r.json();
756
+ if (!r.ok) return { error: j.detail || "Could not read the photo." };
757
+ showRead(j); return j;
758
+ }
759
+ $("usePhoto").onclick = async () => {
760
+ const file = $("photo").files[0];
761
+ if (!file) { $("photoNote").textContent = "Choose a photo first."; return; }
762
+ showPhoto(URL.createObjectURL(file), "The customer's photo.");
763
+ const j = await readPhoto(file, file.name);
764
+ if (j.error) $("photoNote").textContent = j.error;
765
+ };
766
+ // The sample: read it live if the Meta models are available, else show how live SAM read it earlier.
767
+ $("sampleBtn").onclick = async () => {
768
+ if (!samples) return;
769
+ $("concept").value = samples.concept;
770
+ showPhoto(samples.photo, "A customer's kurta photographed on white paper with the printed colour card.");
771
+ const live = config.online || $("passcode").value;
772
+ if (live) {
773
+ const blob = await fetch(samples.photo).then((r) => r.blob());
774
+ const j = await readPhoto(blob, "kurta.jpg");
775
+ if (!j.error) return;
776
  }
777
+ if (samples.read) showRead(samples.read, `Live SAM 3.1 read this kurta earlier as ${samples.read.hex}, calibrated against the colour card. Press Start the lab.`);
778
+ };
779
+ $("replayBtn").onclick = async () => {
780
+ if (!samples || !samples.runs.length) return;
781
+ const r = await fetch(`/api/replay/${encodeURIComponent(samples.runs[samples.runs.length - 1].name)}`, { method: "POST" });
782
+ if (!r.ok) { const j = await r.json(); $("photoNote").textContent = j.detail || "Could not replay."; }
783
  };
 
784
  $("start").onclick = async () => {
785
  const r = await fetch("/api/campaign", {
786
  method: "POST", headers: { "Content-Type": "application/json" },
 
796
  $("sendPhoto").onclick = () => {
797
  const file = $("assistPhoto").files[0];
798
  if (!file) { $("assistNote").textContent = "Take or choose a photo first."; return; }
799
+ const form = new FormData(); form.append("photo", file); sendAssist("/api/assist/photo", form);
 
800
  };
801
  $("useWebcam").onclick = () => sendAssist("/api/assist/webcam");
802
  $("stop").onclick = () => fetch("/api/stop", { method: "POST" });
803
 
804
+ $("replyBtn").onclick = async () => {
805
+ const form = new FormData(); form.append("passcode", $("passcode").value);
806
+ $("replyNote").textContent = "Muse Spark is drafting the reply in the buyer's language…";
807
+ const r = await fetch("/api/reply", { method: "POST", body: form });
808
+ const j = await r.json();
809
+ if (!r.ok) { $("replyNote").textContent = j.detail || "Could not draft the reply."; return; }
810
+ $("replyText").value = j.reply; $("replyText").hidden = false;
811
+ $("replyNote").textContent = "A draft: check it, then send it with the lab-dip report. Rangrez never sends messages itself.";
812
+ };
813
+ $("readOrder").onclick = async () => {
814
+ const msg = $("orderMsg").value.trim() || (samples && samples.order) || "";
815
+ if (!msg) { $("orderNote").textContent = "Paste the buyer's message first."; return; }
816
+ $("orderMsg").value = msg;
817
+ const form = new FormData(); form.append("message", msg); form.append("passcode", $("passcode").value);
818
+ $("orderNote").textContent = "Muse Spark is reading the order…";
819
+ const r = await fetch("/api/order", { method: "POST", body: form });
820
+ const o = await r.json();
821
+ if (!r.ok) { $("orderNote").textContent = o.detail || "Could not read the order."; return; }
822
+ $("tol").value = o.tolerance_de00; orderRead = true;
823
+ $("orderNote").textContent = `${o.quantity} ${o.quantity_unit} of ${o.garment}, ${o.fabric}` + (o.deadline ? `, by ${o.deadline}` : "") +
824
+ `. Shade ${o.shade_strictness}: pass at ΔE00 ≤ ${o.tolerance_de00}.` + (o.lab_dip_due ? ` Lab dip due ${o.lab_dip_due}.` : "");
825
+ };
826
  $("previewBtn").onclick = async () => {
827
  const form = new FormData(); form.append("passcode", $("passcode").value);
828
  $("previewNote").textContent = "Muse Image is recolouring the garment; SAM 3.1 will check it…";
 
830
  const j = await r.json();
831
  if (!r.ok) { $("previewNote").textContent = j.detail || "Preview failed."; return; }
832
  $("previewImg").src = `/api/preview.jpg?t=${Date.now()}`; $("previewImg").hidden = false;
833
+ $("previewNote").textContent = `Dip ${j.dip}'s shade on the customer's garment. Muse Image's first attempt measured ΔE00 ${j.raw_de00} from the dip, so Rangrez corrected it: a picture for the eye, the dip is the evidence.`;
834
  };
835
+
836
+ // ---- hands-free: Muse Voice Transcribe hears the dyer; the browser reads instructions aloud ----
837
+ const DYE_HI = { blue: "नीला", red: "लाल", yellow: "पीला", turquoise: "फ़िरोज़ी", fuchsia: "रानी", "lemon yellow": "नींबू पीला" };
838
+ function instructions(e) {
839
+ const parts = Object.entries(e.amounts).filter(([, v]) => +v > 0);
840
+ if ($("lang").value === "hi")
841
+ return `डिप ${e.index}। ` + parts.map(([d, v]) => `${DYE_HI[d] || d} ${v} ${e.unit === "drops" ? "बूँद" : "एमएल"}`).join(", ") + `। ${e.dip_seconds} सेकंड डुबोइए। कपड़ा सूखकर कागज़ पर रखें, तो 'हो गया' बोलिए।`;
842
+ return `Dip ${e.index}. ` + parts.map(([d, v]) => `${v} ${e.unit} of ${d}`).join(", ") + `. Dip for ${e.dip_seconds} seconds. Say done when the strip is dry and on the paper.`;
843
+ }
844
  function say(text) {
845
  if (!("speechSynthesis" in window)) return;
846
  speechSynthesis.cancel();
847
+ const u = new SpeechSynthesisUtterance(text); u.lang = $("lang").value === "hi" ? "hi-IN" : "en-IN"; speechSynthesis.speak(u);
848
  }
849
+ function wav16k(samplesIn, rate) {
850
+ const ratio = rate / 16000, n = Math.floor(samplesIn.length / ratio), pcm = new Int16Array(n);
851
+ for (let i = 0; i < n; i++) pcm[i] = Math.max(-1, Math.min(1, samplesIn[Math.floor(i * ratio)])) * 0x7fff;
852
  const buf = new ArrayBuffer(44 + pcm.length * 2), v = new DataView(buf);
853
  const str = (o, t) => [...t].forEach((ch, i) => v.setUint8(o + i, ch.charCodeAt(0)));
854
  str(0, "RIFF"); v.setUint32(4, 36 + pcm.length * 2, true); str(8, "WAVEfmt "); v.setUint32(16, 16, true);
 
859
  }
860
  async function sendTurn(blob) {
861
  const form = new FormData();
862
+ form.append("audio", blob, "turn.wav"); form.append("passcode", $("passcode").value); form.append("lang", $("lang").value);
863
  $("voiceNote").textContent = "Transcribing with Muse Voice Transcribe…";
864
  const r = await fetch("/api/voice", { method: "POST", body: form });
865
  const j = await r.json();
 
895
  $("handsFree").setAttribute("aria-pressed", handsFree);
896
  $("handsFree").textContent = `Hands-free voice: ${handsFree ? "listening" : "off"}`;
897
  if (handsFree) {
898
+ try { await startListening(); $("voiceNote").textContent = $("lang").value === "hi" ? "सुन रहे हैं। बोलिए: \"हो गया\", \"फिर से\" या \"बंद करो\"।" : "Listening. Say \"done\", \"repeat\" or \"stop\"."; }
899
  catch (err) { handsFree = false; $("handsFree").textContent = "Hands-free voice: off"; $("voiceNote").textContent = "Microphone unavailable: " + err.message; }
900
  } else if (mic) { mic.stream.getTracks().forEach((t) => t.stop()); mic.ctx.close(); mic = null; }
901
  };
902
+
903
+ // ---- tabs ----------------------------------------------------------------------------------
904
+ function showTab(name) {
905
+ $("labView").hidden = name !== "lab"; $("caseView").hidden = name !== "case";
906
+ $("tapeWrap").hidden = name !== "lab";
907
+ $("tabLab").setAttribute("aria-selected", name === "lab"); $("tabCase").setAttribute("aria-selected", name === "case");
908
+ window.scrollTo({ top: 0, behavior: "smooth" });
909
+ }
910
+ $("tabLab").onclick = () => showTab("lab"); $("tabCase").onclick = () => showTab("case"); $("caseToLab").onclick = () => showTab("lab");
911
+
912
+ // ---- the guided tour: shows the whole project, and has the judge run it ---------------------
913
+ const TOUR = [
914
+ { title: "Welcome to Rangrez", text: "A dye house's lab dip, in minutes instead of days, from a phone. In two minutes you'll run the lab yourself: match a kurta, watch it dye and correct, and get the buyer's report." },
915
+ { el: "models", title: "Six Meta models, six jobs", text: "SAM 3.1 measures, Muse Wonder runs the lab, Muse Glimmer proposes the first dip, Muse Spark reads orders, Muse Image previews, Muse Voice listens. Each lights up the moment it's called." },
916
+ { el: "startCard", title: "Start from the customer's garment", text: "A buyer sends a photo of the kurta to match. Rangrez finds the garment and the printed colour card with SAM 3.1 and turns pixels into a calibrated colour.", action: () => $("sampleBtn").click() },
917
+ { el: "cameraCard", title: "SAM 3.1 as a measuring instrument", text: "The colour card corrects the camera; the white paper evens out uneven light. The kurta's colour is now the target, and the page takes on its shade." },
918
+ { el: "tapeWrap", title: "Now the lab runs", text: "We'll replay a recorded live run, sped up: every Wonder decision and SAM measurement in it happened for real. Watch each dip become a segment of this cotton tape.", action: () => $("replayBtn").click() },
919
+ { el: "gaugeCard", title: "Closer with every dip", text: "CIEDE2000 is the colour difference the eye sees. Below the tolerance (green) is a match; on a robot rig a second photo must agree before Rangrez calls it." },
920
+ { el: "logCard", title: "Muse Wonder, the colourist", text: "Wonder chooses each dip with tools: the dye model, the lab's memory, a correction learned from real dips. It never touches a pump: a safety gate checks every recipe, and it hands over to a human when stuck.", action: () => { $("logDetails").open = true; } },
921
+ { el: "learningCard", title: "It learns this dye lot", text: "Every dip is remembered. Over a 40-campaign night shift, the first dip's error fell from 3.81 to 1.40 ΔE00. Glimmer, fine-tuned on the lab's records, proposes first dips too." },
922
+ { el: "outcome", title: "The buyer's lab-dip report", text: "When it matches: a report the buyer already asks for: ΔE00 and CMC 2:1, options A/B/C, the recipe in % on weight of fabric, the production plan, and the garment previewed in the shade.", wait: true },
923
+ { el: "advanced", title: "Hands-free, in Hindi too", text: "The dyer's gloves are wet with dye: say \"done\" or \"हो गया\", \"repeat\" or \"फिर से\". Paste the buyer's WhatsApp order and Muse Spark reads it and drafts the reply.", action: () => { $("advanced").open = true; } },
924
+ { el: "tabCase", title: "The case", text: "What only Rangrez does, what it has shown, where it honestly stands (simulated, not yet dyed for real), and how a dye house would adopt it." },
925
+ { title: "Your turn", text: "Pick a preset shade or your own colour and press Start the lab. Or open “Your own photo” and photograph your outfit: Rangrez gives you a recipe to dye a matching cloth at home." },
926
+ ];
927
+ let tourAt = 0;
928
+ function placeTour() {
929
+ const step = TOUR[tourAt], spot = $("tourSpot"), card = $("tourCard");
930
+ $("tourStep").textContent = `${tourAt + 1} of ${TOUR.length}`; $("tourTitle").textContent = step.title; $("tourText").textContent = step.text;
931
+ $("tourBack").disabled = tourAt === 0; $("tourNext").textContent = tourAt === TOUR.length - 1 ? "Start exploring" : "Next";
932
+ const el = step.el && $(step.el);
933
+ if (el && !el.hidden) {
934
+ el.scrollIntoView({ block: "center", behavior: "smooth" });
935
+ followSpot();
936
+ } else {
937
+ Object.assign(spot.style, { left: innerWidth / 2 + "px", top: innerHeight / 2 + "px", width: "0px", height: "0px" });
938
+ card.style.left = (innerWidth - card.offsetWidth) / 2 + "px"; card.style.top = innerHeight * 0.3 + "px";
939
+ }
940
+ }
941
+ // Keep the spotlight on its element while the page scrolls into place (and afterwards).
942
+ function followSpot() {
943
+ const step = TOUR[tourAt], el = step && step.el && $(step.el);
944
+ if ($("tour").hidden || !el || el.hidden) return;
945
+ const r = el.getBoundingClientRect(), pad = 8, spot = $("tourSpot"), card = $("tourCard");
946
+ Object.assign(spot.style, { left: r.left - pad + "px", top: r.top - pad + "px", width: r.width + 2 * pad + "px", height: r.height + 2 * pad + "px" });
947
+ const below = r.bottom + 16 + card.offsetHeight < innerHeight;
948
+ const above = r.top - card.offsetHeight - 16 > 0;
949
+ card.style.left = Math.max(16, Math.min(innerWidth - card.offsetWidth - 16, r.left)) + "px";
950
+ card.style.top = (below ? r.bottom + 16 : above ? r.top - card.offsetHeight - 16 : 16) + "px";
951
+ }
952
+ addEventListener("scroll", followSpot, { passive: true });
953
+
954
+ function tourGo(i) {
955
+ if (i >= TOUR.length) return endTour();
956
+ tourAt = Math.max(0, i);
957
+ const step = TOUR[tourAt];
958
+ if (step.el === "tabCase" || step.el === "models") showTab(step.el === "tabCase" ? "case" : "lab"); else if (step.el) showTab("lab");
959
+ if (step.action) step.action();
960
+ if (step.wait && $(step.el).hidden) { // wait for the replay to finish, then point at the report
961
+ $("tourText").textContent = "The lab is still dyeing… the report appears when it's done.";
962
+ const t = setInterval(() => { if (!$(step.el).hidden || $("tour").hidden) { clearInterval(t); placeTour(); } }, 500);
963
+ }
964
+ placeTour();
965
+ }
966
+ function endTour() { $("tour").hidden = true; showTab("lab"); try { localStorage.setItem("rangrez-tour", "done"); } catch (e) {} }
967
+ $("tourBtn").onclick = () => { $("tour").hidden = false; tourGo(0); };
968
+ $("tourNext").onclick = () => tourGo(tourAt + 1);
969
+ $("tourBack").onclick = () => tourGo(tourAt - 1);
970
+ $("tourSkip").onclick = endTour;
971
+ addEventListener("resize", () => { if (!$("tour").hidden) placeTour(); });
972
+ addEventListener("keydown", (ev) => { if ($("tour").hidden) return; if (ev.key === "Escape") endTour(); if (ev.key === "ArrowRight") tourGo(tourAt + 1); if (ev.key === "ArrowLeft") tourGo(tourAt - 1); });
973
+ // Shareable links: ?replay=1 plays the recorded live run, ?tour=1 opens the tour.
974
+ const params = new URLSearchParams(location.search);
975
+ if (params.has("replay")) {
976
+ const go = () => samples ? ($("sampleBtn").click(), setTimeout(() => $("replayBtn").click(), 500)) : setTimeout(go, 200);
977
+ go();
978
+ } else if (params.has("tour")) {
979
+ setTimeout(() => { $("tour").hidden = false; tourGo(0); }, 600);
980
+ } else {
981
+ try { if (!localStorage.getItem("rangrez-tour")) setTimeout(() => { $("tour").hidden = false; tourGo(0); }, 900); } catch (e) {}
982
+ }
983
+
984
+ setDye($("picker").value); drawChart(); drawTape(); setGauge(NaN);
985
  </script>
986
  </body>
987
  </html>
src/rangrez/static/logo.svg ADDED
src/rangrez/voice.py CHANGED
@@ -19,22 +19,30 @@ from .profiles import PROFILE
19
 
20
  MODEL = "muse-voice-transcribe-1.0"
21
  URL = "https://api.meta.ai/v1/asr/transcribe"
22
- KEYWORDS = ["done", "dip", "photo", "webcam", "repeat", "stop", "drops", "patchy", "darker", "lighter", "Rangrez",
23
- *PROFILE.names]
 
 
24
 
25
- INTENTS = [ # first match wins; whole words only
26
- ("stop", r"\b(stop|cancel|abort)\b"),
27
- ("repeat", r"\b(repeat|again|say that|what was|instructions)\b"),
28
- ("photo", r"\b(done|ready|photo|picture|capture|webcam|shoot|snap|dry now|it'?s dry)\b"),
 
29
  ]
 
30
 
31
 
32
- def transcribe(wav: bytes, log=None) -> str:
33
- """Text of one spoken turn (a 16 kHz mono WAV)."""
34
  from .vision import ApiCall
35
 
36
  boundary = uuid.uuid4().hex
37
- request = json.dumps({"model": MODEL, "audioEncoding": "WAV", "mode": "PUSH_TO_TALK", "keywords": KEYWORDS})
 
 
 
 
38
  body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"request\"\r\nContent-Type: application/json\r\n\r\n"
39
  f"{request}\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"audio\"; filename=\"turn.wav\"\r\n"
40
  "Content-Type: audio/wav\r\n\r\n").encode() + wav + f"\r\n--{boundary}--\r\n".encode()
@@ -62,7 +70,30 @@ def intent(text: str) -> str:
62
  for name, pattern in INTENTS:
63
  if re.search(pattern, t):
64
  # "it looks done but patchy" is an observation with a command in it: a long turn is a note.
65
- if name == "photo" and len(t.split()) > 6:
66
  return "note"
67
  return name
68
  return "note"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
 
20
  MODEL = "muse-voice-transcribe-1.0"
21
  URL = "https://api.meta.ai/v1/asr/transcribe"
22
+ KEYWORDS = {
23
+ "en": ["done", "dip", "photo", "webcam", "repeat", "stop", "drops", "patchy", "darker", "lighter", "Rangrez", *PROFILE.names],
24
+ "hi": [], # tried: Devanagari keywords made Hindi worse ("फोटो लो" heard as "फट लो"); language bias alone is clean
25
+ }
26
 
27
+ INTENTS = [ # first match wins. English as whole words; Hindi (Devanagari or Hinglish) as phrases.
28
+ ("stop", r"\b(stop|cancel|abort|band karo|ruk jao)\b|रुको|रुक जाओ|बंद करो|^बस\b"),
29
+ ("repeat", r"\b(repeat|again|say that|what was|instructions|phir se|dobara)\b|फिर से|दोबारा|दुबारा|क्या था"),
30
+ ("photo", (r"\b(done|ready|photo|picture|capture|webcam|shoot|snap|dry now|it'?s dry|ho gaya|ho gya)\b"
31
+ r"|हो गया|हो गई|फोटो|फ़ोटो|तस्वीर|खींचो|सूख गया")),
32
  ]
33
+ LANGUAGES = {"en": ["English"], "hi": ["Hindi", "English"]}
34
 
35
 
36
+ def transcribe(wav: bytes, log=None, lang: str = "en") -> str:
37
+ """Text of one spoken turn (a 16 kHz mono WAV), biased towards the dyer's language."""
38
  from .vision import ApiCall
39
 
40
  boundary = uuid.uuid4().hex
41
+ lang = lang if lang in LANGUAGES else "en"
42
+ settings = {"model": MODEL, "audioEncoding": "WAV", "mode": "PUSH_TO_TALK", "languageBias": LANGUAGES[lang]}
43
+ if KEYWORDS[lang]:
44
+ settings["keywords"] = KEYWORDS[lang]
45
+ request = json.dumps(settings)
46
  body = (f"--{boundary}\r\nContent-Disposition: form-data; name=\"request\"\r\nContent-Type: application/json\r\n\r\n"
47
  f"{request}\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"audio\"; filename=\"turn.wav\"\r\n"
48
  "Content-Type: audio/wav\r\n\r\n").encode() + wav + f"\r\n--{boundary}--\r\n".encode()
 
70
  for name, pattern in INTENTS:
71
  if re.search(pattern, t):
72
  # "it looks done but patchy" is an observation with a command in it: a long turn is a note.
73
+ if name == "photo" and len(t.split()) > 5:
74
  return "note"
75
  return name
76
  return "note"
77
+
78
+
79
+ # What the lab says back, in the dyer's language.
80
+ SAY = {
81
+ "en": {"photo": "Photo taken. Measuring.", "no_photo": "Nothing to photograph right now.",
82
+ "stopped": "Stopped.", "no_dip": "No dip is waiting.", "noted": "Noted for the colourist.",
83
+ "photo_failed": "Could not take the photo. Send one from your phone."},
84
+ "hi": {"photo": "फोटो ले ली। नाप रहे हैं।", "no_photo": "अभी फोटो लेने को कुछ नहीं है।",
85
+ "stopped": "रोक दिया।", "no_dip": "अभी कोई डिप बाकी नहीं है।", "noted": "रंगसाज़ के लिए लिख लिया।",
86
+ "photo_failed": "फोटो नहीं ले पाए। फ़ोन से फोटो भेजिए।"},
87
+ }
88
+ DYE_HI = {"blue": "नीला", "red": "लाल", "yellow": "पीला", "turquoise": "फ़िरोज़ी", "fuchsia": "रानी", "lemon yellow": "नींबू पीला"}
89
+ UNIT_HI = {"drops": "बूँद", "mL": "एमएल"}
90
+
91
+
92
+ def instructions(w: dict, lang: str = "en") -> str:
93
+ """One dip's instructions as a sentence to read aloud."""
94
+ amounts = [(d, v) for d, v in w["amounts"].items() if float(v) > 0]
95
+ if lang == "hi":
96
+ parts = ", ".join(f"{DYE_HI.get(d, d)} {v} {UNIT_HI.get(w['unit'], w['unit'])}" for d, v in amounts)
97
+ return f"डिप {w['index']}। {parts}। {w['dip_seconds']} सेकंड डुबोइए। कपड़ा सूखकर कागज़ पर रखें, तो 'हो गया' बोलिए।"
98
+ parts = ", ".join(f"{v} {w['unit']} of {d}" for d, v in amounts)
99
+ return f"Dip {w['index']}. {parts}. Dip for {w['dip_seconds']} seconds. Say done when the strip is dry and on the paper."