Xunzhuo commited on
Commit
3aeb1c2
·
verified ·
1 Parent(s): f64eca9

d3-nano v3.1.0

Browse files
Files changed (9) hide show
  1. MODEL_MANIFEST.json +23 -21
  2. README.md +43 -6
  3. assets/example-video.mp4 +0 -0
  4. d3_engine.py +6 -5
  5. d3_runtime.py +711 -15
  6. d3_server.py +53 -11
  7. modeling_d3.py +12 -6
  8. pipeline_d3.py +21 -7
  9. requirements.txt +3 -2
MODEL_MANIFEST.json CHANGED
@@ -554,14 +554,14 @@
554
  },
555
  "runtime": {
556
  "files_sha256": {
557
- "d3_runtime.py": "139ece85007c5771b5e374da9658dc8832eb23aabfeaabe29f18e56d59d7f82f",
558
  "d3_fast.py": "68a1fb9a2c3bde9ac2763bf504decae592ca12722698fbde1b39a31262955535",
559
  "d3_kernels.py": "8f7534ca1e1846ebf7c4c73058002f3be4dbf0d5d426f0815150ae0cbe7677bd",
560
- "modeling_d3.py": "c87cb2c608ce078c8cb2ea8070624ab5a1c4eca02790492bd6e33c2fdc279acb",
561
- "pipeline_d3.py": "c0975a91316bcda9c09c5f14bf634ceec94e6980ed7e76a29b718fb451fe4091",
562
- "d3_server.py": "11211faa83fee50b957903f0d596c153b0921cb5414f18edee31d8dac76db9fc",
563
- "d3_engine.py": "77628954a52cdd03048125899504f1ef55770a768ff8d2c34971a513bfb45a62",
564
- "requirements.txt": "3b0f92cb48584015ec0c9adf1559445806bd2ba9f6a41a92be6b2dd07fa48a21",
565
  "d3_format.py": "187a0f359f6f89a844e723377769556f78133b118851e702e4b0fe5123ccb0f1"
566
  }
567
  },
@@ -578,31 +578,32 @@
578
  }
579
  ]
580
  },
581
- "built_utc": "2026-10-11T04:21:49+00:00",
582
  "files_sha256": {
583
  "LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
584
  "NOTICE": "300b6105c41a1843653e40be644c3d54e06288b25199373e04143530cc3db366",
585
- "README.md": "cb20a6644d2e779be1fbb5c22a8e9b5ac2d7c6954554d03348f1753613d1b4ac",
586
  "assets/banner.png": "cfd3c0d984881e917b33d22d011a086f5ba7a7d16e95769e5bc03c5e508d790a",
587
  "assets/example-receipt.png": "b9dbb8b103bacd45f1de824d844a3c07b81d57cb1a7466712ef060ad7dcb3926",
 
588
  "assets/index-areas.png": "a290ace23e12c01ae96d0959aa8e715cf01d22e9ff09e3314db74b0f1a4f8449",
589
  "assets/index-pareto.png": "8df8991d5bd83d82ce20db40318c2fc190e4768c9163e01ce31440194fcde910",
590
  "chat_template.jinja": "273d8e0e683b885071fb17e08d71e5f2a5ddfb5309756181681de4f5a1822d80",
591
  "config.json": "9caa6ab8956c859c3852328dda6322893a18b30826d3916db91d938d6c74ac4f",
592
- "d3_engine.py": "77628954a52cdd03048125899504f1ef55770a768ff8d2c34971a513bfb45a62",
593
  "d3_fast.py": "68a1fb9a2c3bde9ac2763bf504decae592ca12722698fbde1b39a31262955535",
594
  "d3_format.py": "187a0f359f6f89a844e723377769556f78133b118851e702e4b0fe5123ccb0f1",
595
  "d3_kernels.py": "8f7534ca1e1846ebf7c4c73058002f3be4dbf0d5d426f0815150ae0cbe7677bd",
596
- "d3_runtime.py": "139ece85007c5771b5e374da9658dc8832eb23aabfeaabe29f18e56d59d7f82f",
597
- "d3_server.py": "11211faa83fee50b957903f0d596c153b0921cb5414f18edee31d8dac76db9fc",
598
  "decision_config.json": "9175a0f42d5886def9c8429fdf8b0be58a3c97d106b94520e2ac38fe326302bf",
599
  "merges.txt": "a9d356d7bdf1ef4949e3e748e95b8e10ad9d4e2e838eddc38a0a7b6b94d1db8d",
600
  "model.safetensors": "7269b3260f1106f3443bfdb028a76143be2e679db3f29a31f4dfd1d595f03155",
601
- "modeling_d3.py": "c87cb2c608ce078c8cb2ea8070624ab5a1c4eca02790492bd6e33c2fdc279acb",
602
- "pipeline_d3.py": "c0975a91316bcda9c09c5f14bf634ceec94e6980ed7e76a29b718fb451fe4091",
603
  "preprocessor_config.json": "27225450ac9c6529872ee1924fcb0962ff5634834f817040f444118116f4e516",
604
  "readout.safetensors": "662e8a26d8016b2d479f1ca66bf64944603316c2b5e074ab7ac3304b8ae045e9",
605
- "requirements.txt": "3b0f92cb48584015ec0c9adf1559445806bd2ba9f6a41a92be6b2dd07fa48a21",
606
  "tokenizer.json": "5f9e4d4901a92b997e463c1f46055088b6cca5ca61a6522d1b9f64c4bb81cb42",
607
  "tokenizer_config.json": "49e2b6e395f959f077f1e992b338919c0d4a9732fc6e613995e06557f843500c",
608
  "video_preprocessor_config.json": "7768af27c1fafa9cc9011c1dc20067e03f8915e03b63504550e11d5066986d13",
@@ -611,27 +612,28 @@
611
  "files_bytes": {
612
  "LICENSE": 11357,
613
  "NOTICE": 104,
614
- "README.md": 6258,
615
  "assets/banner.png": 2233242,
616
  "assets/example-receipt.png": 65918,
 
617
  "assets/index-areas.png": 124987,
618
  "assets/index-pareto.png": 184438,
619
  "chat_template.jinja": 7755,
620
  "config.json": 2885,
621
- "d3_engine.py": 4227,
622
  "d3_fast.py": 22331,
623
  "d3_format.py": 4881,
624
  "d3_kernels.py": 16129,
625
- "d3_runtime.py": 59432,
626
- "d3_server.py": 8333,
627
  "decision_config.json": 5502,
628
  "merges.txt": 3353259,
629
  "model.safetensors": 4426555160,
630
- "modeling_d3.py": 11673,
631
- "pipeline_d3.py": 2601,
632
  "preprocessor_config.json": 390,
633
  "readout.safetensors": 2089040,
634
- "requirements.txt": 707,
635
  "tokenizer.json": 12807982,
636
  "tokenizer_config.json": 16709,
637
  "video_preprocessor_config.json": 385,
 
554
  },
555
  "runtime": {
556
  "files_sha256": {
557
+ "d3_runtime.py": "6c049aa7d3da8da3738602a2812572879d0a47b03c28c3e33f36d7300257de89",
558
  "d3_fast.py": "68a1fb9a2c3bde9ac2763bf504decae592ca12722698fbde1b39a31262955535",
559
  "d3_kernels.py": "8f7534ca1e1846ebf7c4c73058002f3be4dbf0d5d426f0815150ae0cbe7677bd",
560
+ "modeling_d3.py": "f8f8db23ff58f1a990ffa24977d744a50e1b4446d02a64c22070967705b6dbef",
561
+ "pipeline_d3.py": "4f9297c7c74e596e9b67df91a1e6823cfada48bf781336f141974ff6a2f1038e",
562
+ "d3_server.py": "f9b8f2481461a074f469b4f58da3b0d7004824096528366d5b1f23187dd6e692",
563
+ "d3_engine.py": "086238942762df45047a32cd9a8e2bedd9b63167eb40be260c776de567979f53",
564
+ "requirements.txt": "57a1938448bc240bdf4fee5d1b9cbc8450109fa1f6cee11e94dc04f274be8a32",
565
  "d3_format.py": "187a0f359f6f89a844e723377769556f78133b118851e702e4b0fe5123ccb0f1"
566
  }
567
  },
 
578
  }
579
  ]
580
  },
