JacobLinCool Codex commited on
Commit
18b8de2
·
verified ·
1 Parent(s): 9e8a876

fix: render share png through server endpoint

Browse files

Co-authored-by: Codex <noreply@openai.com>

README.md CHANGED
@@ -143,6 +143,8 @@ the user-facing app stays centered on idea evaluation. The main `/api/bootstrap`
143
  Every scored fate page now carries a deterministic `wood_map` artifact: background dots for inked Spaces, red dots for
144
  the closest cited echoes, and a green/red "you" dot for the current idea. The live UI and PNG export render the same
145
  map, so the share artifact visually proves whether the page sits in an empty margin or near existing work.
 
 
146
 
147
  ## Latency Watchdog
148
 
 
143
  Every scored fate page now carries a deterministic `wood_map` artifact: background dots for inked Spaces, red dots for
144
  the closest cited echoes, and a green/red "you" dot for the current idea. The live UI and PNG export render the same
145
  map, so the share artifact visually proves whether the page sits in an empty margin or near existing work.
146
+ The `PNG` button posts the current artifact to `/api/artifact.png`, which uses the same Pillow renderer as
147
+ `/api/demo-bundle.zip`, so browser downloads and bundled evidence cannot drift into different layouts.
148
 
149
  ## Latency Watchdog
150
 
app.py CHANGED
@@ -3,8 +3,9 @@ from __future__ import annotations
3
  import json
4
  import os
5
  from pathlib import Path
6
- from typing import Iterator
7
 
 
8
  from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
9
  from gradio import Server
10
 
@@ -16,6 +17,7 @@ from hackathon_advisor.demo_rehearsal import build_demo_rehearsal
16
  from hackathon_advisor.field_notes import build_field_notes_markdown
17
  from hackathon_advisor.lora_dataset import build_lora_dataset_jsonl
18
  from hackathon_advisor.lora_training_kit import TRAINING_KIT_FILENAME, build_lora_training_kit_zip
 
19
  from hackathon_advisor.prize_ledger import prize_ledger
20
  from hackathon_advisor.submission_packet import build_submission_packet_markdown
21
  from hackathon_advisor.tool_contracts import resolve_tool_call, tool_schemas
@@ -116,6 +118,17 @@ def demo_bundle() -> Response:
116
  )
117
 
118
 
 
 
 
 
 
 
 
 
 
 
 
119
  @app.get("/api/lora-training-kit.zip")
120
  def lora_training_kit() -> Response:
121
  runtime_status = engine.runtime_status()
 
3
  import json
4
  import os
5
  from pathlib import Path
6
+ from typing import Any, Iterator
7
 
8
+ from fastapi import Body
9
  from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
10
  from gradio import Server
11
 
 
17
  from hackathon_advisor.field_notes import build_field_notes_markdown
18
  from hackathon_advisor.lora_dataset import build_lora_dataset_jsonl
19
  from hackathon_advisor.lora_training_kit import TRAINING_KIT_FILENAME, build_lora_training_kit_zip
20
+ from hackathon_advisor.png_export import artifact_png_filename, render_artifact_png
21
  from hackathon_advisor.prize_ledger import prize_ledger
22
  from hackathon_advisor.submission_packet import build_submission_packet_markdown
23
  from hackathon_advisor.tool_contracts import resolve_tool_call, tool_schemas
 
118
  )
119
 
120
 
121
+ @app.post("/api/artifact.png")
122
+ def artifact_png(artifact: dict[str, Any] | None = Body(default=None)) -> Response:
123
+ artifact = artifact or {}
124
+ filename = artifact_png_filename(artifact)
125
+ return Response(
126
+ content=render_artifact_png(artifact),
127
+ media_type="image/png",
128
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
129
+ )
130
+
131
+
132
  @app.get("/api/lora-training-kit.zip")
133
  def lora_training_kit() -> Response:
134
  runtime_status = engine.runtime_status()
hackathon_advisor/png_export.py CHANGED
@@ -148,7 +148,7 @@ def _draw_wood_map(
148
  outline=(80, 47, 22),
149
  width=2,
150
  )
151
- draw.text((x, y - 26), "YOU VS THE WOOD", font=label_font, fill=INK_SOFT)
152
 
153
  for dot in dots:
154
  if not isinstance(dot, dict):
 
148
  outline=(80, 47, 22),
149
  width=2,
150
  )
151
+ draw.text((x, y - 26), "IDEA MAP", font=label_font, fill=INK_SOFT)
152
 
153
  for dot in dots:
