janction-render 0.4.97__tar.gz → 0.4.99__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {janction_render-0.4.97 → janction_render-0.4.99}/PKG-INFO +1 -1
  2. {janction_render-0.4.97 → janction_render-0.4.99}/janction_render/__init__.py +1 -1
  3. {janction_render-0.4.97 → janction_render-0.4.99}/janction_render/cli.py +19 -7
  4. {janction_render-0.4.97 → janction_render-0.4.99}/janction_render/client.py +103 -13
  5. {janction_render-0.4.97 → janction_render-0.4.99}/janction_render/mcp_server.py +7 -4
  6. {janction_render-0.4.97 → janction_render-0.4.99}/janction_render.egg-info/PKG-INFO +1 -1
  7. {janction_render-0.4.97 → janction_render-0.4.99}/pyproject.toml +1 -1
  8. {janction_render-0.4.97 → janction_render-0.4.99}/LICENSE +0 -0
  9. {janction_render-0.4.97 → janction_render-0.4.99}/README.md +0 -0
  10. {janction_render-0.4.97 → janction_render-0.4.99}/janction_render/brief.py +0 -0
  11. {janction_render-0.4.97 → janction_render-0.4.99}/janction_render/hookup.py +0 -0
  12. {janction_render-0.4.97 → janction_render-0.4.99}/janction_render/links.py +0 -0
  13. {janction_render-0.4.97 → janction_render-0.4.99}/janction_render/mcp_app.html +0 -0
  14. {janction_render-0.4.97 → janction_render-0.4.99}/janction_render/mcp_common.py +0 -0
  15. {janction_render-0.4.97 → janction_render-0.4.99}/janction_render/prompts.py +0 -0
  16. {janction_render-0.4.97 → janction_render-0.4.99}/janction_render/tooldocs.py +0 -0
  17. {janction_render-0.4.97 → janction_render-0.4.99}/janction_render.egg-info/SOURCES.txt +0 -0
  18. {janction_render-0.4.97 → janction_render-0.4.99}/janction_render.egg-info/dependency_links.txt +0 -0
  19. {janction_render-0.4.97 → janction_render-0.4.99}/janction_render.egg-info/entry_points.txt +0 -0
  20. {janction_render-0.4.97 → janction_render-0.4.99}/janction_render.egg-info/requires.txt +0 -0
  21. {janction_render-0.4.97 → janction_render-0.4.99}/janction_render.egg-info/top_level.txt +0 -0
  22. {janction_render-0.4.97 → janction_render-0.4.99}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: janction-render
3
- Version: 0.4.97
3
+ Version: 0.4.99
4
4
  Summary: Cloud GPU rendering for Blender scenes and 3D models (GLB, FBX, USD, OBJ, STL), built for AI agents: MCP server (remote + stdio), HTTP API and CLI. Product shots, 360-degree turntables, animations and stills; preview in seconds with a critic; no local Blender or GPU. A 100 JPY welcome credit for a new key (one per network every 30 days) and 400 JPY more for saving a card that is not charged, then 0.1 JPY per GPU-second.
5
5
  Author-email: "JasmyLab Inc." <contact@jasmylab.com>
6
6
  License-Expression: MIT
@@ -1,3 +1,3 @@
1
1
  """JANCTION Render — ターミナルの AI エージェントから GPU で 3DCG を描く。"""
2
2
 
3
- __version__ = "0.4.97"
3
+ __version__ = "0.4.99"
@@ -66,6 +66,11 @@ def _frames(s: str) -> tuple[int, int]:
66
66
  raise argparse.ArgumentTypeError("frames must look like 1-240 or 12")
67
67
 
68
68
 
69
+ def _say_err(msg: str) -> None:
70
+ """受け取りの進み具合(10/14: 5 GB の受け取りが 4 分黙っていた)。標準出力は結果の JSON だけにしておく。"""
71
+ print(msg, file=sys.stderr)
72
+
73
+
69
74
  def _progress(j: dict[str, Any]) -> None:
70
75
  p = j["progress"]