581
+ "built_utc": "2026-10-11T04:33:06+00:00",
582
  "files_sha256": {
583
  "LICENSE": "c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4",
584
  "NOTICE": "300b6105c41a1843653e40be644c3d54e06288b25199373e04143530cc3db366",
585
+ "README.md": "65356c023e4a7f6881ba36c761e997f834543caded50d9fc45737176ba49d510",
586
  "assets/banner.png": "cfd3c0d984881e917b33d22d011a086f5ba7a7d16e95769e5bc03c5e508d790a",
587
  "assets/example-receipt.png": "b9dbb8b103bacd45f1de824d844a3c07b81d57cb1a7466712ef060ad7dcb3926",
588
+ "assets/example-video.mp4": "f4968ef89d07e97f20df4fd8047510a7a8e9df5c2d07c35e363a97927f164ab9",
589
  "assets/index-areas.png": "a290ace23e12c01ae96d0959aa8e715cf01d22e9ff09e3314db74b0f1a4f8449",
590
  "assets/index-pareto.png": "8df8991d5bd83d82ce20db40318c2fc190e4768c9163e01ce31440194fcde910",
591
  "chat_template.jinja": "273d8e0e683b885071fb17e08d71e5f2a5ddfb5309756181681de4f5a1822d80",
592
  "config.json": "9caa6ab8956c859c3852328dda6322893a18b30826d3916db91d938d6c74ac4f",
593
+ "d3_engine.py": "086238942762df45047a32cd9a8e2bedd9b63167eb40be260c776de567979f53",
594
  "d3_fast.py": "68a1fb9a2c3bde9ac2763bf504decae592ca12722698fbde1b39a31262955535",
595
  "d3_format.py": "187a0f359f6f89a844e723377769556f78133b118851e702e4b0fe5123ccb0f1",
596
  "d3_kernels.py": "8f7534ca1e1846ebf7c4c73058002f3be4dbf0d5d426f0815150ae0cbe7677bd",
597
+ "d3_runtime.py": "6c049aa7d3da8da3738602a2812572879d0a47b03c28c3e33f36d7300257de89",
598
+ "d3_server.py": "f9b8f2481461a074f469b4f58da3b0d7004824096528366d5b1f23187dd6e692",
599
  "decision_config.json": "9175a0f42d5886def9c8429fdf8b0be58a3c97d106b94520e2ac38fe326302bf",
600
  "merges.txt": "a9d356d7bdf1ef4949e3e748e95b8e10ad9d4e2e838eddc38a0a7b6b94d1db8d",
601
  "model.safetensors": "7269b3260f1106f3443bfdb028a76143be2e679db3f29a31f4dfd1d595f03155",
602
+ "modeling_d3.py": "f8f8db23ff58f1a990ffa24977d744a50e1b4446d02a64c22070967705b6dbef",
603
+ "pipeline_d3.py": "4f9297c7c74e596e9b67df91a1e6823cfada48bf781336f141974ff6a2f1038e",
604
  "preprocessor_config.json": "27225450ac9c6529872ee1924fcb0962ff5634834f817040f444118116f4e516",
605
  "readout.safetensors": "662e8a26d8016b2d479f1ca66bf64944603316c2b5e074ab7ac3304b8ae045e9",
606
+ "requirements.txt": "57a1938448bc240bdf4fee5d1b9cbc8450109fa1f6cee11e94dc04f274be8a32",
607
  "tokenizer.json": "5f9e4d4901a92b997e463c1f46055088b6cca5ca61a6522d1b9f64c4bb81cb42",
608
  "tokenizer_config.json": "49e2b6e395f959f077f1e992b338919c0d4a9732fc6e613995e06557f843500c",
609
  "video_preprocessor_config.json": "7768af27c1fafa9cc9011c1dc20067e03f8915e03b63504550e11d5066986d13",
 
612
  "files_bytes": {
613
  "LICENSE": 11357,
614
  "NOTICE": 104,
615
+ "README.md": 7846,
616
  "assets/banner.png": 2233242,
617
  "assets/example-receipt.png": 65918,
618
+ "assets/example-video.mp4": 26300,
619
  "assets/index-areas.png": 124987,
620
  "assets/index-pareto.png": 184438,
621
  "chat_template.jinja": 7755,
622
  "config.json": 2885,
623
+ "d3_engine.py": 4413,
624
  "d3_fast.py": 22331,
625
  "d3_format.py": 4881,
626
  "d3_kernels.py": 16129,
627
+ "d3_runtime.py": 88800,
628
+ "d3_server.py": 10100,
629
  "decision_config.json": 5502,
630
  "merges.txt": 3353259,
631
  "model.safetensors": 4426555160,
632
+ "modeling_d3.py": 12079,
633
+ "pipeline_d3.py": 2936,
634
  "preprocessor_config.json": 390,
635
  "readout.safetensors": 2089040,
636
+ "requirements.txt": 679,
637
  "tokenizer.json": 12807982,
638
  "tokenizer_config.json": 16709,
639
  "video_preprocessor_config.json": 385,
README.md CHANGED
@@ -10,6 +10,7 @@ tags:
10
  - system-one
11
  - multimodal
12
  - vision
 
13
  - safetensors
14
  ---
15
 
@@ -17,12 +18,12 @@ tags:
17
 
18
  # d3-nano
19
 
20
- **d3-nano** is the 2B multimodal foundation decision model of Decision 3.0, the decision models of [vLLM Semantic Router](https://github.com/vllm-project/semantic-router). Give it an input (text or JSON, optionally with images) and the questions you need answered: pick one of several options, say yes or no, or rate on a scale. It answers them all in one call and returns a probability for every answer, without generating text.
21
 
22
  | | |
23
  | --- | --- |
24
  | **Parameters** | 2.21B, including the 0.33B vision encoder |
25
- | **Inputs** | Text or JSON, plus images (several per request) |
26
  | **Decision types** | Choice · Yes / No · Score |
27
  | **License** | Apache-2.0 |
28
 
@@ -31,13 +32,14 @@ tags:
31
  - **Jev Decision Index 0.3, public suite: 42.22**, measured with the official 0.3 kit on the released weights: all 140,178 public requests answered, none unsupported.
32
  - **+12.2 on the public suite over Decision 2.0** (its 2B model: 30.05 on the board), ahead in all five areas.
33
  - **Reads images:** multiple images per request (PNG, JPEG or WebP), given as paths, URLs, PIL images or base64 data URLs; every question of the request sees all of them.
34
- - **Speed:** a median of 12.9 ms for a text request and 73.8 ms for a request with an image, on one AMD Instinct MI325X GPU, one request at a time.
 
35
  - **Many questions, one call:** Choice, Yes / No and Score questions about the same input are answered together, each from its own forward pass over the input, with a probability for every option.
36
 
37
  ## Quickstart
38
 
39
  ```bash
40
- pip install "transformers==5.17.0" torch torchvision pillow safetensors accelerate
41
  pip install flash-linear-attention # optional: fast GPU kernels for the linear-attention layers
42
  ```
43
 
@@ -111,11 +113,34 @@ result = model.system_one(
111
  )
112
  print(json.dumps(result["answers"], indent=2))
113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  # Or as a pipeline:
115
- # transformers.pipeline("decision", model="vllm-sr/d3-nano", trust_remote_code=True)(state=..., questions=..., images=...)
116
  ```
117
 
118
- Images go before the text of the request, each read at up to 1.6 megapixels; every question of the request sees all of them.
119
 
120
  ## Evaluation
121
 
@@ -157,6 +182,18 @@ d3-nano on the public vision benchmarks († approximate rebuild):
157
  | R-Bench-M | 14.8 |
158
  | MMMU-Pro vision | 18.6 |
159
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  <sub>d3-nano: internal evaluation. Others: live board data, text 2026-10-10, vision 2026-10-09.</sub>
161
 
162
  ## License
 
10
  - system-one
11
  - multimodal
12
  - vision
13
+ - video
14
  - safetensors
15
  ---
16
 
 
18
 
19
  # d3-nano
20
 
21
+ **d3-nano** is the 2B multimodal foundation decision model of Decision 3.0, the decision models of [vLLM Semantic Router](https://github.com/vllm-project/semantic-router). Give it an input (text or JSON, optionally with images and videos) and the questions you need answered: pick one of several options, say yes or no, or rate on a scale. It answers them all in one call and returns a probability for every answer, without generating text.
22
 
23
  | | |
24
  | --- | --- |
25
  | **Parameters** | 2.21B, including the 0.33B vision encoder |
26
+ | **Inputs** | Text or JSON, plus images and videos (several per request) |
27
  | **Decision types** | Choice · Yes / No · Score |
28
  | **License** | Apache-2.0 |
29
 
 
32
  - **Jev Decision Index 0.3, public suite: 42.22**, measured with the official 0.3 kit on the released weights: all 140,178 public requests answered, none unsupported.
33
  - **+12.2 on the public suite over Decision 2.0** (its 2B model: 30.05 on the board), ahead in all five areas.
34
  - **Reads images:** multiple images per request (PNG, JPEG or WebP), given as paths, URLs, PIL images or base64 data URLs; every question of the request sees all of them.
35
+ - **Reads videos:** multiple videos per request (MP4, WebM, MOV or MKV), given as paths, URLs, base64 data URLs or frame arrays, read at 2 frames per second; **Perception Test (multiple-choice video QA): 63.5** (internal evaluation).
36
+ - **Speed:** a median of 12.9 ms for a text request, 73.8 ms for a request with an image and 344.9 ms for a request with a 10-second video, on one AMD Instinct MI325X GPU, one request at a time.
37
  - **Many questions, one call:** Choice, Yes / No and Score questions about the same input are answered together, each from its own forward pass over the input, with a probability for every option.
38
 
39
  ## Quickstart
40
 
41
  ```bash
42
+ pip install "transformers==5.17.0" torch torchvision pillow opencv-python-headless safetensors accelerate
43
  pip install flash-linear-attention # optional: fast GPU kernels for the linear-attention layers
44
  ```
45
 
 
113
  )
114
  print(json.dumps(result["answers"], indent=2))
115
 
116
+ # Text and a video (or several)
117
+ clip = hf_hub_download("vllm-sr/d3-nano", "assets/example-video.mp4")
118
+ result = model.system_one(
119
+ state="A short clip from a test camera.",
120
+ videos=[clip], # local paths, http(s) URLs, base64 data URLs or frame arrays
121
+ questions={
122
+ "direction": {
123
+ "type": "choice",
124
+ "instructions": "Which way does the square move?",
125
+ "criteria": {
126
+ "right": "From left to right",
127
+ "left": "From right to left",
128
+ "still": "It does not move"
129
+ }
130
+ },
131
+ "color_change": {
132
+ "type": "noul",
133
+ "instructions": "Does the square change color?"
134
+ }
135
+ },
136
+ )
137
+ print(json.dumps(result["answers"], indent=2))
138
+
139
  # Or as a pipeline:
140
+ # transformers.pipeline("decision", model="vllm-sr/d3-nano", trust_remote_code=True)(state=..., questions=..., images=..., videos=...)
141
  ```
142
 
143
+ Images go before the text of the request, each read at up to 1.6 megapixels; videos follow the images, read at 2 frames per second (at most 32 frames spread over the whole video, each at up to 0.2 megapixels). Every question of the request sees all of them.
144
 
145
  ## Evaluation
146
 
 
182
  | R-Bench-M | 14.8 |
183
  | MMMU-Pro vision | 18.6 |
184
 
185
+ ### Videos: Perception Test
186
+
187
+ | Perception Test (validation) | d3-nano |
188
+ | --- | ---: |
189
+ | **All 19,140 questions** | **63.5** |
190
+ | Memory | 53.8 |
191
+ | Abstraction | 55.7 |
192
+ | Physics | 56.0 |
193
+ | Semantics | 81.0 |
194
+
195
+ Multiple-choice video question answering (three options per question, chance 33.3): top-1 accuracy (%), videos read with the defaults above.
196
+
197
  <sub>d3-nano: internal evaluation. Others: live board data, text 2026-10-10, vision 2026-10-09.</sub>
198
 
199
  ## License
assets/example-video.mp4 ADDED
Binary file (26.3 kB). View file
 
d3_engine.py CHANGED
@@ -11,7 +11,8 @@ average, see d3_runtime.py). A request with a question over the checkpoint's inp
11
  (nothing is truncated).
12
 
13
  Image requests: ``engine(state, questions, images=[...])`` with any number of images (PIL images, paths,
14
- http(s) or data URLs) that every question sees.
 
15
  """
16
 
17
  from __future__ import annotations
@@ -37,8 +38,8 @@ class D3Engine(Engine):
37
  name = "d3"
38
  latency = (
39
  "Device-synchronized in-process request wall time including prompt rendering and tokenization (and, "
40
- "for image requests, image decoding and preprocessing); one request per call, its questions in request "
41
- "order in batches of batch_size; excludes model loading."
42
  )
43
 
44
  def __init__(
@@ -90,8 +91,8 @@ class D3Engine(Engine):
90
  def synchronize(self):
91
  self.decision.synchronize()
92
 
93
- def __call__(self, state, questions, images=None):
94
- prepared = self.decision.prepare(state, questions, images)
95
  over = [
96
  e["message"]
97
  for e in prepared.errors.values()
 
11
  (nothing is truncated).
12
 
13
  Image requests: ``engine(state, questions, images=[...])`` with any number of images (PIL images, paths,
14
+ http(s) or data URLs) that every question sees. Video requests: ``engine(state, questions, videos=[...])``
15
+ (with or without images) with any number of videos (paths, http(s) or data URLs, frame arrays).
16
  """
17
 
18
  from __future__ import annotations
 
38
  name = "d3"
39
  latency = (
40
  "Device-synchronized in-process request wall time including prompt rendering and tokenization (and, "
41
+ "for image and video requests, their decoding and preprocessing); one request per call, its questions in "
42
+ "request order in batches of batch_size; excludes model loading."
43
  )
44
 
45
  def __init__(
 
91
  def synchronize(self):
92
  self.decision.synchronize()
93
 
94
+ def __call__(self, state, questions, images=None, videos=None):
95
+ prepared = self.decision.prepare(state, questions, images, videos)
96
  over = [
97
  e["message"]
98
  for e in prepared.errors.values()
d3_runtime.py CHANGED
@@ -11,6 +11,7 @@ probability for every option. No text is generated and no input is truncated.
11
  # "confidence": ...}}, "usage": {"input_tokens": n, "output_tokens": 0}}
12
 
13
  model.system_one(state="...", questions={...}, images=["photo.png", "label.jpg"])
 
14
 
15
  The checkpoint directory holds ``config.json`` + ``model*.safetensors`` (a transformers
16
  ``Qwen3_5Model``, or a ``Qwen3VLModel`` when ``config.json`` says ``model_type: qwen3_vl``),
@@ -26,6 +27,14 @@ the aspect ratio; each 32 x 32 pixel patch is one input token. The prompt is the
26
  images in front; a request without images takes exactly the text path. Image inputs need a checkpoint
27
  with the vision tower (``visual.*`` weights).
28
 
 
 
 
 
 
 
 
 
29
  Numerics: BF16 backbone with SDPA attention, FP32 readout and softmax (unless the checkpoint says
30
  otherwise). A request's questions run in request order, ``batch_size`` per forward pass, each batch
31
  left-padded to its longest prompt. ``noncausal_full_attention`` (Qwen3.5 backbones only) lets the
@@ -122,6 +131,22 @@ IMAGE_INPUTS = (
122
  "pixel_values",
123
  "image_grid_thw",
124
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
 
127
  class MaxLengthExceeded(ValueError):
@@ -239,6 +264,28 @@ def image_messages(
239
  raise ValueError(f"unknown prompt family {prompt!r}")
240
 
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  def reversed_question(question: Question) -> dict[str, Any] | None:
243
  """The rendered choice question with its options in reverse order (None when there is nothing to permute)."""
244
  if question.kind != "choice" or len(question.keys) < 2:
@@ -332,7 +379,7 @@ def _data_url_payload(value: str, strict: bool) -> bytes:
332
  raise ValueError("invalid base64 image data") from exc
333
 
334
 
335
- def _download(url: str) -> bytes:
336
  import urllib.request
337
 
338
  request = urllib.request.Request(url, headers={"User-Agent": RUNTIME})
@@ -340,7 +387,7 @@ def _download(url: str) -> bytes:
340
  payload = response.read(MAX_DOWNLOAD_BYTES + 1)
341
  if len(payload) > MAX_DOWNLOAD_BYTES:
342
  raise ValueError(
343
- f"the image at {url} is larger than {MAX_DOWNLOAD_BYTES:,} bytes"
344
  )
345
  return payload
346
 
@@ -450,6 +497,278 @@ def linearize_patch_embed(model) -> int:
450
  return count
451
 
452
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  # ---------------------------------------------------------------------------------------------
454
  # Package files
455
  # ---------------------------------------------------------------------------------------------
@@ -684,7 +1003,9 @@ class Prepared:
684
 
685
  A text request holds token ``sequences``; an image request holds the decoded ``images`` (shared by
686
  every question), the rendered prompt ``texts`` and the planned input ``lengths`` (text tokens plus
687
- image tokens), and the processor tokenizes it when it runs.
 
 
688
  """
689
 
690
  keys: list[str]
@@ -694,6 +1015,9 @@ class Prepared:
694
  images: list[Any] = field(default_factory=list)
695
  texts: dict[str, str] = field(default_factory=dict)
696
  lengths: dict[str, int] = field(default_factory=dict)
 
 
 
697
  # Reversed-option prompts of the choice questions (permutation_average only).
698
  reversed_sequences: dict[str, list[int]] = field(default_factory=dict)
699
  reversed_texts: dict[str, str] = field(default_factory=dict)
@@ -832,6 +1156,22 @@ class D3:
832
  self.image_unavailable = (
833
  f"the image processor failed to load ({type(exc).__name__}: {exc})"
834
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
835
  fast, reason = d3_fast, "d3_fast.py is not present"
836
  if fast is None:
837
  try:
@@ -900,6 +1240,36 @@ class D3:
900
  enable_thinking=False,
901
  )
902
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
903
  def load_images(self, images: Sequence[Any], *, strict: bool = False) -> list[Any]:
904
  """Decode a request's images (RGB PIL), any number; raises ValueError for an image it cannot read."""
905
  if not images:
@@ -921,11 +1291,14 @@ class D3:
921
  state: Any,
922
  questions: Mapping[str, Any],
923
  images: Sequence[Any] | None = None,
 
924
  ) -> Prepared:
925
- """Validate and tokenize one request; malformed ``state``, ``questions`` or ``images`` raise ValueError.
 
926
 
927
- ``images`` (a list of any number of images shared by every question) select the image path. Without
928
- images the request takes the text path unchanged.
 
929
  """
930
  if not isinstance(questions, Mapping) or not questions:
931
  raise ValueError(
@@ -936,6 +1309,15 @@ class D3:
936
  _json_value(state)
937
  if images is not None and not isinstance(images, (list, tuple)):
938
  raise ValueError("images must be a list of images")
 
 
 
 
 
 
 
 
 
939
  if images:
940
  return self._prepare_images(state, questions, self.load_images(images))
941
  prepared = Prepared(keys=list(questions))
@@ -976,9 +1358,10 @@ class D3:
976
  ) -> None:
977
  """Render and tokenize the reversed-option prompt of every runnable choice question.
978
 
979
- ``visual``: the image tokens of the request (image path), counted like ``_prepare_images`` counts them.
 
980
  """
981
- n_images = len(prepared.images)
982
  texts = []
983
  for key in prepared.runnable:
984
  flipped = reversed_question(prepared.questions[key])
@@ -987,18 +1370,22 @@ class D3:
987
  (
988
  key,
989
  (
990
- self.image_text(state, flipped, n_images)
991
- if n_images
992
- else self.text(state, flipped)
 
 
 
 
993
  ),
994
  )
995
  )
996
  if not texts:
997
  return
998
- tokenizer = self.processor.tokenizer if n_images else self.tokenizer
999
  ids = tokenizer([t for _, t in texts], add_special_tokens=False)["input_ids"]
1000
  for (key, text), sequence in zip(texts, ids):
1001
- length = len(sequence) - n_images + visual
1002
  if self.max_length is not None and length > self.max_length:
1003
  for planned in (prepared.sequences, prepared.texts, prepared.lengths):
1004
  planned.pop(key, None)
@@ -1008,7 +1395,7 @@ class D3:
1008
  "message": f"the reversed-option prompt has {length} tokens, over the maximum context "
1009
  f"length of {self.max_length} tokens; nothing was truncated",
1010
  }
1011
- elif n_images:
1012
  prepared.reversed_texts[key] = text
1013
  prepared.reversed_lengths[key] = length
1014
  else:
@@ -1069,6 +1456,125 @@ class D3:
1069
  self._prepare_reversed(state, prepared, sum(visual))
1070
  return prepared
1071
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1072
  def logits(self, sequences: Sequence[Sequence[int]], counts: Sequence[int]):
1073
  """Masked code logits (FP32, [B, 255]) for one left-padded batch."""
1074
  torch = self.torch
@@ -1196,8 +1702,170 @@ class D3:
1196
  prepared.reversed_lengths.values()
1197
  )
1198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1199
  def run(self, prepared: Prepared) -> tuple[dict[str, list[float]], int]:
1200
  """Probabilities per runnable question (request order, ``batch_size`` per pass) and the input tokens."""
 
 
1201
  if prepared.images:
1202
  return self._run_images(prepared)
1203
  keys = prepared.runnable
@@ -1255,15 +1923,17 @@ class D3:
1255
  state: Any,
1256
  questions: Mapping[str, Any],
1257
  images: Sequence[Any] | None = None,
 
1258
  ) -> dict[str, Any]:
1259
  """Typed Choice / Noul / Score answers about one state: ``{"model", "answers", "usage"}``.
1260
 
1261
  ``images``: any number of images (PIL images, paths, http(s) or data URLs) that every question sees.
 
1262
  A question that cannot be answered gets ``{"type", "error", "message"}`` with ``error`` one of
1263
  ``invalid_question``, ``max_length_exceeded`` (never truncated) or ``invalid_model_output``; the
1264
  other questions of the request are still answered.
1265
  """
1266
- prepared = self.prepare(state, questions, images)
1267
  probabilities, tokens = self.run(prepared)
1268
  return self.respond(prepared, probabilities, tokens)
1269
 
@@ -1359,6 +2029,7 @@ class D3:
1359
  "over the question's codes only; argmax choice; no truncation (over-limit questions are "
1360
  "refused); no option filtering; one fixed prompt for every request.",
1361
  "images": self.image_contract(),
 
1362
  }