154
  if not isinstance(dot, dict):
hackathon_advisor/submission_packet.py CHANGED
@@ -102,7 +102,7 @@ def _artifact_checklist(trace: list[dict[str, Any]], ideas: list[dict[str, Any]]
102
  ("Almanac chapter markdown", has_ideas, "chapter"),
103
  ("MiniCPM5 LoRA SFT JSONL", has_trace, "lora_dataset"),
104
  ("Submission packet markdown", True, "submission_packet"),
105
- ("Fate page PNG", has_artifact, "client-side canvas export"),
106
  ]
107
  lines = ["## Artifact Checklist", "", "| Artifact | Status | Source |", "| --- | --- | --- |"]
108
  for name, ready, source in rows:
 
102
  ("Almanac chapter markdown", has_ideas, "chapter"),
103
  ("MiniCPM5 LoRA SFT JSONL", has_trace, "lora_dataset"),
104
  ("Submission packet markdown", True, "submission_packet"),
105
+ ("Fate page PNG", has_artifact, "/api/artifact.png"),
106
  ]
107
  lines = ["## Artifact Checklist", "", "| Artifact | Status | Source |", "| --- | --- | --- |"]
108
  for name, ready, source in rows:
static/app.js CHANGED
@@ -1036,86 +1036,45 @@ async function exportMarkdown({ endpoint, filename, button, busyLabel, pendingLa
1036
  }
1037
  }
1038
 
1039
- function exportArtifact(artifact) {
1040
  const idleLabel = actionButtonLabel(exportButton);
 
1041
  exportButton.disabled = true;
1042
  setActionButtonLabel(exportButton, "PNG...");
1043
  session.ui_status = "Drawing PNG.";
1044
  corrections.textContent = session.ui_status;
1045
  saveSession();
1046
  try {
1047
- const filename = `${slugify(artifact.title || "unwritten-page")}.png`;
1048
- const canvas = renderArtifactCanvas(artifact);
1049
- const dataUrl = canvas.toDataURL("image/png");
1050
- if (!dataUrl.startsWith("data:image/png")) throw new Error("PNG rendering failed");
1051
- const link = document.createElement("a");
1052
- link.download = filename;
1053
- link.href = dataUrl;
1054
- link.click();
 
 
 
1055
  session.ui_status = `PNG saved: ${filename}`;
1056
  corrections.textContent = session.ui_status;
1057
  } catch (error) {
 
1058
  session.ui_status = `Export failed: ${error.message}`;
1059
  corrections.textContent = session.ui_status;
1060
  } finally {
1061
- saveSession();
1062
  setActionButtonLabel(exportButton, idleLabel || PNG_EXPORT_LABEL);
1063
  setCommandDisabled(false);
1064
  }
1065
  }
1066
 
1067
- function renderArtifactCanvas(artifact) {
1068
- const canvas = document.createElement("canvas");
1069
- canvas.width = 1200;
1070
- canvas.height = 675;
1071
- const ctx = canvas.getContext("2d");
1072
- if (!ctx) throw new Error("canvas is unavailable");
1073
- drawParchment(ctx, canvas.width, canvas.height);
1074
- const seal = artifact.seal || {};
1075
- ctx.fillStyle = "#25160e";
1076
- ctx.font = "700 58px Georgia, serif";
1077
- wrapText(ctx, artifact.title, 78, 112, 760, 66);
1078
- ctx.font = "28px Georgia, serif";
1079
- ctx.fillStyle = "#6b4e35";
1080
- wrapText(ctx, artifact.caption || "", 82, 252, 720, 36);
1081
- drawCitationList(ctx, seal.echoes || [], 742, 330, 330);
1082
-
1083
- ctx.save();
1084
- ctx.translate(930, 226);
1085
- ctx.rotate(-0.08);
1086
- ctx.fillStyle = artifact.verdict?.startsWith("UNWRITTEN") ? "#b68a12" : "#8d2d26";
1087
- ctx.beginPath();
1088
- ctx.arc(0, 0, 120, 0, Math.PI * 2);
1089
- ctx.fill();
1090
- ctx.fillStyle = "#fff0b5";
1091
- ctx.textAlign = "center";
1092
- ctx.font = "800 27px Inter, sans-serif";
1093
- wrapText(ctx, artifact.verdict || "UNWRITTEN", -92, -28, 184, 32, "center");
1094
- ctx.font = "700 58px Georgia, serif";
1095
- ctx.fillText(Number(artifact.overall || seal.overall || 0).toFixed(1), 0, 48);
1096
- ctx.restore();
1097
-
1098
- const rows = [
1099
- ["Originality", seal.originality || 0],
1100
- ["Delight", seal.delight || 0],
1101
- ["AI Need", seal.ai_necessity || 0],
1102
- ["Feasible", seal.feasibility || 0],
1103
- ["Goal Fit", seal.goal_fit || 0],
1104
- ];
1105
- rows.forEach(([label, value], index) => {
1106
- const y = 418 + index * 34;
1107
- ctx.fillStyle = "#6b4e35";
1108
- ctx.font = "700 20px Inter, sans-serif";
1109
- ctx.fillText(label, 82, y);
1110
- ctx.fillStyle = "rgba(80, 47, 22, 0.22)";
1111
- ctx.fillRect(240, y - 17, 320, 16);
1112
- ctx.fillStyle = artifact.verdict?.startsWith("UNWRITTEN") ? "#2f7a49" : "#8d2d26";
1113
- ctx.fillRect(240, y - 17, 32 * Number(value), 16);
1114
- ctx.fillStyle = "#25160e";
1115
- ctx.fillText(String(value), 582, y);
1116
- });
1117
- drawWoodMap(ctx, artifact.wood_map, 742, 396, 330, 184, artifact.verdict);
1118
- return canvas;
1119
  }