71
76
  print(f" {j['status']}: {p['frames_done']}/{p['frames_total']} frames"
@@ -119,7 +124,7 @@ def cmd_preview(c: Client, args: argparse.Namespace) -> int:
119
124
  print(_j({k: j.get(k) for k in ("job_id", "status", "error", "log_tail", "warnings")}))
120
125
  return 1
121
126
  out = Path(args.out) if args.out else Path("render_out") / j["job_id"]
122
- paths = c.download(j["job_id"], out)
127
+ paths = c.download(j["job_id"], out, on_progress=_say_err)
123
128
  print(_j({"job_id": j["job_id"], "scene_id": sid, "frames": frames, "files": [str(p) for p in paths],
124
129
  "warnings": j.get("warnings") or [], "gpu_seconds": j["gpu_seconds"], "device": j["device"],
125
130
  "expires_at": j["expires_at"]}))
@@ -146,7 +151,7 @@ def cmd_render(c: Client, args: argparse.Namespace) -> int:
146
151
  print(_j({k: j.get(k) for k in ("job_id", "status", "error", "log_tail")}))
147
152
  return 1
148
153
  out = Path(args.out) if args.out else Path("render_out") / j["job_id"]
149
- paths = c.download(j["job_id"], out)
154
+ paths = c.download(j["job_id"], out, on_progress=_say_err)
150
155
  print(_j({"job_id": j["job_id"], "scene_id": sid, "files": [str(p) for p in paths],
151
156
  "warnings": j.get("warnings") or [], "gpu_seconds": j["gpu_seconds"], "device": j["device"],
152
157
  "expires_at": j["expires_at"]}))
@@ -161,7 +166,7 @@ def cmd_status(c: Client, args: argparse.Namespace) -> int:
161
166
  def cmd_download(c: Client, args: argparse.Namespace) -> int:
162
167
  j = c.wait(args.job, timeout=args.wait, on_progress=_progress) if args.wait else c.job(args.job)
163
168
  out = Path(args.out) if args.out else Path("render_out") / args.job
164
- paths = c.download(args.job, out)
169
+ paths = c.download(args.job, out, on_progress=_say_err)
165
170
  print(_j({"job_id": args.job, "status": j["status"], "files": [str(p) for p in paths]}))
166
171
  return 0 if j["status"] == "done" else 1
167
172
 
@@ -383,7 +388,7 @@ def _outcome(c: Client, args: argparse.Namespace, name: str, options: dict[str,
383
388
  print(_j({k: j.get(k) for k in ("job_id", "status", "error", "log_tail")}))
384
389
  return 1
385
390
  out = Path(args.out) if args.out else Path("render_out") / j["job_id"]
386
- paths = c.download(j["job_id"], out, only=only)
391
+ paths = c.download(j["job_id"], out, only=only, on_progress=_say_err)
387
392
  print(_j({"job_id": j["job_id"], "scene_id": sid, "files": [str(p) for p in paths], "gpu_seconds": j["gpu_seconds"],
388
393
  "expires_at": j["expires_at"]}))
389
394
  return 0
@@ -408,7 +413,7 @@ def cmd_try(c: Client, args: argparse.Namespace) -> int:
408
413
  print(_j({k: j.get(k) for k in ("job_id", "status", "error", "log_tail")}))
409
414
  return 1
410
415
  out = Path(args.out) if args.out else Path("render_out") / j["job_id"]
411
- paths = c.download(j["job_id"], out)
416
+ paths = c.download(j["job_id"], out, on_progress=_say_err)
412
417
  print(f"done: {j['gpu_seconds']} GPU seconds on {j.get('device') or 'GPU'} -> " + ", ".join(str(p) for p in paths))
413
418
  try:
414
419
  me = c.me()
@@ -606,8 +611,15 @@ def main(argv: list[str] | None = None) -> int:
606
611
  pay = exc.payment()
607
612
  if pay:
608
613
  print(f"payment required: {exc.detail}", file=sys.stderr)
609
- print(f"open this page in a browser and pay, then run the command again: {pay['checkout_url']}",
610
- file=sys.stderr)
614
+ order = (pay.get("resume") or {}).get("order_id")
615
+ if order:
616
+ # 10/14: 払うと預かった仕事が自動で始まる。送り直すと同じ仕事が返る(2 本は描かない)
617
+ print(f"open this page in a browser and pay: {pay['checkout_url']}", file=sys.stderr)
618
+ print(f"the render then starts by itself (nothing to run again); follow it with: janction-render status {order}",
619
+ file=sys.stderr)
620
+ else:
621
+ print(f"open this page in a browser and pay, then run the command again: {pay['checkout_url']}",
622
+ file=sys.stderr)
611
623
  print(_j(pay))
612
624
  return 3
613
625
  print(f"error: {exc}", file=sys.stderr)
@@ -25,6 +25,9 @@ CACHE = Path.home() / ".janction-render.json"
25
25
  SCENE_SUFFIXES = (".blend", ".py", ".fbx", ".glb", ".gltf", ".obj", ".stl", ".ply", ".usd", ".usda", ".usdc", ".usdz", ".abc")
26
26
  ONLY_CHOICES = ("all", "mp4", "frames", "sheet", "output", "video")
27
27
  ARCHIVE_MIN_FILES = 8 # これ以上の数のファイルは zip で 1 回で取る(1 枚ずつだと入口の回数制限 429 に当たる、10/9)
28
+ # zip は約 1 GiB ずつの区切りで取る(10/14 受け取り側の 5 GB 試験: 1 本の zip は途中から取り直せず、切れると全部を 1 枚ずつ取り直していた)
29
+ ARCHIVE_PART_BYTES = 1 << 30
30
+ DOWNLOAD_TRIES = 3
28
31
  # 公開の受付(render.janction.jp)の前に立つ Cloudflare は、1 回の送信を 100 MB までしか通さない(無料プラン)。超えると受付に
29
32
  # 届かず HTML の 413 になるので、送る前に断ってリンク(scene_url / asset_urls)で渡すよう言う。素材は 100 MB ずつに分けて送る
30
33
  # (10/12)。手元や自前の受付には掛けない。JANCTION_RENDER_MAX_UPLOAD_MB で変えられる(0 で見ない)
@@ -37,6 +40,15 @@ ASSET_LINK_MAX_BYTES = 128 * 1024 * 1024 # 受付の素材 1 つの上限(JR
37
40
  _FRAME_NAME_RE = re.compile(r"^(part-\d+_)?frame_")
38
41
 
39
42
 
43
+ def _have(dest: Path, a: dict[str, Any]) -> bool:
44
+ """dest にもう同じ大きさのファイルがあるか(大きさの分からない古い受付では、無いものとして取り直す)。"""
45
+ n = a.get("size")
46
+ try:
47
+ return n is not None and dest.is_file() and dest.stat().st_size == int(n)
48
+ except (OSError, ValueError):
49
+ return False
50
+
51
+
40
52
  def select_artifacts(artifacts: list[dict[str, Any]], only: Optional[str] = None) -> list[dict[str, Any]]:
41
53
  """成果物の絞り込み: all / mp4(output.mp4 だけ)/ frames(PNG のコマだけ)/ sheet(4 コマ並べだけ)/ output(mp4 か sheet)。"""
42
54
  o = (only or "all").strip().lower()
@@ -273,7 +285,8 @@ class ClientError(RuntimeError):
273
285
  out[k] = self.extra[k]
274
286
  # ようこそクレジット(docs/46)の受付なら付いてくる。card_offer はカード登録(0 円)で無料の GPU 時間が足される鍵の案内、
275
287
  # topup_options はチャージの選択肢(docs/62 P0-1: REST の 402 と同じものを AI に渡す)
276
- for k in ("welcome", "first_topup_bonus", "card_offer", "topup_options", "topup_credit_yen"):
288
+ # resume: 402 で預かった注文(o_…)。払うとその仕事が自動で始まり、render_status(o_…) で追える(10/14)
289
+ for k in ("welcome", "first_topup_bonus", "card_offer", "topup_options", "topup_credit_yen", "resume"):
277
290
  if self.extra.get(k):
278
291
  out[k] = self.extra[k]
279
292
  if self.extra.get("accepts"):
@@ -840,8 +853,11 @@ class Client:
840
853
  return j
841
854
  time.sleep(interval)
842
855
 
843
- def download(self, job_id: str, out_dir: str | Path, only: Optional[list[str] | str] = None) -> list[Path]:
844
- """成果物を out_dir に落とす。only は名前の一覧か、'mp4' / 'frames' / 'sheet' / 'output' / 'all'。"""
856
+ def download(self, job_id: str, out_dir: str | Path, only: Optional[list[str] | str] = None,
857
+ on_progress: Optional[Callable[[str], None]] = None) -> list[Path]:
858
+ """成果物を out_dir に落とす。only は名前の一覧か、'mp4' / 'frames' / 'sheet' / 'output' / 'all'。
859
+ on_progress は進み具合の 1 行(CLI は標準エラーに出す。10/14: 5 GB の受け取りが 4 分黙っていた)。"""
860
+ self._say = on_progress or (lambda _msg: None)
845
861
  j = self.job(job_id)
846
862
  out = Path(out_dir)
847
863
  out.mkdir(parents=True, exist_ok=True)
@@ -854,40 +870,114 @@ class Client:
854
870
  got = self._download_archive(job_id, out, wanted)
855
871
  if got is not None:
856
872
  return got
857
- for a in wanted:
873
+ self._say("the zip did not come through; fetching the files one by one")
874
+ last = time.monotonic()
875
+ for i, a in enumerate(wanted, 1):
876
+ dest = out / a["name"]
877
+ if _have(dest, a): # 前の回(切れた zip・打ち直し)で届いている
878
+ paths.append(dest)
879
+ continue
880
+ if len(wanted) > 1 and time.monotonic() - last > 10:
881
+ last = time.monotonic()
882
+ self._say(f"downloading {i}/{len(wanted)} files")
858
883
  # 受付が返す URL は公開 URL の土台で組まれている。自分がつないだ先(SSH トンネルなど)でも
859
884
  # 落とせるように、パスだけを取り出して自分の server に付け直す
860
885
  parts = urlsplit(a["url"])
861
886
  path = parts.path + (f"?{parts.query}" if parts.query else "")
862
887
  url = f"{self.server}{path}" if parts.path.startswith("/v1/") else a["url"]
863
- with self.s.get(url, headers=self._headers(), stream=True, timeout=600) as r:
864
- self._raise(r)
865
- dest = out / a["name"]
866
- with dest.open("wb") as fh:
867
- for chunk in r.iter_content(1024 * 1024):
868
- fh.write(chunk)
888
+ self._download_file(url, dest)
869
889
  paths.append(dest)
870
890
  return paths
871
891
 
892
+ def _download_file(self, url: str, dest: Path) -> None:
893
+ """1 つのファイルを dest.part に受け取り、切れたら Range で続きから(10/14。受付の FileResponse は Range に答える)。"""
894
+ part = dest.with_name(dest.name + ".part")
895
+ for attempt in range(DOWNLOAD_TRIES):
896
+ have = part.stat().st_size if part.exists() else 0
897
+ headers = {**self._headers(), **({"Range": f"bytes={have}-"} if have else {})}
898
+ try:
899
+ with self.s.get(url, headers=headers, stream=True, timeout=600) as r:
900
+ if r.status_code == 416: # もう全部ある
901
+ break
902
+ self._raise(r)
903
+ with part.open("ab" if r.status_code == 206 else "wb") as fh:
904
+ for chunk in r.iter_content(1024 * 1024):
905
+ fh.write(chunk)
906
+ break
907
+ except requests.RequestException:
908
+ if attempt == DOWNLOAD_TRIES - 1:
909
+ raise
910
+ time.sleep(2.0 * (attempt + 1))
911
+ part.replace(dest)
912
+
872
913
  def _download_archive(self, job_id: str, out: Path, wanted: list[dict[str, Any]]) -> Optional[list[Path]]:
873
- """まとめて zip(GET /v1/jobs/{id}/archive)で取り、欲しい名前だけを out に出す。受付が古くて口が無い・途中で切れた・
874
- 中身が足りないときは None(呼び手が 1 枚ずつに戻る)。"""
914
+ """まとめて zip(GET /v1/jobs/{id}/archive)で取り、欲しい名前だけを out に出す。大きいときは名前の順に約 1 GiB ずつの
915
+ 区切り(first・last)で取り、届いた区切りは残して、切れた区切りだけを取り直す(10/14)。受付が古くて口が無い・取り直しても
916
+ 届かない・中身が足りないときは None(呼び手が 1 枚ずつに戻り、届いているファイルは飛ばす)。"""
917
+ ordered = sorted(wanted, key=lambda a: a["name"])
918
+ groups: list[list[dict[str, Any]]] = []
919
+ size = 0
920
+ for a in ordered:
921
+ n = int(a.get("size") or 0)
922
+ if not groups or (size + n > ARCHIVE_PART_BYTES and groups[-1]):
923
+ groups.append([])
924
+ size = 0
925
+ groups[-1].append(a)
926
+ size += n
927
+ total_mb = sum(int(a.get("size") or 0) for a in wanted) / 1048576
928
+ if len(groups) == 1:
929
+ self._say(f"downloading {len(wanted)} files in one zip ({total_mb:.0f} MB)")
930
+ return self._fetch_archive(job_id, out, wanted)
931
+ self._say(f"downloading {len(wanted)} files ({total_mb / 1024:.1f} GB) in {len(groups)} zips of about 1 GB")
932
+ paths: list[Path] = []
933
+ for gi, g in enumerate(groups, 1):
934
+ if all(_have(out / a["name"], a) for a in g):
935
+ paths += [out / a["name"] for a in g]
936
+ continue
937
+ got: Any = None
938
+ for attempt in range(DOWNLOAD_TRIES):
939
+ self._say(f"zip {gi}/{len(groups)} ({len(g)} files, {sum(int(a.get('size') or 0) for a in g) / 1048576:.0f} MB)"
940
+ + (f", again (try {attempt + 1})" if attempt else ""))
941
+ got = self._fetch_archive(job_id, out, g, (g[0]["name"], g[-1]["name"]))
942
+ if got is not None:
943
+ break
944
+ time.sleep(2.0 * (attempt + 1))
945
+ if got == "unsliced": # 区切りを知らない受付: 前のとおり 1 本で
946
+ return self._fetch_archive(job_id, out, wanted)
947
+ if got is None:
948
+ return None
949
+ paths += got
950
+ return paths
951
+
952
+ def _fetch_archive(self, job_id: str, out: Path, wanted: list[dict[str, Any]],
953
+ span: Optional[tuple[str, str]] = None) -> Any:
954
+ """zip を 1 本取って欲しい名前を out に出す。span(最初と最後の名前)を渡すと、その一続きだけを頼む。
955
+ 区切りを頼んだのに受付が X-Archive-Range を返さなければ "unsliced"。"""
875
956
  import shutil
876
957
  import tempfile
877
958
  import zipfile
959
+ from urllib.parse import urlencode
878
960
 
879
961
  names = {a["name"] for a in wanted}
880
962
  exts = sorted({Path(n).suffix.lstrip(".").lower() for n in names if Path(n).suffix})
881
- url = f"{self.server}/v1/jobs/{job_id}/archive" + (f"?only={','.join(exts)}" if exts else "")
963
+ q = {**({"only": ",".join(exts)} if exts else {}), **({"first": span[0], "last": span[1]} if span else {})}
964
+ url = f"{self.server}/v1/jobs/{job_id}/archive" + (f"?{urlencode(q)}" if q else "")
882
965
  tmp: Optional[str] = None
883
966
  try:
884
967
  with self.s.get(url, headers=self._headers(), stream=True, timeout=1800) as r:
885
968
  if r.status_code != 200:
886
969
  return None
970
+ if span and not r.headers.get("X-Archive-Range"):
971
+ return "unsliced"
887
972
  fd, tmp = tempfile.mkstemp(suffix=".zip", dir=out)
973
+ got_b, last = 0, time.monotonic()
888
974
  with os.fdopen(fd, "wb") as fh:
889
975
  for chunk in r.iter_content(1024 * 1024):
890
976
  fh.write(chunk)
977
+ got_b += len(chunk)
978
+ if time.monotonic() - last > 15:
979
+ last = time.monotonic()
980
+ getattr(self, "_say", lambda _m: None)(f" {got_b // 1048576} MB")
891
981
  paths: list[Path] = []
892
982
  with zipfile.ZipFile(tmp) as zf:
893
983
  if not names <= set(zf.namelist()):
@@ -236,17 +236,20 @@ def _payment_block(exc: ClientError) -> Optional[dict[str, Any]]:
236
236
  p = exc.payment()
237
237
  if p is None:
238
238
  return None
239
+ # 払ったあとは預かり(resume.order_id)から自動で始まる受付(0.4.98〜)なら、送り直させない(10/14)
240
+ after = ("after they paid (or registered the card) the render starts by itself: tell them so and follow it with "
241
+ "render_status(job_id=resume.order_id); do not upload or submit it again (a resend returns the same job)"
242
+ if p.get("resume") else "after they paid, call billing() to confirm the balance, then submit the same job again")
239
243
  if p.get("card_offer"):
240
244
  # 0 円のカード登録で無料の GPU 時間が足される鍵には、その道を先に(docs/62 P0-1)。チャージは代わりの道
241
245
  p["next"] = ("first tell the user card_offer.tell_user in their own language (registering a card costs nothing and adds free "
242
- "GPU time; they open card_offer.link in a browser), and offer the top-up at checkout_url as the other way; after "
243
- "either is done, call billing() to confirm, then submit the same job again")
246
+ "GPU time; they open card_offer.link in a browser), and offer the top-up at checkout_url as the other way; " + after)
244
247
  return p
245
248
  p["next"] = (("relay tell_user to the user in their own language (what this render costs and what the top-up buys) "
246
249
  "with checkout_url " if p.get("tell_user") else
247
250
  "tell the user the job goes past the free GPU time left and what the top-up costs; show checkout_url ")
248
- + "and ask them to pay in a browser (Stripe; the rest stays as credit); after they paid, call billing() to "
249
- "confirm the balance, then submit the same job again (or render a smaller job that fits the free time)")
251
+ + "and ask them to pay in a browser (Stripe; the rest stays as credit); " + after
252
+ + " (or render a smaller job that fits the free time)")
250
253
  return p
251
254
 
252
255
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: janction-render
3
- Version: 0.4.97
3
+ Version: 0.4.99
4
4
  Summary: Cloud GPU rendering for Blender scenes and 3D models (GLB, FBX, USD, OBJ, STL), built for AI agents: MCP server (remote + stdio), HTTP API and CLI. Product shots, 360-degree turntables, animations and stills; preview in seconds with a critic; no local Blender or GPU. A 100 JPY welcome credit for a new key (one per network every 30 days) and 400 JPY more for saving a card that is not charged, then 0.1 JPY per GPU-second.
5
5
  Author-email: "JasmyLab Inc." <contact@jasmylab.com>
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "janction-render"
7
- version = "0.4.97"
7
+ version = "0.4.99"
8
8
  description = "Cloud GPU rendering for Blender scenes and 3D models (GLB, FBX, USD, OBJ, STL), built for AI agents: MCP server (remote + stdio), HTTP API and CLI. Product shots, 360-degree turntables, animations and stills; preview in seconds with a critic; no local Blender or GPU. A 100 JPY welcome credit for a new key (one per network every 30 days) and 400 JPY more for saving a card that is not charged, then 0.1 JPY per GPU-second."
9
9
  readme = "README.md"
10
10
  license = "MIT"