1363
  if self.permutation_average:
1364
  record["permutation_average"] = True
@@ -1389,6 +2060,31 @@ class D3:
1389
  contract["unavailable"] = self.image_unavailable
1390
  return contract
1391
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1392
  def runtime_info(self) -> dict[str, Any]:
1393
  import transformers
1394
 
 
11
  # "confidence": ...}}, "usage": {"input_tokens": n, "output_tokens": 0}}
12
 
13
  model.system_one(state="...", questions={...}, images=["photo.png", "label.jpg"])
14
+ model.system_one(state="...", questions={...}, videos=["clip.mp4"])
15
 
16
  The checkpoint directory holds ``config.json`` + ``model*.safetensors`` (a transformers
17
  ``Qwen3_5Model``, or a ``Qwen3VLModel`` when ``config.json`` says ``model_type: qwen3_vl``),
 
27
  images in front; a request without images takes exactly the text path. Image inputs need a checkpoint
28
  with the vision tower (``visual.*`` weights).
29
 
30
+ Videos: a request also takes any number of videos (local paths, http(s) URLs, base64 ``data:video/...``
31
+ URLs, or frame arrays: a uint8 ``[frames, height, width, 3]`` RGB array or a list of PIL images, read as
32
+ frames sampled at 2 per second), shared by all of its questions and placed after the images. Files are
33
+ decoded with OpenCV (FFmpeg): 2 frames per second, at least 4 and at most 32 frames spread evenly over the
34
+ whole video, each read by the checkpoint's own video processor at up to 200,704 pixels (0.2 MP); every two
35
+ frames are one group of input tokens (one token per 32 x 32 pixels) after a timestamp. All videos of one
36
+ request take at most 16,384 input tokens. A request without videos takes exactly the text or image path.
37
+
38
  Numerics: BF16 backbone with SDPA attention, FP32 readout and softmax (unless the checkpoint says
39
  otherwise). A request's questions run in request order, ``batch_size`` per forward pass, each batch
40
  left-padded to its longest prompt. ``noncausal_full_attention`` (Qwen3.5 backbones only) lets the
 
131
  "pixel_values",
132
  "image_grid_thw",
133
  )