1120
 
1121
  function downloadText(filename, text, type = "application/jsonl;charset=utf-8") {
@@ -1127,108 +1086,9 @@ function downloadText(filename, text, type = "application/jsonl;charset=utf-8")
1127
  setTimeout(() => URL.revokeObjectURL(link.href), 0);
1128
  }
1129
 
1130
- function drawParchment(ctx, width, height) {
1131
- const gradient = ctx.createLinearGradient(0, 0, width, height);
1132
- gradient.addColorStop(0, "#ead7a7");
1133
- gradient.addColorStop(0.55, "#d4b476");
1134
- gradient.addColorStop(1, "#b98a4c");
1135
- ctx.fillStyle = gradient;
1136
- ctx.fillRect(0, 0, width, height);
1137
- ctx.fillStyle = "rgba(59, 33, 15, 0.16)";
1138
- for (let i = 0; i < 360; i += 1) {
1139
- const x = (i * 73) % width;
1140
- const y = (i * 37) % height;
1141
- ctx.fillRect(x, y, 2 + (i % 7), 1);
1142
- }
1143
- ctx.strokeStyle = "rgba(72, 39, 18, 0.42)";
1144
- ctx.lineWidth = 16;
1145
- ctx.strokeRect(28, 28, width - 56, height - 56);
1146
- }
1147
-
1148
- function drawWoodMap(ctx, map, x, y, width, height, verdict) {
1149
- if (!map?.dots?.length) return;
1150
- ctx.save();
1151
- ctx.fillStyle = "rgba(255, 241, 196, 0.38)";
1152
- ctx.strokeStyle = "rgba(80, 47, 22, 0.34)";
1153
- ctx.lineWidth = 2;
1154
- ctx.beginPath();
1155
- ctx.roundRect(x, y, width, height, 8);
1156
- ctx.fill();
1157
- ctx.stroke();
1158
-
1159
- ctx.fillStyle = "#6b4e35";
1160
- ctx.font = "800 18px Inter, sans-serif";
1161
- ctx.fillText("IDEA MAP", x, y - 14);
1162
-
1163
- for (const dot of map.dots) {
1164
- const px = x + (width * boundedPercent(dot.x)) / 100;
1165
- const py = y + (height * boundedPercent(dot.y)) / 100;
1166
- const radius = Math.max(3, Math.min(10, Number(dot.radius || 4)));
1167
- if (dot.kind === "idea") {
1168
- ctx.fillStyle = verdict?.startsWith("UNWRITTEN") ? "#2f7a49" : "#8d2d26";
1169
- ctx.strokeStyle = "#fff0b5";
1170
- ctx.lineWidth = 3;
1171
- } else if (dot.kind === "echo") {
1172
- ctx.fillStyle = "#8d2d26";
1173
- ctx.strokeStyle = "rgba(255, 240, 181, 0.72)";
1174
- ctx.lineWidth = 1.5;
1175
- } else {
1176
- ctx.fillStyle = "rgba(80, 47, 22, 0.34)";
1177
- ctx.strokeStyle = "transparent";
1178
- ctx.lineWidth = 0;
1179
- }
1180
- ctx.beginPath();
1181
- ctx.arc(px, py, radius, 0, Math.PI * 2);
1182
- ctx.fill();
1183
- if (ctx.lineWidth) ctx.stroke();
1184
- }
1185
-
1186
- ctx.fillStyle = "#6b4e35";
1187
- ctx.font = "700 15px Inter, sans-serif";
1188
- wrapText(ctx, map.caption || "", x, y + height + 24, width, 20);
1189
- ctx.restore();
1190
- }
1191
-
1192
- function drawCitationList(ctx, echoes, x, y, maxWidth) {
1193
- if (!echoes.length) return;
1194
- ctx.save();
1195
- ctx.fillStyle = "#6b4e35";
1196
- ctx.font = "800 18px Inter, sans-serif";
1197
- ctx.fillText("CLOSEST PAGES", x, y);
1198
- ctx.font = "700 15px Inter, sans-serif";
1199
- echoes.slice(0, 3).forEach((echo, index) => {
1200
- const project = echo.project || {};
1201
- const label = `Page ${echo.page_number || "?"}: ${project.title || project.id || "Untitled"}`;
1202
- wrapText(ctx, label, x, y + 24 + index * 26, maxWidth, 18);
1203
- });
1204
- ctx.restore();
1205
- }
1206
-
1207
- function wrapText(ctx, text, x, y, maxWidth, lineHeight, align = "left") {
1208
- const words = String(text).split(/\s+/);
1209
- let line = "";
1210
- const originalAlign = ctx.textAlign;
1211
- ctx.textAlign = align;
1212
- for (const word of words) {
1213
- const next = line ? `${line} ${word}` : word;
1214
- if (ctx.measureText(next).width > maxWidth && line) {
1215
- ctx.fillText(line, align === "center" ? x + maxWidth / 2 : x, y);
1216
- line = word;
1217
- y += lineHeight;
1218
- } else {
1219
- line = next;
1220
- }
1221
- }
1222
- if (line) ctx.fillText(line, align === "center" ? x + maxWidth / 2 : x, y);
1223
- ctx.textAlign = originalAlign;
1224
- }
1225
-
1226
- function slugify(value) {
1227
- return String(value)
1228
- .toLowerCase()
1229
- .replace(/[^a-z0-9]+/g, "-")
1230
- .replace(/^-|-$/g, "")
1231
- .slice(0, 60);
1232
  }
