PreFlight Observe
PreFlight Observe turns an image into a strict JSON description — and refuses to judge it
- image
- observations_json
- raw_response
Here's the design decision that makes the PreFlight set interesting: the vision model is not asked whether an image is acceptable. It is only asked what is visually present. Garment, setting, framing, pose, exposure, whether there's text burned into the frame. No rating, no verdict, no "this might violate guidelines."
That split is deliberate, and it's the difference between a tool you can debug and a vibe you have to trust. When the output is wrong, you need to know whether the model misread the image or the rules were too strict. Keep observation and judgment in separate nodes and you can actually tell.
What it runs
No model widget. PreFlight Observe always runs Qwen3.5-9B at INT8 ConvRot, downloaded on first use from beycanai/Qwen-LM - the same runtime and the same file as Qwen LM, sharing the same single LM model slot. Peak VRAM is around 10.5 GB measured on a 3090 for one image or a 4-frame batch. If you were hoping for a 3B, no, and the reason is that misreading the garment is the failure this whole pipeline is trying to avoid.
Decoding is pure greedy - most likely token every step, no penalties, seed 42, at most 300 new tokens. Same input, same object out, every run. That's non-negotiable if you're going to accumulate predictions and later check them against what the platforms actually did.
Inputs and outputs
Three inputs. image is a single image or a video's frame batch. max_frames (default 6, range 1–16) samples frames evenly from a video batch and sends them all in one call, so the model can infer motion from the sequence. keep_model_loaded defaults on - the model stays resident for the next run, and it's the same slot Qwen LM uses, so a Qwen LM run with a different file will displace it and vice versa.
Two outputs. observations_json is the observation - or {"error": "..."} if anything went wrong. raw_response is the model's answer exactly as it came back, including both attempts when it had to retry, for when you want to see what it was actually saying.
The JSON is strict, and the keys are worth seeing because they define the whole vocabulary the rules engine can reason about:
{
"subject_appears_under_18": false,
"garment": "bikini",
"setting": "beach_pool",
"framing": "full_body",
"pose": "neutral",
"see_through_or_wet": false,
"exposure": "none",
"nudity_or_sexual_act": false,
"motion_flags": "none",
"visible_text": "",
"confidence": "high"
}
Each enum carries real definitions in the prompt - full_body is the default for an ordinary lifestyle shot even when the chest sits mid-frame, and chest_focus only fires when the frame is deliberately composed around a body region with the face cropped or pushed to the edge. subject_appears_under_18 is explicitly false unless there are concrete indicators; a youthful-looking adult is not a minor. That kind of disambiguation is the difference between a sensor and a coin flip.
Each valid observation also carries a meta stamp - schema version, prompt version, model name, precision, and how many frames were analysed. Which brings us to the good part: because observations are stored separately from verdicts and stamped, you can re-judge old content with updated rules without running the model again.
Failure behaviour
This is the bit most LLM nodes get wrong. Anything that goes wrong - missing model file, failed download, GPU trouble, an empty batch - comes back as {"error": "..."} on the string output with the traceback in the console. The graph does not explode. Downstream, Report turns that into UNKNOWN for that run, which is a verdict you can see and act on.
If the model's answer isn't parseable as JSON at all, it gets exactly one retry with an added "Return ONLY the JSON object, nothing else." A second failure is the error object. The first {...} in the answer is read regardless of surrounding prose or fences. And values outside the schema get replaced with cautious defaults - a missing exposure reads as moderate, a missing pose as mildly_suggestive, confidence as low - never with the permissive end. The high-stakes flags are only ever set by a positive observation. Fail-closed, not fail-open.
Install
cd ComfyUI/custom_nodes
git clone https://github.com/0xBeycan/ComfyUI-BCNodes
pip install -r ComfyUI-BCNodes/requirements.txt
Restart. The node needs no third-party Python - the requirements file's opencv-python, postfx and caption-audit back the mask, PostFx and Caption Audit nodes.
The model file is Qwen3.5-9B_int8_convrot.safetensors, looked for in any Qwen-LM folder then anywhere under models/text_encoders, and downloaded into models/Qwen-LM/ if it isn't there. If you already have it for Qwen LM, this node shares it - no second download.
Getting burned
The first run is a multi-gigabyte download that reports in the console every 10% and never on the node. People queue, see nothing, and assume it's hung. Watch the terminal.
10.5 GB peak VRAM means it doesn't fit alongside a loaded video model on a 16 GB card. Keep keep_model_loaded off if you're interleaving this with sampling, and accept the reload cost.
INT8 needs a cu130 torch to hit the fast kernels; on cu12x ComfyUI disables that backend and you get the slow fallback.
Greedy determinism is a property, not a guarantee of correctness. The same misread of a garment will reproduce forever. That's the point - but it means a systematic misreading looks perfectly stable and confident in your logs. Which is exactly why confidence is in the JSON and low confidence deliberately widens the verdict ranges downstream.
And be clear about what this is. The prompt says it doesn't judge, and it doesn't. But it is a model's description of your image, and it's feeding a rules engine that was written by a person guessing at platform policy. Useful second opinion before you publish. Not an approval gate, and nothing here has any privileged knowledge of Instagram's actual thresholds.
Inputs (3)
| Name | Type | Default | Description |
|---|---|---|---|
| image | IMAGE | A single image or a video frame batch. | |
| max_frames | INT | 61–16 | For a video: how many frames to sample evenly from the batch. All of them go to the model in one call. |
| keep_model_loaded | BOOLEAN | true | On: the model stays loaded for the next run (one LM model is held at a time, shared with Qwen LM). Off: it is unloaded when the run ends. |
Outputs (2)
| Name | Type | Description |
|---|---|---|
| observations_json | STRING | — |
| raw_response | STRING | — |