134
+ VIDEO_FPS = 2.0
135
+ VIDEO_MIN_FRAMES = 4
136
+ VIDEO_MAX_FRAMES = 32
137
+ # Per frame; one input token per 32 x 32 pixels for every two frames.
138
+ VIDEO_MAX_PIXELS = 200_704
139
+ VIDEO_MIN_PIXELS = 4_096
140
+ # All videos of one request.
141
+ VIDEO_MAX_TOKENS = 16_384
142
+ # Checked for every video the server receives (strict loading).
143
+ MAX_VIDEO_BYTES = 32_000_000
144
+ MAX_VIDEO_SOURCE_PIXELS = 8_294_400
145
+ MAX_VIDEO_SECONDS = 300
146
+ VIDEO_FORMATS = ("mp4", "webm", "quicktime", "x-matroska")
147
+ # A source frame rate outside this range is treated as unknown.
148
+ VIDEO_SOURCE_FPS = (0.1, 1000.0)
149
+ VIDEO_UNKNOWN_FPS = 24.0
150
 
151
 
152
  class MaxLengthExceeded(ValueError):
 
264
  raise ValueError(f"unknown prompt family {prompt!r}")
265
 
266
 
267
+ def video_messages(
268
+ prompt: str,
269
+ state: Any,
270
+ question: dict[str, Any],
271
+ codes: Sequence[str],
272
+ n_images: int,
273
+ n_videos: int,
274
+ ) -> list[dict[str, Any]]:
275
+ """The prompt family's messages with the image placeholders, then the video placeholders, before the user text."""
276
+ if n_videos < 1:
277
+ raise ValueError(f"a video prompt takes at least 1 video, got {n_videos}")
278
+ media: list[dict[str, Any]] = [{"type": "image"} for _ in range(n_images)]
279
+ media += [{"type": "video"} for _ in range(n_videos)]
280
+ if prompt == "d3":
281
+ text = {"type": "text", "text": user_prompt(state, question, codes)}
282
+ return [
283
+ {"role": "system", "content": SYSTEM_PROMPT},
284
+ {"role": "user", "content": media + [text]},
285
+ ]
286
+ raise ValueError(f"unknown prompt family {prompt!r}")
287
+
288
+
289
  def reversed_question(question: Question) -> dict[str, Any] | None:
290
  """The rendered choice question with its options in reverse order (None when there is nothing to permute)."""
291
  if question.kind != "choice" or len(question.keys) < 2:
 
379
  raise ValueError("invalid base64 image data") from exc
380
 
381
 
382
+ def _download(url: str, what: str = "image") -> bytes:
383
  import urllib.request
384
 
385
  request = urllib.request.Request(url, headers={"User-Agent": RUNTIME})
 
387
  payload = response.read(MAX_DOWNLOAD_BYTES + 1)
388
  if len(payload) > MAX_DOWNLOAD_BYTES:
389
  raise ValueError(
390
+ f"the {what} at {url} is larger than {MAX_DOWNLOAD_BYTES:,} bytes"
391
  )
392
  return payload
393
 
 
497
  return count
498
 
499
 