1233
 
1234
  function escapeHtml(value) {
 
1036
  }
1037
  }
1038
 
1039
+ async function exportArtifact(artifact) {
1040
  const idleLabel = actionButtonLabel(exportButton);
1041
+ const revision = sessionRevision;
1042
  exportButton.disabled = true;
1043
  setActionButtonLabel(exportButton, "PNG...");
1044
  session.ui_status = "Drawing PNG.";
1045
  corrections.textContent = session.ui_status;
1046
  saveSession();
1047
  try {
1048
+ const response = await fetch("/api/artifact.png", {
1049
+ method: "POST",
1050
+ headers: { "Content-Type": "application/json" },
1051
+ body: JSON.stringify(artifact),
1052
+ });
1053
+ if (!response.ok) throw new Error(`PNG rendering failed with ${response.status}`);
1054
+ const blob = await response.blob();
1055
+ if (!blob.size || !blob.type.includes("png")) throw new Error("PNG rendering failed");
1056
+ if (!isCurrentSessionRevision(revision)) return;
1057
+ const filename = filenameFromContentDisposition(response.headers.get("content-disposition")) || "unwritten-page.png";
1058
+ downloadBlob(filename, blob);
1059
  session.ui_status = `PNG saved: ${filename}`;
1060
  corrections.textContent = session.ui_status;
1061
  } catch (error) {
1062
+ if (!isCurrentSessionRevision(revision)) return;
1063
  session.ui_status = `Export failed: ${error.message}`;
1064
  corrections.textContent = session.ui_status;
1065
  } finally {
1066
+ if (isCurrentSessionRevision(revision)) saveSession();
1067
  setActionButtonLabel(exportButton, idleLabel || PNG_EXPORT_LABEL);
1068
  setCommandDisabled(false);
1069
  }
1070
  }
1071
 
1072
+ function downloadBlob(filename, blob) {
1073
+ const link = document.createElement("a");
1074
+ link.download = filename;
1075
+ link.href = URL.createObjectURL(blob);
1076
+ link.click();
1077
+ setTimeout(() => URL.revokeObjectURL(link.href), 0);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1078
  }
1079
 