500
+ # ---------------------------------------------------------------------------------------------
501
+ # Videos
502
+ # ---------------------------------------------------------------------------------------------
503
+
504
+
505
+ @dataclass
506
+ class Video:
507
+ """A decoded video: the sampled RGB frames and where they come from in the source.
508
+
509
+ ``frames`` is a uint8 array ``[frames, height, width, 3]`` holding an even number of frames (the last
510
+ one is repeated when needed); ``indices`` are their source frame numbers and ``fps`` the source frame
511
+ rate, which give each frame its timestamp.
512
+ """
513
+
514
+ frames: Any
515
+ fps: float
516
+ indices: list[int]
517
+ total_frames: int
518
+
519
+ @property
520
+ def size(self) -> tuple[int, int]:
521
+ """(width, height) of the frames."""
522
+ return int(self.frames.shape[2]), int(self.frames.shape[1])
523
+
524
+ def metadata(self) -> dict[str, Any]:
525
+ """The video processor's ``video_metadata`` for these frames."""
526
+ width, height = self.size
527
+ return {
528
+ "total_num_frames": self.total_frames,
529
+ "fps": self.fps,
530
+ "width": width,
531
+ "height": height,
532
+ "duration": self.total_frames / self.fps,
533
+ "frames_indices": list(self.indices),
534
+ }
535
+
536
+
537
+ def video_sample_indices(total: int, fps: float) -> list[int]:
538
+ """Source frames to read: 2 per second, at least 4 and at most 32, spread evenly over the whole video."""
539
+ import numpy as np
540
+
541
+ count = int(total / fps * VIDEO_FPS)
542
+ count = min(max(count, VIDEO_MIN_FRAMES), VIDEO_MAX_FRAMES, total)
543
+ return np.linspace(0, total - 1, count).round().astype(int).tolist()
544
+
545
+
546
+ def _video(frames: list[Any], indices: list[int], fps: float, total: int) -> Video:
547
+ import numpy as np
548
+
549
+ if len(frames) % 2:
550
+ frames.append(frames[-1])
551
+ indices.append(indices[-1])
552
+ try:
553
+ stacked = np.stack(frames)
554
+ except ValueError as exc:
555
+ raise ValueError("all frames of a video must have the same size") from exc
556
+ return Video(np.ascontiguousarray(stacked), float(fps), list(indices), int(total))
557
+
558
+
559
+ def _video_data_url_payload(value: str, strict: bool) -> bytes:
560
+ header, separator, encoded = value.partition(",")
561
+ kind = header.strip().lower()
562
+ if (
563
+ not separator
564
+ or not kind.startswith("data:video/")
565
+ or not kind.endswith(";base64")
566
+ ):
567
+ raise ValueError("a data URL video is data:video/<format>;base64,<data>")
568
+ if strict:
569
+ if kind[len("data:video/") :].split(";")[0] not in VIDEO_FORMATS:
570
+ raise ValueError(
571
+ "videos must be base64 MP4, WebM, QuickTime or Matroska data URLs"
572
+ )
573
+ if len(encoded) > 4 * -(-MAX_VIDEO_BYTES // 3):
574
+ raise ValueError(f"each video must be at most {MAX_VIDEO_BYTES:,} bytes")
575
+ try:
576
+ payload = base64.b64decode(encoded, validate=True)
577
+ except binascii.Error as exc:
578
+ raise ValueError("invalid base64 video data") from exc
579
+ if strict and len(payload) > MAX_VIDEO_BYTES:
580
+ raise ValueError(f"each video must be at most {MAX_VIDEO_BYTES:,} bytes")
581
+ return payload
582
+
583
+
584
+ def _decode_video(
585
+ source: bytes | str, *, strict: bool, end_frame: int | None = None
586
+ ) -> Video:
587
+ """Decode a video file (path) or encoded video (bytes) with OpenCV and sample its frames."""
588
+ try:
589
+ import cv2
590
+ except ImportError as exc:
591
+ raise ValueError(
592
+ "decoding video files needs OpenCV (pip install opencv-python-headless)"
593
+ ) from exc
594
+ import tempfile
595
+
596
+ try:
597
+ api = (
598
+ cv2.CAP_FFMPEG
599
+ if cv2.videoio_registry.hasBackend(cv2.CAP_FFMPEG)
600
+ else cv2.CAP_ANY
601
+ )
602
+ except (AttributeError, cv2.error):
603
+ api = cv2.CAP_ANY
604
+ temporary = None
605
+ if not isinstance(source, str):
606
+ # Encoded bytes are read from a temporary file, so they decode exactly as the same file given by path.
607
+ with tempfile.NamedTemporaryFile(prefix="d3-video-", delete=False) as handle:
608
+ handle.write(source)
609
+ source = temporary = handle.name
610
+
611
+ def capture():
612
+ return cv2.VideoCapture(source, api)
613
+
614
+ frames, kept, reader = [], [], None
615
+ try:
616
+ reader = capture()
617
+ if not reader.isOpened():
618
+ raise ValueError("the video could not be opened")
619
+ width = int(reader.get(cv2.CAP_PROP_FRAME_WIDTH))
620
+ height = int(reader.get(cv2.CAP_PROP_FRAME_HEIGHT))
621
+ if width <= 0 or height <= 0:
622
+ raise ValueError("the video has no frame size")
623
+ if strict and width * height > MAX_VIDEO_SOURCE_PIXELS:
624
+ raise ValueError(
625
+ f"video frames must have at most {MAX_VIDEO_SOURCE_PIXELS:,} pixels"
626
+ )
627
+ fps = float(reader.get(cv2.CAP_PROP_FPS) or 0.0)
628
+ if not (
629
+ math.isfinite(fps) and VIDEO_SOURCE_FPS[0] <= fps <= VIDEO_SOURCE_FPS[1]
630
+ ):
631
+ fps = VIDEO_UNKNOWN_FPS
632
+ total = int(reader.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
633
+ if total <= 0: # the container does not say: count the frames, then read again
634
+ limit = int(MAX_VIDEO_SECONDS * fps) if strict else None
635
+ while reader.grab():
636
+ total += 1
637
+ if limit is not None and total > limit:
638
+ break
639
+ reader.release()
640
+ reader = capture()
641
+ if end_frame is not None:
642
+ total = min(total, int(end_frame))
643
+ if total <= 0:
644
+ raise ValueError("the video has no frames")
645
+ if strict and total / fps > MAX_VIDEO_SECONDS:
646
+ raise ValueError(
647
+ f"each video must be at most {MAX_VIDEO_SECONDS} seconds long"
648
+ )
649
+ indices = video_sample_indices(total, fps)
650
+ wanted = set(indices)
651
+ for number in range(indices[-1] + 1):
652
+ if not reader.grab():
653
+ continue
654
+ if number in wanted:
655
+ ok, frame = reader.retrieve()
656
+ if ok and frame is not None and frame.size:
657
+ frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
658
+ kept.append(number)
659
+ except cv2.error as exc:
660
+ raise ValueError(f"invalid video data ({exc.__class__.__name__})") from exc
661
+ finally:
662
+ if reader is not None:
663
+ reader.release()
664
+ if temporary is not None:
665
+ os.unlink(temporary)
666
+ if not frames:
667
+ raise ValueError("no frame of the video could be decoded")
668
+ return _video(frames, kept, fps, total)
669
+
670
+
671
+ def _frames_video(value: Any, end_frame: int | None = None) -> Video:
672
+ """A frame array as a video sampled at ``VIDEO_FPS``, thinned evenly to at most ``VIDEO_MAX_FRAMES``."""
673
+ import numpy as np
674
+ from PIL import Image
675
+
676
+ if hasattr(value, "detach"):
677
+ value = value.detach().cpu().numpy()
678
+ if isinstance(value, (list, tuple)):
679
+ if not value:
680
+ raise ValueError("a video given as frames needs at least one frame")
681
+ value = [
682
+ (
683
+ np.asarray(frame.convert("RGB"))
684
+ if isinstance(frame, Image.Image)
685
+ else np.asarray(frame)
686
+ )
687
+ for frame in value
688
+ ]
689
+ if len({frame.shape for frame in value}) != 1:
690
+ raise ValueError("all frames of a video must have the same size")
691
+ value = np.stack(value)
692
+ array = np.asarray(value)
693
+ if (
694
+ array.ndim != 4
695
+ or array.shape[-1] != 3
696
+ or array.dtype != np.uint8
697
+ or not len(array)
698
+ ):
699
+ raise ValueError(
700
+ "a video given as frames is a uint8 [frames, height, width, 3] RGB array or a list of "
701
+ "PIL images or [height, width, 3] arrays"
702
+ )
703
+ if end_frame is not None:
704
+ array = array[: max(1, int(end_frame))]
705
+ total = len(array)
706
+ if total > VIDEO_MAX_FRAMES:
707
+ indices = (
708
+ np.linspace(0, total - 1, VIDEO_MAX_FRAMES).round().astype(int).tolist()
709
+ )
710
+ else:
711
+ indices = list(range(total))
712
+ return _video([array[i] for i in indices], indices, VIDEO_FPS, total)
713
+
714
+
715
+ def load_video(
716
+ value: Any, *, strict: bool = False, end_frame: int | None = None
717
+ ) -> Video:
718
+ """One video input, decoded and sampled (see the module docstring).
719
+
720
+ ``value`` is a local path, an http(s) URL, a ``data:video/<format>;base64,`` URL, a frame array (a uint8
721
+ ``[frames, height, width, 3]`` RGB array or a list of PIL images or ``[height, width, 3]`` arrays, read
722
+ as frames sampled at 2 per second) or a ``Video`` this function returned. ``end_frame`` reads only the
723
+ frames before it. ``strict`` (the server) accepts only data URLs of MP4, WebM, QuickTime or Matroska
724
+ videos of at most 32,000,000 bytes, 300 seconds and 8,294,400 pixels per frame.
725
+ """
726
+ if isinstance(value, Video) and not strict:
727
+ return value
728
+ if isinstance(value, os.PathLike):
729
+ value = os.fspath(value)
730
+ if not isinstance(value, str):
731
+ if strict:
732
+ raise ValueError(
733
+ "videos must be base64 MP4, WebM, QuickTime or Matroska data URLs"
734
+ )
735
+ return _frames_video(value, end_frame)
736
+ if not value:
737
+ raise ValueError(
738
+ "a video is a path, an http(s) URL, a data:video/...;base64 URL or a frame array"
739
+ )
740
+ if strict and not value.startswith("data:"):
741
+ raise ValueError(
742
+ "videos must be base64 MP4, WebM, QuickTime or Matroska data URLs"
743
+ )
744
+ try:
745
+ if value.startswith("data:"):
746
+ source: bytes | str = _video_data_url_payload(value, strict)
747
+ elif value.startswith(("http://", "https://")):
748
+ source = _download(value, "video")
749
+ else:
750
+ path = Path(value).expanduser()
751
+ if not path.is_file():
752
+ raise ValueError(f"no video file at {value}")
753
+ source = str(path)
754
+ except OSError as exc:
755
+ raise ValueError(f"video unreadable ({exc})") from exc
756
+ return _decode_video(source, strict=strict, end_frame=end_frame)
757
+
758
+
759
+ def configure_video_processor(processor) -> None:
760
+ """Read already-sampled frames at up to ``VIDEO_MAX_PIXELS`` each with the checkpoint's video processor."""
761
+ video = processor.video_processor
762
+ factor = video.patch_size * video.merge_size
763
+ video.size = {
764
+ "shortest_edge": VIDEO_MIN_PIXELS,
765
+ "longest_edge": VIDEO_MAX_PIXELS * VIDEO_MAX_FRAMES,
766
+ }
767
+ video.cap_pixels_per_frame = True
768
+ video.max_video_tokens = VIDEO_MAX_PIXELS // factor**2
769
+ video.do_sample_frames = False
770
+
771
+
772
  # ---------------------------------------------------------------------------------------------
773
  # Package files
774
  # ---------------------------------------------------------------------------------------------
 
1003
 
1004
  A text request holds token ``sequences``; an image request holds the decoded ``images`` (shared by
1005
  every question), the rendered prompt ``texts`` and the planned input ``lengths`` (text tokens plus
1006
+ image tokens), and the processor tokenizes it when it runs. A video request also holds the decoded
1007
+ ``videos``, their processed pixels (``media``, one copy per image and video) and each video's
1008
+ placeholder expansion (``video_texts``).
1009
  """
1010
 
1011
  keys: list[str]
 
1015
  images: list[Any] = field(default_factory=list)
1016
  texts: dict[str, str] = field(default_factory=dict)
1017
  lengths: dict[str, int] = field(default_factory=dict)
1018
+ videos: list[Video] = field(default_factory=list)
1019
+ media: dict[str, Any] = field(default_factory=dict)
1020
+ video_texts: list[str] = field(default_factory=list)
1021
  # Reversed-option prompts of the choice questions (permutation_average only).
1022
  reversed_sequences: dict[str, list[int]] = field(default_factory=dict)
1023
  reversed_texts: dict[str, str] = field(default_factory=dict)
 
1156
  self.image_unavailable = (
1157
  f"the image processor failed to load ({type(exc).__name__}: {exc})"
1158
  )
1159
+ self.video_unavailable: str | None = None
1160
+ if self.image_unavailable is not None:
1161
+ self.video_unavailable = self.image_unavailable
1162
+ elif self.backbone_type != "qwen3_5":
1163
+ self.video_unavailable = "video inputs need a Qwen3.5 backbone"
1164
+ elif getattr(self.processor, "video_processor", None) is None:
1165
+ self.video_unavailable = "the checkpoint has no video processor"
1166
+ else:
1167
+ try:
1168
+ configure_video_processor(self.processor)
1169
+ except (
1170
+ Exception
1171
+ ) as exc: # noqa: BLE001 - text and image requests do not use it
1172
+ self.video_unavailable = (
1173
+ f"the video processor failed to load ({type(exc).__name__}: {exc})"
1174
+ )
1175
  fast, reason = d3_fast, "d3_fast.py is not present"
1176
  if fast is None:
1177
  try:
 
1240
  enable_thinking=False,
1241
  )
1242
 
1243
+ def video_text(
1244
+ self, state: Any, question: dict[str, Any], n_images: int, n_videos: int
1245
+ ) -> str:
1246
+ return self.processor.apply_chat_template(
1247
+ video_messages(
1248
+ self.prompt, state, question, self.codes, n_images, n_videos
1249
+ ),
1250
+ tokenize=False,
1251
+ add_generation_prompt=True,
1252
+ enable_thinking=False,
1253
+ )
1254
+
1255
+ def load_videos(
1256
+ self, videos: Sequence[Any], *, strict: bool = False
1257
+ ) -> list[Video]:
1258
+ """Decode and sample a request's videos, any number; raises ValueError for a video it cannot read."""
1259
+ if not videos:
1260
+ return []
1261
+ if self.video_unavailable is not None:
1262
+ raise ValueError(
1263
+ f"video inputs are not available: {self.video_unavailable}"
1264
+ )
1265
+ decoded = []
1266
+ for number, value in enumerate(videos):
1267
+ try:
1268
+ decoded.append(load_video(value, strict=strict))
1269
+ except ValueError as exc:
1270
+ raise ValueError(f"videos[{number}]: {exc}") from exc
1271
+ return decoded
1272
+
1273
  def load_images(self, images: Sequence[Any], *, strict: bool = False) -> list[Any]:
1274
  """Decode a request's images (RGB PIL), any number; raises ValueError for an image it cannot read."""
1275
  if not images:
 
1291
  state: Any,
1292
  questions: Mapping[str, Any],
1293
  images: Sequence[Any] | None = None,
1294
+ videos: Sequence[Any] | None = None,
1295
  ) -> Prepared:
1296
+ """Validate and tokenize one request; malformed ``state``, ``questions``, ``images`` or ``videos`` raise
1297
+ ValueError.
1298
 
1299
+ ``images`` (a list of any number of images shared by every question) select the image path, ``videos``
1300
+ (a list of any number of videos, with or without images) the video path. Without them the request takes
1301
+ the text path unchanged.
1302
  """
1303
  if not isinstance(questions, Mapping) or not questions:
1304
  raise ValueError(
 
1309
  _json_value(state)
1310
  if images is not None and not isinstance(images, (list, tuple)):
1311
  raise ValueError("images must be a list of images")
1312
+ if videos is not None and not isinstance(videos, (list, tuple)):
1313
+ raise ValueError("videos must be a list of videos")
1314
+ if videos:
1315
+ return self._prepare_videos(
1316
+ state,
1317
+ questions,
1318
+ self.load_images(images or []),
1319
+ self.load_videos(videos),
1320
+ )
1321
  if images:
1322
  return self._prepare_images(state, questions, self.load_images(images))
1323
  prepared = Prepared(keys=list(questions))
 
1358
  ) -> None:
1359
  """Render and tokenize the reversed-option prompt of every runnable choice question.
1360
 
1361
+ ``visual``: the image and video tokens of the request (image and video paths), counted like
1362
+ ``_prepare_images`` and ``_prepare_videos`` count them.
1363
  """
1364
+ n_images, n_videos = len(prepared.images), len(prepared.videos)
1365
  texts = []
1366
  for key in prepared.runnable:
1367
  flipped = reversed_question(prepared.questions[key])
 
1370
  (
1371
  key,
1372
  (
1373
+ self.video_text(state, flipped, n_images, n_videos)
1374
+ if n_videos
1375
+ else (
1376
+ self.image_text(state, flipped, n_images)
1377
+ if n_images
1378
+ else self.text(state, flipped)
1379
+ )
1380
  ),
1381
  )
1382
  )
1383
  if not texts:
1384
  return
1385
+ tokenizer = self.processor.tokenizer if n_images or n_videos else self.tokenizer
1386
  ids = tokenizer([t for _, t in texts], add_special_tokens=False)["input_ids"]
1387
  for (key, text), sequence in zip(texts, ids):
1388
+ length = len(sequence) - n_images - n_videos + visual
1389
  if self.max_length is not None and length > self.max_length:
1390
  for planned in (prepared.sequences, prepared.texts, prepared.lengths):
1391
  planned.pop(key, None)
 
1395
  "message": f"the reversed-option prompt has {length} tokens, over the maximum context "
1396
  f"length of {self.max_length} tokens; nothing was truncated",
1397
  }
1398
+ elif n_images or n_videos:
1399
  prepared.reversed_texts[key] = text
1400
  prepared.reversed_lengths[key] = length
1401
  else:
 
1456
  self._prepare_reversed(state, prepared, sum(visual))
1457
  return prepared
1458
 
1459
+ def _prepare_videos(
1460
+ self,
1461
+ state: Any,
1462
+ questions: Mapping[str, Any],
1463
+ images: list[Any],
1464
+ videos: list[Video],
1465
+ ) -> Prepared:
1466
+ """Process the images and videos once, render every question with them in front and plan its tokens."""
1467
+ processor = self.processor
1468
+ try:
1469
+ visual = [
1470
+ visual_tokens(processor.image_processor, *image.size)
1471
+ for image in images
1472
+ ]
1473
+ except ValueError as exc:
1474
+ raise ValueError(f"image rejected by the processor: {exc}") from exc
1475
+ merge = processor.video_processor.merge_size**2
1476
+ try:
1477
+ planned = (
1478
+ sum(
1479
+ processor.video_processor.get_num_of_video_patches(
1480
+ len(video.frames), video.size[1], video.size[0]
1481
+ )
1482
+ for video in videos
1483
+ )
1484
+ // merge
1485
+ )
1486
+ except ValueError as exc:
1487
+ raise ValueError(f"video rejected by the processor: {exc}") from exc
1488
+ if planned > VIDEO_MAX_TOKENS:
1489
+ raise ValueError(
1490
+ f"the videos take {planned:,} input tokens, over the {VIDEO_MAX_TOKENS:,} tokens a "
1491
+ "request may spend on videos; send fewer or shorter videos"
1492
+ )
1493
+ try:
1494
+ media = dict(
1495
+ processor.video_processor(
1496
+ videos=[video.frames for video in videos],
1497
+ video_metadata=[video.metadata() for video in videos],
1498
+ return_metadata=True,
1499
+ return_tensors="pt",
1500
+ )
1501
+ )
1502
+ except ValueError as exc:
1503
+ raise ValueError(f"video rejected by the processor: {exc}") from exc
1504
+ video_texts = [
1505
+ processor.replace_video_token(media, video_idx=i)
1506
+ for i in range(len(videos))
1507
+ ]
1508
+ if int(media["video_grid_thw"].prod(-1).sum()) // merge != planned:
1509
+ raise RuntimeError("the video processor and its token count disagree")
1510
+ expanded = sum(
1511
+ len(ids)
1512
+ for ids in processor.tokenizer(video_texts, add_special_tokens=False)[
1513
+ "input_ids"
1514
+ ]
1515
+ )
1516
+ media = {
1517
+ "pixel_values_videos": media["pixel_values_videos"],
1518
+ "video_grid_thw": media["video_grid_thw"],
1519
+ }
1520
+ if images:
1521
+ media.update(processor.image_processor(images=images, return_tensors="pt"))
1522
+ prepared = Prepared(
1523
+ keys=list(questions),
1524
+ images=images,
1525
+ videos=videos,
1526
+ media=media,
1527
+ video_texts=video_texts,
1528
+ )
1529
+ texts = []
1530
+ for key, question in questions.items():
1531
+ try:
1532
+ normalized = normalize_question(question)
1533
+ text = self.video_text(
1534
+ state, normalized.rendered, len(images), len(videos)
1535
+ )
1536
+ except ValueError as exc:
1537
+ kind = question.get("type") if isinstance(question, Mapping) else None
1538
+ prepared.errors[key] = {
1539
+ "type": kind,
1540
+ "error": "invalid_question",
1541
+ "message": str(exc),
1542
+ }
1543
+ continue
1544
+ if text.count(IMAGE_TOKEN) != len(images) or text.count(VIDEO_TOKEN) != len(
1545
+ videos
1546
+ ):
1547
+ prepared.errors[key] = {
1548
+ "type": normalized.kind,
1549
+ "error": "invalid_question",
1550
+ "message": "the state or question contains a literal image or video placeholder token",
1551
+ }
1552
+ continue
1553
+ prepared.questions[key] = normalized
1554
+ texts.append((key, text))
1555
+ if texts:
1556
+ ids = processor.tokenizer([t for _, t in texts], add_special_tokens=False)[
1557
+ "input_ids"
1558
+ ]
1559
+ for (key, text), sequence in zip(texts, ids):
1560
+ length = (
1561
+ len(sequence) - len(images) - len(videos) + sum(visual) + expanded
1562
+ )
1563
+ if self.max_length is not None and length > self.max_length:
1564
+ prepared.errors[key] = {
1565
+ "type": prepared.questions[key].kind,
1566
+ "error": "max_length_exceeded",
1567
+ "message": f"the question prompt has {length} tokens ({sum(visual) + expanded} for "
1568
+ f"{len(images)} image(s) and {len(videos)} video(s)), over the maximum context length "
1569
+ f"of {self.max_length} tokens; nothing was truncated",
1570
+ }
1571
+ continue
1572
+ prepared.texts[key] = text
1573
+ prepared.lengths[key] = length
1574
+ if self.permutation_average:
1575
+ self._prepare_reversed(state, prepared, sum(visual) + expanded)
1576
+ return prepared
1577
+
1578
  def logits(self, sequences: Sequence[Sequence[int]], counts: Sequence[int]):
1579
  """Masked code logits (FP32, [B, 255]) for one left-padded batch."""
1580
  torch = self.torch
 
1702
  prepared.reversed_lengths.values()
1703
  )
1704
 
1705
+ def media_features(self, media: Mapping[str, Any]) -> dict[str, list[Any]]:
1706
+ """Vision-tower features of each image and video of a video request, computed once per request."""
1707
+ torch = self.torch
1708
+ backbone = self.backbone
1709
+ features: dict[str, list[Any]] = {}
1710
+ with torch.inference_mode(), sdpa_backends(self.device):
1711
+ if "pixel_values" in media:
1712
+ features["image"] = list(
1713
+ backbone.get_image_features(
1714
+ media["pixel_values"].to(self.device),
1715
+ media["image_grid_thw"].to(self.device),
1716
+ return_dict=True,
1717
+ ).pooler_output
1718
+ )
1719
+ features["video"] = list(
1720
+ backbone.get_video_features(
1721
+ media["pixel_values_videos"].to(self.device),
1722
+ media["video_grid_thw"].to(self.device),
1723
+ return_dict=True,
1724
+ ).pooler_output
1725
+ )
1726
+ return features
1727
+
1728
+ def video_logits(
1729
+ self,
1730
+ texts: Sequence[str],
1731
+ prepared: Prepared,
1732
+ features: Mapping[str, list[Any]],
1733
+ counts: Sequence[int],
1734
+ width: int,
1735
+ ):
1736
+ """Masked code logits (FP32, [B, 255]) for one left-padded batch of prompts that share the request's videos.
1737
+
1738
+ Each prompt's placeholders are expanded with the request's images and videos and the batch is padded to
1739
+ ``width`` tokens (the longest planned prompt); the vision features of the request go into every prompt,
1740
+ then the text layers run as for an image batch (the fused layers when the fast path is active).
1741
+ """
1742
+ torch = self.torch
1743
+ processor = self.processor
1744
+ backbone = self.backbone
1745
+ media = prepared.media
1746
+ image_texts = [
1747
+ processor.replace_image_token(media, image_idx=i)
1748
+ for i in range(len(prepared.images))
1749
+ ]
1750
+ expanded = [
1751
+ processor.get_text_with_replacements(
1752
+ [text], image_texts, prepared.video_texts
1753
+ )[0][0]
1754
+ for text in texts
1755
+ ]
1756
+ encoded = processor.tokenizer(
1757
+ expanded, padding=True, return_token_type_ids=False, return_tensors="pt"
1758
+ )
1759
+ if encoded["input_ids"].shape[1] != width:
1760
+ raise RuntimeError(
1761
+ f"planned {width} input tokens, the processor produced {encoded['input_ids'].shape[1]}"
1762
+ )
1763
+ rows = len(texts)
1764
+ ids = encoded["input_ids"].to(self.device)
1765
+ mask = encoded["attention_mask"].to(self.device)
1766
+ types = torch.as_tensor(
1767
+ processor.create_mm_token_type_ids(encoded["input_ids"]), device=self.device
1768
+ )
1769
+ padded = not bool(encoded["attention_mask"].all())
1770
+ with torch.inference_mode(), sdpa_backends(self.device):
1771
+ embeds = backbone.get_input_embeddings()(ids)
1772
+ image_grid = None
1773
+ if "image" in features:
1774
+ values = torch.cat(features["image"] * rows).to(
1775
+ embeds.device, embeds.dtype
1776
+ )
1777
+ image_mask, _ = backbone.get_placeholder_mask(
1778
+ ids, inputs_embeds=embeds, image_features=values
1779
+ )
1780
+ embeds = embeds.masked_scatter(image_mask, values)
1781
+ image_grid = media["image_grid_thw"].repeat(rows, 1).to(self.device)
1782
+ values = torch.cat(features["video"] * rows).to(embeds.device, embeds.dtype)
1783
+ _, video_mask = backbone.get_placeholder_mask(
1784
+ ids, inputs_embeds=embeds, video_features=values
1785
+ )
1786
+ embeds = embeds.masked_scatter(video_mask, values)
1787
+ positions = backbone.compute_3d_position_ids(
1788
+ input_ids=ids,
1789
+ image_grid_thw=image_grid,
1790
+ video_grid_thw=media["video_grid_thw"].repeat(rows, 1).to(self.device),
1791
+ inputs_embeds=embeds,
1792
+ attention_mask=mask,
1793
+ past_key_values=None,
1794
+ mm_token_type_ids=types,
1795
+ )
1796
+ fused = self.fast.fused if self.fast is not None else None
1797
+ if fused is not None:
1798
+ rotary = self.fast.lm.rotary_emb(embeds, positions)
1799
+ hidden = fused.forward(
1800
+ embeds,
1801
+ mask.bool()[:, None, None, :],
1802
+ mask.reshape(-1) if padded else None,
1803
+ rotary,
1804
+ )[:, -1]
1805
+ else:
1806
+ hidden = backbone.language_model(
1807
+ input_ids=None,
1808
+ position_ids=positions,
1809
+ attention_mask=mask,
1810
+ past_key_values=None,
1811
+ inputs_embeds=embeds,
1812
+ use_cache=False,
1813
+ ).last_hidden_state[:, -1]
1814
+ if self.readout_dtype == "float32":
1815
+ logits = hidden.float() @ self.readout.T
1816
+ else:
1817
+ logits = torch.nn.functional.linear(hidden, self.readout).float()
1818
+ limit = torch.as_tensor(list(counts), device=self.device)[:, None]
1819
+ invalid = torch.arange(MAX_OPTIONS, device=self.device)[None] >= limit
1820
+ return logits.masked_fill(invalid, float("-inf"))
1821
+
1822
+ def _run_videos(self, prepared: Prepared) -> tuple[dict[str, list[float]], int]:
1823
+ keys = prepared.runnable
1824
+ out: dict[str, list[float]] = {}
1825
+ if not keys:
1826
+ return out, 0
1827
+ features = self.media_features(prepared.media)
1828
+
1829
+ def probabilities(texts, counts, width):
1830
+ probs = (
1831
+ (
1832
+ self.video_logits(texts, prepared, features, counts, width)
1833
+ / self.temperature
1834
+ )
1835
+ .softmax(-1)
1836
+ .cpu()
1837
+ .tolist()
1838
+ )
1839
+ return [p[:c] for p, c in zip(probs, counts)]
1840
+
1841
+ for start in range(0, len(keys), self.batch_size):
1842
+ chunk = keys[start : start + self.batch_size]
1843
+ extra = [k for k in chunk if k in prepared.reversed_texts]
1844
+ texts = [prepared.texts[k] for k in chunk] + [
1845
+ prepared.reversed_texts[k] for k in extra
1846
+ ]
1847
+ counts = [len(prepared.questions[k].keys) for k in chunk + extra]
1848
+ widths = [prepared.lengths[k] for k in chunk] + [
1849
+ prepared.reversed_lengths[k] for k in extra
1850
+ ]
1851
+ n = len(chunk)
1852
+ if extra and len(widths) * max(widths) > MERGE_TOKENS:
1853
+ probs = probabilities(
1854
+ texts[:n], counts[:n], max(widths[:n])
1855
+ ) + probabilities(texts[n:], counts[n:], max(widths[n:]))
1856
+ else:
1857
+ probs = probabilities(texts, counts, max(widths))
1858
+ out.update(zip(chunk, probs))
1859
+ for key, backward in zip(extra, probs[n:]):
1860
+ out[key] = average_orders(out[key], backward)
1861
+ return out, sum(prepared.lengths[k] for k in keys) + sum(
1862
+ prepared.reversed_lengths.values()
1863
+ )
1864
+
1865
  def run(self, prepared: Prepared) -> tuple[dict[str, list[float]], int]:
1866
  """Probabilities per runnable question (request order, ``batch_size`` per pass) and the input tokens."""
1867
+ if prepared.videos:
1868
+ return self._run_videos(prepared)
1869
  if prepared.images:
1870
  return self._run_images(prepared)
1871
  keys = prepared.runnable
 
1923
  state: Any,
1924
  questions: Mapping[str, Any],
1925
  images: Sequence[Any] | None = None,
1926
+ videos: Sequence[Any] | None = None,
1927
  ) -> dict[str, Any]:
1928
  """Typed Choice / Noul / Score answers about one state: ``{"model", "answers", "usage"}``.
1929
 
1930
  ``images``: any number of images (PIL images, paths, http(s) or data URLs) that every question sees.
1931
+ ``videos``: any number of videos (paths, http(s) or data URLs, frame arrays) that every question sees.
1932
  A question that cannot be answered gets ``{"type", "error", "message"}`` with ``error`` one of
1933
  ``invalid_question``, ``max_length_exceeded`` (never truncated) or ``invalid_model_output``; the
1934
  other questions of the request are still answered.
1935
  """
1936
+ prepared = self.prepare(state, questions, images, videos)
1937
  probabilities, tokens = self.run(prepared)
1938
  return self.respond(prepared, probabilities, tokens)
1939
 
 
2029
  "over the question's codes only; argmax choice; no truncation (over-limit questions are "
2030
  "refused); no option filtering; one fixed prompt for every request.",
2031
  "images": self.image_contract(),
2032
+ "videos": self.video_contract(),
2033
  }
2034
  if self.permutation_average:
2035
  record["permutation_average"] = True
 
2060
  contract["unavailable"] = self.image_unavailable
2061
  return contract
2062
 
2063
+ def video_contract(self) -> dict[str, Any]:
2064
+ """How video inputs are read (or why they are not available)."""
2065
+ contract: dict[str, Any] = {
2066
+ "supported": self.video_unavailable is None,
2067
+ "fps": VIDEO_FPS,
2068
+ "min_frames": VIDEO_MIN_FRAMES,
2069
+ "max_frames": VIDEO_MAX_FRAMES,
2070
+ "max_pixels_per_frame": VIDEO_MAX_PIXELS,
2071
+ "max_tokens_per_request": VIDEO_MAX_TOKENS,
2072
+ "placement": "after the images, before the text of the user turn, one placeholder per video, "
2073
+ "request order; every two frames are one token group after their timestamp",
2074
+ "sampling": "source frames spread evenly over the whole video (frame arrays: frames at 2 per second)",
2075
+ }
2076
+ if self.video_unavailable is None:
2077
+ contract["video_processor"] = type(self.processor.video_processor).__name__
2078
+ try:
2079
+ import cv2
2080
+
2081
+ contract["opencv"] = cv2.__version__
2082
+ except Exception: # noqa: BLE001
2083
+ contract["opencv"] = None
2084
+ else:
2085
+ contract["unavailable"] = self.video_unavailable
2086
+ return contract
2087
+
2088
  def runtime_info(self) -> dict[str, Any]:
2089
  import transformers
2090
 
d3_server.py CHANGED
@@ -3,12 +3,16 @@
3
  pip install fastapi uvicorn
4
  python d3_server.py --model <package dir or Hub id> [--device cuda:0] [--host 127.0.0.1] [--port 8000]
5
 
6
- Request ``{"model", "state", "questions", "images"}``, response ``{"model", "answers", "usage"}``: the wire
7
- format of the Decision Index ``http`` engine. ``images`` (optional) lists any number of base64 PNG, JPEG or WebP
8
- data URLs (``data:image/png;base64,...``) that every question sees, each at most 8,000,000 bytes and 16,000,000
9
- pixels (the model reads it at up to 1.6 MP). A question over the input limit refuses the whole request with
10
- HTTP 422 naming the maximum context length (the Index records it as unsupported; nothing is truncated);
11
- malformed requests and invalid images also get 422. Requests are served one at a time. With
 
 
 
 
12
  ``DECISION_API_KEY`` set, requests need ``Authorization: Bearer <key>``. ``GET /health`` and
13
  ``GET /v1/models`` describe the loaded model.
14
 
@@ -35,18 +39,28 @@ os.environ.setdefault("TRITON_CACHE_AUTOTUNING", "1")
35
  from d3_runtime import ( # noqa: E402
36
  DEFAULT_BATCH_SIZE,
37
  IMAGE_MAX_PIXELS,
 
 
 
 
 
38
  D3,
39
  )
40
 
41
- REQUEST_FIELDS = {"model", "state", "questions", "images"}
42
 
43
 
44
  def reads_images(model: D3) -> bool:
45
  return getattr(model, "image_unavailable", "unknown") is None
46
 
47
 
 
 
 
 
48
  def modalities(model: D3) -> list[str]:
49
- return ["text", "image"] if reads_images(model) else ["text"]
 
50
 
51
 
52
  class Service:
@@ -129,6 +143,14 @@ def build_app(args: argparse.Namespace):
129
  }
130
  if reads_images(model):
131
  entry["image_max_pixels"] = IMAGE_MAX_PIXELS
 
 
 
 
 
 
 
 
132
  return {"models": [entry]}
133
 
134
  def decode_images(model: D3, images: Any) -> list[Any]:
@@ -140,10 +162,25 @@ def build_app(args: argparse.Namespace):
140
  raise ValueError("This model reads text only; images are not supported.")
141
  return model.load_images(images, strict=True)
142
 
143
- def decide(body: dict[str, Any], images: list[Any]) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
144
  model = service.model
145
  with service.lock:
146
- if images:
 
 
 
 
147
  prepared = model.prepare(
148
  body.get("state"), body.get("questions"), images
149
  )
@@ -181,7 +218,12 @@ def build_app(args: argparse.Namespace):
181
  if body.get("images") is not None
182
  else []
183
  )
184
- return await run_in_threadpool(decide, body, images)
 
 
 
 
 
185
  except ValueError as exc:
186
  raise HTTPException(422, str(exc)) from exc
187
 
 
3
  pip install fastapi uvicorn
4
  python d3_server.py --model <package dir or Hub id> [--device cuda:0] [--host 127.0.0.1] [--port 8000]
5
 
6
+ Request ``{"model", "state", "questions", "images", "videos"}``, response ``{"model", "answers", "usage"}``: the
7
+ wire format of the Decision Index ``http`` engine. ``images`` (optional) lists any number of base64 PNG, JPEG or
8
+ WebP data URLs (``data:image/png;base64,...``) that every question sees, each at most 8,000,000 bytes and
9
+ 16,000,000 pixels (the model reads it at up to 1.6 MP). ``videos`` (optional) lists any number of base64 MP4, WebM,
10
+ QuickTime or Matroska data URLs (``data:video/mp4;base64,...``) that every question sees, each at most 32,000,000
11
+ bytes, 300 seconds and 8,294,400 pixels per frame (the model reads 2 frames per second, at most 32 frames spread
12
+ over the video, each at up to 0.2 MP; at most 16,384 video tokens per request). A question over the input limit
13
+ refuses the whole request with HTTP 422 naming the maximum context length (the Index records it as unsupported;
14
+ nothing is truncated); malformed requests and invalid images or videos also get 422. Requests are served one at
15
+ a time. With
16
  ``DECISION_API_KEY`` set, requests need ``Authorization: Bearer <key>``. ``GET /health`` and
17
  ``GET /v1/models`` describe the loaded model.
18
 
 
39
  from d3_runtime import ( # noqa: E402
40
  DEFAULT_BATCH_SIZE,
41
  IMAGE_MAX_PIXELS,
42
+ MAX_VIDEO_BYTES,
43
+ VIDEO_FPS,
44
+ VIDEO_MAX_FRAMES,
45
+ VIDEO_MAX_PIXELS,
46
+ VIDEO_MAX_TOKENS,
47
  D3,
48
  )
49
 
50
+ REQUEST_FIELDS = {"model", "state", "questions", "images", "videos"}
51
 
52
 
53
  def reads_images(model: D3) -> bool:
54
  return getattr(model, "image_unavailable", "unknown") is None
55
 
56
 
57
+ def reads_videos(model: D3) -> bool:
58
+ return getattr(model, "video_unavailable", "unknown") is None
59
+
60
+
61
  def modalities(model: D3) -> list[str]:
62
+ names = ["text", "image"] if reads_images(model) else ["text"]
63
+ return names + ["video"] if reads_videos(model) else names
64
 
65
 
66
  class Service:
 
143
  }
144
  if reads_images(model):
145
  entry["image_max_pixels"] = IMAGE_MAX_PIXELS
146
+ if reads_videos(model):
147
+ entry["video"] = {
148
+ "fps": VIDEO_FPS,
149
+ "max_frames": VIDEO_MAX_FRAMES,
150
+ "max_pixels_per_frame": VIDEO_MAX_PIXELS,
151
+ "max_tokens_per_request": VIDEO_MAX_TOKENS,
152
+ "max_bytes": MAX_VIDEO_BYTES,
153
+ }
154
  return {"models": [entry]}
155
 
156
  def decode_images(model: D3, images: Any) -> list[Any]:
 
162
  raise ValueError("This model reads text only; images are not supported.")
163
  return model.load_images(images, strict=True)
164
 
165
+ def decode_videos(model: D3, videos: Any) -> list[Any]:
166
+ if not isinstance(videos, list):
167
+ raise ValueError("videos must be a list of base64 data URLs")
168
+ if not videos:
169
+ return []
170
+ if not reads_videos(model):
171
+ raise ValueError("This model does not read videos.")
172
+ return model.load_videos(videos, strict=True)
173
+
174
+ def decide(
175
+ body: dict[str, Any], images: list[Any], videos: list[Any]
176
+ ) -> dict[str, Any]:
177
  model = service.model
178
  with service.lock:
179
+ if videos:
180
+ prepared = model.prepare(
181
+ body.get("state"), body.get("questions"), images, videos
182
+ )
183
+ elif images:
184
  prepared = model.prepare(
185
  body.get("state"), body.get("questions"), images
186
  )
 
218
  if body.get("images") is not None
219
  else []
220
  )
221
+ videos = (
222
+ await run_in_threadpool(decode_videos, service.model, body["videos"])
223
+ if body.get("videos") is not None
224
+ else []
225
+ )
226
+ return await run_in_threadpool(decide, body, images, videos)
227
  except ValueError as exc:
228
  raise HTTPException(422, str(exc)) from exc
229
 