1080
  function downloadText(filename, text, type = "application/jsonl;charset=utf-8") {
 
1086
  setTimeout(() => URL.revokeObjectURL(link.href), 0);
1087
  }
1088
 
1089
+ function filenameFromContentDisposition(value) {
1090
+ const match = String(value || "").match(/filename="([^"]+)"/i);
1091
+ return match ? match[1] : "";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1092
  }
1093
 
1094
  function escapeHtml(value) {
tests/test_app.py CHANGED
@@ -3,6 +3,7 @@ from io import BytesIO
3
  from zipfile import ZipFile
4
 
5
  from app import (
 
6
  bootstrap,
7
  chapter_artifact,
8
  demo_bundle,
@@ -156,6 +157,18 @@ def test_demo_bundle_endpoint_returns_zip_attachment() -> None:
156
  assert manifest["turn_count"] == 2
157
 
158
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  def test_lora_training_kit_endpoint_returns_zip_attachment() -> None:
160
  response = lora_training_kit()
161
 
 
3
  from zipfile import ZipFile
4
 
5
  from app import (
6
+ artifact_png,
7
  bootstrap,
8
  chapter_artifact,
9
  demo_bundle,
 
157
  assert manifest["turn_count"] == 2
158
 
159
 
160
+ def test_artifact_png_endpoint_returns_png_attachment() -> None:
161
+ state = engine.turn("A local-first archive cartographer for family photos", {}).state
162
+ response = artifact_png(state["last_artifact"])
163
+
164
+ assert response.media_type == "image/png"
165
+ assert 'filename="a-local-first-archive-cartographer-for-family-photos.png"' in response.headers[
166
+ "content-disposition"
167
+ ]
168
+ assert response.body.startswith(b"\x89PNG\r\n\x1a\n")
169
+ assert len(response.body) > 10_000
170
+
171
+
172
  def test_lora_training_kit_endpoint_returns_zip_attachment() -> None:
173
  response = lora_training_kit()
174
 
tests/test_frontend_copy.py CHANGED
@@ -10,6 +10,9 @@ def test_main_interface_copy_is_builder_facing() -> None:
10
  assert "Closest project echoes" in html
11
  assert "Press Plan to draft build steps for the selected idea." in app_js
12
  assert "Loading an example idea board." in app_js
 
 
 
13
 
14
  stale_jargon = [
15
  "No wax path pressed.",
@@ -40,3 +43,10 @@ def test_visible_static_shell_does_not_promote_submission_evidence() -> None:
40
  ]
41
  for term in promotional_terms:
42
  assert term not in html
 
 
 
 
 
 
 
 
10
  assert "Closest project echoes" in html
11
  assert "Press Plan to draft build steps for the selected idea." in app_js
12
  assert "Loading an example idea board." in app_js
13
+ assert "/api/artifact.png" in app_js
14
+ assert "renderArtifactCanvas" not in app_js
15
+ assert "canvas.toDataURL" not in app_js
16
 
17
  stale_jargon = [
18
  "No wax path pressed.",
 
43
  ]
44
  for term in promotional_terms:
45
  assert term not in html
46
+
47
+
48
+ def test_server_png_copy_uses_interface_language() -> None:
49
+ source = Path("hackathon_advisor/png_export.py").read_text(encoding="utf-8")
50
+
51
+ assert "IDEA MAP" in source
52
+ assert "YOU VS THE WOOD" not in source
tests/test_submission_packet.py CHANGED
@@ -32,6 +32,7 @@ def test_submission_packet_contains_demo_and_prize_evidence() -> None:
32
  assert "Hackathon Advisor" in markdown
33
  assert "Well-Tuned | training-kit-ready" in markdown
34
  assert "MiniCPM5 LoRA SFT JSONL | ready | lora_dataset" in markdown
 
35
  assert "Notes, Chapter, and PNG are available in the app" in markdown
36
  assert "`/api/prize-ledger` separates ready and planned badge states" in markdown
37
  assert "A local-first archive cartographer for family photos" in markdown
 
32
  assert "Hackathon Advisor" in markdown
33
  assert "Well-Tuned | training-kit-ready" in markdown
34
  assert "MiniCPM5 LoRA SFT JSONL | ready | lora_dataset" in markdown
35
+ assert "Fate page PNG | ready | /api/artifact.png" in markdown
36
  assert "Notes, Chapter, and PNG are available in the app" in markdown
37
  assert "`/api/prize-ledger` separates ready and planned badge states" in markdown
38
  assert "A local-first archive cartographer for family photos" in markdown