modeling_d3.py CHANGED
@@ -1,8 +1,8 @@
1
  """d3 model for 🤗 Transformers (``trust_remote_code=True``).
2
 
3
  ``AutoModel.from_pretrained(repo, trust_remote_code=True)`` loads the repository through its own runtime
4
- (``d3_runtime.py``) and returns a model with ``system_one(state=..., questions={...}, images=[...])``
5
- (any number of images per request). The
6
  repository is a standard ``Qwen3_5Model`` checkpoint (``D3Model``) or ``Qwen3VLModel`` checkpoint
7
  (``D3Qwen3VLModel``) plus a 255-way answer-code readout, so without ``trust_remote_code`` the same
8
  repository loads as the plain backbone. A directory without ``decision_config.json`` is not a Decision
@@ -107,7 +107,7 @@ def _is_decision(name_or_path: Any, revision: Any, hub: dict[str, Any]) -> bool:
107
 
108
 
109
  class D3Model(PreTrainedModel):
110
- """A d3 checkpoint behind System One: ``system_one(state=..., questions={...}, images=[...])``."""
111
 
112
  config_class = Qwen3_5Config
113
  base_model_prefix = "decision"
@@ -233,16 +233,19 @@ class D3Model(PreTrainedModel):
233
  state: Any,
234
  questions: dict[str, Any],
235
  images: list[Any] | None = None,
 
236
  ) -> dict[str, Any]:
237
  """Typed Choice / Noul / Score answers about one state: ``{"model", "answers", "usage"}``.
238
 
239
  ``questions`` maps question IDs to ``{"type": "choice" | "noul" | "score", "instructions": ...,
240
  "criteria": ...}``; a question over the input limit is answered ``max_length_exceeded``, never
241
  truncated. ``images``: any number of images every question sees (PIL images, local paths, http(s) URLs or
242
- base64 ``data:image/...`` URLs), placed before the text and read at up to 1.6 MP each.
 
 
243
  """
244
  return self._require().system_one(
245
- state=state, questions=questions, images=images
246
  )
247
 
248
  def forward(
@@ -250,8 +253,11 @@ class D3Model(PreTrainedModel):
250
  state: Any = None,
251
  questions: dict[str, Any] | None = None,
252
  images: list[Any] | None = None,
 
253
  ) -> dict[str, Any]:
254
- return self.system_one(state=state, questions=questions, images=images)
 
 
255
 
256
  def to(self, *args: Any, **kwargs: Any) -> D3Model:
257
  """Move to another device; numerics are fixed by the checkpoint, so dtype casts are refused."""
 
1
  """d3 model for 🤗 Transformers (``trust_remote_code=True``).
2
 
3
  ``AutoModel.from_pretrained(repo, trust_remote_code=True)`` loads the repository through its own runtime
4
+ (``d3_runtime.py``) and returns a model with ``system_one(state=..., questions={...}, images=[...], videos=[...])``
5
+ (any number of images and videos per request). The
6
  repository is a standard ``Qwen3_5Model`` checkpoint (``D3Model``) or ``Qwen3VLModel`` checkpoint
7
  (``D3Qwen3VLModel``) plus a 255-way answer-code readout, so without ``trust_remote_code`` the same
8
  repository loads as the plain backbone. A directory without ``decision_config.json`` is not a Decision
 
107
 
108
 
109
  class D3Model(PreTrainedModel):
110
+ """A d3 checkpoint behind System One: ``system_one(state=..., questions={...}, images=[...], videos=[...])``."""
111
 
112
  config_class = Qwen3_5Config
113
  base_model_prefix = "decision"
 
233
  state: Any,
234
  questions: dict[str, Any],
235
  images: list[Any] | None = None,
236
+ videos: list[Any] | None = None,
237
  ) -> dict[str, Any]:
238
  """Typed Choice / Noul / Score answers about one state: ``{"model", "answers", "usage"}``.
239
 
240
  ``questions`` maps question IDs to ``{"type": "choice" | "noul" | "score", "instructions": ...,
241
  "criteria": ...}``; a question over the input limit is answered ``max_length_exceeded``, never
242
  truncated. ``images``: any number of images every question sees (PIL images, local paths, http(s) URLs or
243
+ base64 ``data:image/...`` URLs), placed before the text and read at up to 1.6 MP each. ``videos``: any
244
+ number of videos every question sees (local paths, http(s) URLs, base64 ``data:video/...`` URLs or frame
245
+ arrays), placed after the images: 2 frames per second, at most 32 frames, each at up to 0.2 MP.
246
  """
247
  return self._require().system_one(
248
+ state=state, questions=questions, images=images, videos=videos
249
  )
250
 
251
  def forward(
 
253
  state: Any = None,
254
  questions: dict[str, Any] | None = None,
255
  images: list[Any] | None = None,
256
+ videos: list[Any] | None = None,
257
  ) -> dict[str, Any]:
258
+ return self.system_one(
259
+ state=state, questions=questions, images=images, videos=videos
260
+ )
261
 
262
  def to(self, *args: Any, **kwargs: Any) -> D3Model:
263
  """Move to another device; numerics are fixed by the checkpoint, so dtype casts are refused."""
pipeline_d3.py CHANGED
@@ -1,8 +1,9 @@
1
  """The ``decision`` pipeline for d3 models (``trust_remote_code=True``).
2
 
3
  ``pipeline("decision", model=repo, trust_remote_code=True)`` loads the model with ``AutoModel`` and answers
4
- ``{"state": ..., "questions": {...}}`` requests, optionally with ``"images": [...]`` (any number of images per
5
- request), or ``state=..., questions=..., images=...`` keywords, or a list of requests, with the model's
 
6
  ``system_one`` response. The model batches the questions of one request itself.
7
  """
8
 
@@ -10,7 +11,7 @@ from transformers import Pipeline
10
 
11
  _UNSET = object()
12
  REQUEST_KEYS = {"state", "questions"}
13
- OPTIONAL_KEYS = {"images"}
14
 
15
 
16
  class D3Pipeline(Pipeline):
@@ -28,17 +29,28 @@ class D3Pipeline(Pipeline):
28
  return {}, {}, {}
29
 
30
  def __call__(
31
- self, inputs=None, *, state=_UNSET, questions=_UNSET, images=_UNSET, **kwargs
 
 
 
 
 
 
 
32
  ):
33
- if state is not _UNSET or questions is not _UNSET or images is not _UNSET:
34
  if inputs is not None:
35
- raise TypeError("Pass one request, or state=, questions= and images=")
 
 
36
  inputs = {
37
  "state": None if state is _UNSET else state,
38
  "questions": None if questions is _UNSET else questions,
39
  }
40
  if images is not _UNSET:
41
  inputs["images"] = images
 
 
42
  if kwargs.get("batch_size") not in (None, 1):
43
  raise ValueError(
44
  "The decision pipeline runs one request at a time (batch_size=1)"
@@ -51,12 +63,13 @@ class D3Pipeline(Pipeline):
51
  ):
52
  raise ValueError(
53
  'A decision request is {"state": ..., "questions": {<id>: <question>, ...}} '
54
- 'with optional "images": [...]'
55
  )
56
  return {
57
  "state": inputs["state"],
58
  "questions": inputs["questions"],
59
  "images": inputs.get("images"),
 
60
  }
61
 
62
  def _forward(self, model_inputs):
@@ -64,6 +77,7 @@ class D3Pipeline(Pipeline):
64
  state=model_inputs["state"],
65
  questions=model_inputs["questions"],
66
  images=model_inputs["images"],
 
67
  )
68
 
69
  def postprocess(self, model_outputs):
 
1
  """The ``decision`` pipeline for d3 models (``trust_remote_code=True``).
2
 
3
  ``pipeline("decision", model=repo, trust_remote_code=True)`` loads the model with ``AutoModel`` and answers
4
+ ``{"state": ..., "questions": {...}}`` requests, optionally with ``"images": [...]`` and ``"videos": [...]`` (any
5
+ number per request), or ``state=..., questions=..., images=..., videos=...`` keywords, or a list of requests, with
6
+ the model's
7
  ``system_one`` response. The model batches the questions of one request itself.
8
  """
9
 
 
11
 
12
  _UNSET = object()
13
  REQUEST_KEYS = {"state", "questions"}
14
+ OPTIONAL_KEYS = {"images", "videos"}
15
 
16
 
17
  class D3Pipeline(Pipeline):
 
29
  return {}, {}, {}
30
 
31
  def __call__(
32
+ self,
33
+ inputs=None,
34
+ *,
35
+ state=_UNSET,
36
+ questions=_UNSET,
37
+ images=_UNSET,
38
+ videos=_UNSET,
39
+ **kwargs,
40
  ):
41
+ if any(v is not _UNSET for v in (state, questions, images, videos)):
42
  if inputs is not None:
43
+ raise TypeError(
44
+ "Pass one request, or state=, questions=, images= and videos="
45
+ )
46
  inputs = {
47
  "state": None if state is _UNSET else state,
48
  "questions": None if questions is _UNSET else questions,
49
  }
50
  if images is not _UNSET:
51
  inputs["images"] = images
52
+ if videos is not _UNSET:
53
+ inputs["videos"] = videos
54
  if kwargs.get("batch_size") not in (None, 1):
55
  raise ValueError(
56
  "The decision pipeline runs one request at a time (batch_size=1)"
 
63
  ):
64
  raise ValueError(
65
  'A decision request is {"state": ..., "questions": {<id>: <question>, ...}} '
66
+ 'with optional "images": [...] and "videos": [...]'
67
  )
68
  return {
69
  "state": inputs["state"],
70
  "questions": inputs["questions"],
71
  "images": inputs.get("images"),
72
+ "videos": inputs.get("videos"),
73
  }
74
 
75
  def _forward(self, model_inputs):
 
77
  state=model_inputs["state"],
78
  questions=model_inputs["questions"],
79
  images=model_inputs["images"],
80
+ videos=model_inputs["videos"],
81
  )
82
 
83
  def postprocess(self, model_outputs):
requirements.txt CHANGED
@@ -1,4 +1,4 @@
1
- # d3 runtime. Validated with transformers 5.17.0 on ROCm (torch 2.13) and CUDA (RTX PRO 6000).
2
  torch>=2.8
3
  transformers==5.17.0
4
  accelerate>=1.10
@@ -11,7 +11,8 @@ einops
11
  # (install the torchvision build that matches torch; without it transformers falls back to a PIL resize).
12
  pillow>=10
13
  torchvision
14
- # Optional on CUDA: causal-conv1d (the convolution then runs in its CUDA kernel instead of torch conv1d).
 
15
  # d3_server.py only:
16
  # fastapi
17
  # uvicorn
 
1
+ # d3 runtime. Validated with transformers 5.17.0 on ROCm (torch 2.13).
2
  torch>=2.8
3
  transformers==5.17.0
4
  accelerate>=1.10
 
11
  # (install the torchvision build that matches torch; without it transformers falls back to a PIL resize).
12
  pillow>=10
13
  torchvision
14
+ # Video files: OpenCV decodes them with FFmpeg (frame arrays need no decoder).
15
+ opencv-python-headless
16
  # d3_server.py only:
17
  # fastapi
18
  # uvicorn