Browse / github.com/storytold/craft-ai-integrations / patch
feat(ai-comfyui): map a start image for video.generate workflows
ai-comfyui: a start image for video.generate workflows (image-to-video) ### Problem Wan 2.2 TI2V and I2V graphs, and most image-to-video graphs, start from a `LoadImage` node. A `video.generate` workflow maps only a prompt field today: `Workflow::info` accepts `InputRole::Prompt`, and only `image.inpaint` accepts `Source`. These graphs can be imported, but every job reuses the image name saved in the graph, so anyone animating their own still has to edit the graph per job. ### Proposal A `video.generate` workflow may bind one `LoadImage` field. The provider sends the image through the path `image.inpaint` already uses (`read_role`, the byte check, `/upload/imag
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- heidrickla (imported from pull request #102, open)
- Base
1759a52660b138f8cae7ba848774a49b7572b0ab- Size
- +373 −28 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
9f1bf33b4afbfce61f6c211a
How it went
No other operator has reported using it yet.
Diff
raw · crates/ai-comfyui/src/library.rs crates/ai-comfyui/src/provider.rs crates/ai-comfyui/src/workflow.rs crates/ai-comfyui/src/workflow/video.rs crates/ai-comfyui/tests/video_start_image.rs docs/STATUS.md tools/mutation/mutation_check.py
@@ -57,8 +57,8 @@ pub struct WorkflowSpec { /// When absent, the only node with a plain string `text` input is used. #[serde(default, skip_serializing_if = "Option::is_none")] pub prompt: Option<Binding>,- /// Required for image.inpaint and image.segment; maps the uploaded source filename to a- /// LoadImage node.+ /// Required for image.inpaint and image.segment, optional for video.generate (its start image);+ /// maps the uploaded source filename to a LoadImage node. #[serde(default, skip_serializing_if = "Option::is_none")] pub source: Option<Binding>, /// Required for image.inpaint; maps the uploaded PNG filename to a LoadImageMask node.@@ -76,6 +76,11 @@ fn default_capability() -> String { impl WorkflowSpec { /// Validates everything and builds the checked [`Workflow`]. pub fn to_workflow(&self) -> Result<Workflow, AiError> {+ let id = if self.id.trim().is_empty() {+ "workflow"+ } else {+ self.id.trim()+ }; let binding = match &self.prompt { Some(p) => p.clone(), None => infer_binding(&self.graph)?,@@ -86,11 +91,7 @@ impl WorkflowSpec { }; if self.capability == "image.inpaint" { return Workflow::import_inpaint(- if self.id.trim().is_empty() {- "workflow"- } else {- self.id.trim()- },+ id, self.graph.clone(), binding, &output,@@ -107,11 +108,7 @@ impl WorkflowSpec { return Err(AiError::invalid("a selection workflow has no mask binding")); } return Workflow::import_segment(- if self.id.trim().is_empty() {- "workflow"- } else {- self.id.trim()- },+ id, self.graph.clone(), binding, &output,@@ -120,22 +117,24 @@ impl WorkflowSpec { .ok_or_else(|| AiError::invalid("selecting needs a source binding"))?, ); }+ if self.capability == "video.generate"+ && self.mask.is_none()+ && let Some(source) = &self.source+ {+ return Workflow::import_video_from_image(+ id,+ self.graph.clone(),+ binding,+ &output,+ source.clone(),+ );+ } if self.source.is_some() || self.mask.is_some() { return Err(AiError::invalid(- "source and mask bindings need image.inpaint, and a source binding image.segment",+ "source and mask bindings need image.inpaint; a source binding alone needs image.segment or video.generate", )); }- Workflow::import(- if self.id.trim().is_empty() {- "workflow"- } else {- self.id.trim()- },- &self.capability,- self.graph.clone(),- binding,- &output,- )+ Workflow::import(id, &self.capability, self.graph.clone(), binding, &output) } /// Parses a stored file. `id` overrides the spec's `id` when the file has none@@ -287,6 +287,11 @@ impl Provider for ComfyProvider { let source = self.upload(InputRole::Source, &source)?; let mask = self.upload(InputRole::Mask, &mask)?; workflow.render_with_uploads(request, &source, &mask)?+ } else if workflow.capability().as_str() == "video.generate" && workflow.needs_uploads() {+ // A video workflow's start image, read and checked before it is sent.+ let source = self.read_role(request, InputRole::Source)?;+ let source = self.upload(InputRole::Source, &source)?;+ workflow.render_with_source(request, &source)? } else { workflow.render(request)? };@@ -7,6 +7,8 @@ use craft_ai_core::{AiError, ErrorKind, ModelId, Request}; use serde::{Deserialize, Serialize}; use serde_json::Value; +mod video;+ const MAX_GRAPH_BYTES: usize = 1024 * 1024; #[derive(Clone, Debug, Serialize, Deserialize)]@@ -332,7 +334,9 @@ impl Workflow { .accepting(InputRole::Mask, 1) .requiring(InputRole::Mask); }- if self.capability.as_str() == "image.segment" {+ if self.capability.as_str() == "image.segment"+ || (self.capability.as_str() == "video.generate" && self.source.is_some())+ { spec = spec .accepting(InputRole::Source, 1) .requiring(InputRole::Source);@@ -413,9 +417,10 @@ impl Workflow { self.render_mapped(request, &[]) } - /// Substitute the server-returned filename of the uploaded source image (segment workflows).+ /// Substitute the server-returned filename of the uploaded source image (segment workflows and+ /// video workflows with a start image). pub fn render_with_source(&self, request: &Request, source: &str) -> Result<Value, AiError> {- if self.capability.as_str() != "image.segment" {+ if !matches!(self.capability.as_str(), "image.segment" | "video.generate") { return Err(AiError::unsupported( "this workflow has no source-only mapping", ));new file mode 100644@@ -0,0 +1,30 @@+//! Video graphs that start from an image: a Wan 2.2 TI2V or I2V graph loads its first frame through+//! a `LoadImage` node, and the request's source image replaces the file saved in the graph.+use super::{Binding, Workflow};+use craft_ai_core::AiError;+use serde_json::Value;++impl Workflow {+ /// Import a `video.generate` graph with its start image mapped: a text prompt and one source+ /// image in, one video out. The source must map to a ComfyUI image loader and feed the output,+ /// like a selection source, and every request must then carry the image.+ pub fn import_video_from_image(+ id: &str,+ graph: Value,+ prompt: Binding,+ output_node: &str,+ source: Binding,+ ) -> Result<Self, AiError> {+ let mut workflow = Self::import(id, "video.generate", graph, prompt, output_node)?;+ workflow.check_media_binding(&source, "source")?;+ for (binding, role) in [(&workflow.prompt, "prompt"), (&source, "source")] {+ if !workflow.feeds_output(&binding.node) {+ return Err(AiError::invalid(format!(+ "{role} node is not connected to the output"+ )));+ }+ }+ workflow.source = Some(source);+ Ok(workflow)+ }+}new file mode 100644@@ -0,0 +1,296 @@+//! p4: a `video.generate` workflow that binds its start image (a Wan 2.2 TI2V or I2V graph loads its+//! first frame through `LoadImage`). The image is required once bound, read and checked by its bytes+//! like an inpaint source, and uploaded before the graph is queued. Scripted localhost servers and+//! temporary folders only.+#[path = "../../ai-openai/tests/support/mod.rs"]+mod support;++use craft_ai_comfyui::{ComfyConfig, ComfyProvider, Workflow, WorkflowLibrary, WorkflowSpec};+use craft_ai_core::capability::CapabilityId;+use craft_ai_core::model::InputRole;+use craft_ai_core::payload::Payload;+use craft_ai_core::{ErrorKind, ModelId, Provider, Request};+use serde_json::{Value, json};+use support::{Reply, Server};++const PNG: &[u8] = b"\x89PNG\r\n\x1a\nimage body";+const JPEG: &[u8] = b"\xff\xd8\xff\xe0\x00\x10JFIF image body";++fn graph() -> Value {+ json!({+ "1":{"class_type":"CLIPTextEncode","inputs":{"text":"old"}},+ "2":{"class_type":"LoadImage","inputs":{"image":"saved-start.png"}},+ "3":{"class_type":"Wan22ImageToVideoLatent","inputs":{"start_image":["2",0],"width":1280,"height":704,"length":121}},+ "4":{"class_type":"KSampler","inputs":{"positive":["1",0],"latent_image":["3",0]}},+ "5":{"class_type":"SaveVideo","inputs":{"video":["4",0],"filename_prefix":"video/wan"}}+ })+}++/// A stored `video.generate` spec over [`graph`], with `extra` fields added or replaced.+fn spec(extra: Value) -> Vec<u8> {+ let mut v = json!({+ "id":"wan-ti2v", "capability":"video.generate", "graph":graph(),+ "prompt":{"node":"1","class_type":"CLIPTextEncode","input":"text"},+ "output_node":"5"+ });+ v.as_object_mut()+ .unwrap()+ .extend(extra.as_object().unwrap().clone());+ serde_json::to_vec(&v).unwrap()+}++fn start_image() -> Value {+ json!({"source":{"node":"2","class_type":"LoadImage","input":"image"}})+}++fn workflow(extra: Value) -> Workflow {+ WorkflowSpec::parse(&spec(extra), None)+ .unwrap()+ .to_workflow()+ .unwrap()+}++/// A scripted ComfyUI with the video nodes installed that accepts uploads to the scratch area.+fn video_server() -> Server {+ Server::start(|r| {+ match r.path.as_str() {+ "/system_stats" => Reply::Json(200, "{}".into()),+ "/object_info" => Reply::Json(+ 200,+ json!({+ "CLIPTextEncode":{"input":{"required":{"text":["STRING",{}]}}},+ "LoadImage":{"input":{"required":{"image":[["saved-start.png"],{"image_upload":true}]}}},+ "Wan22ImageToVideoLatent":{"input":{"required":{"width":["INT",{}],"height":["INT",{}],"length":["INT",{}]},"optional":{"start_image":["IMAGE",{}]}}},+ "KSampler":{"input":{"required":{"positive":["CONDITIONING",{}],"latent_image":["LATENT",{}]}}},+ "SaveVideo":{"input":{"required":{"video":["VIDEO",{}],"filename_prefix":["STRING",{}]}}}+ })+ .to_string(),+ ),+ "/upload/image" => Reply::Json(+ 200,+ json!({"name":"returned-start.png","subfolder":"","type":"temp"}).to_string(),+ ),+ "/prompt" => Reply::Json(200, r#"{"prompt_id":"video-job"}"#.into()),+ _ => Reply::Json(404, "{}".into()),+ }+ })+}++fn provider(server: &Server, workflow: Workflow) -> ComfyProvider {+ ComfyProvider::new(+ ComfyConfig::new(server.url.trim_end_matches("/v1"))+ .with_max_upload_bytes(4096)+ .with_workflow(workflow),+ )+ .unwrap()+}++fn video(image: Option<Payload>) -> Request {+ let request = Request::new(CapabilityId::parse("video.generate").unwrap())+ .with_input(InputRole::Prompt, Payload::text("a kite over the sea"));+ match image {+ Some(image) => request.with_input(InputRole::Source, image),+ None => request,+ }+}++fn submit(+ p: &ComfyProvider,+ request: &Request,+) -> Result<craft_ai_core::JobId, craft_ai_core::AiError> {+ p.submit(&ModelId::new("wan-ti2v").unwrap(), request)+}++fn file_handle(path: &std::path::Path, media: &str) -> Payload {+ let uri = url::Url::from_file_path(path).unwrap().to_string();+ Payload::handle(&uri, media, None).unwrap()+}++fn write(dir: &tempfile::TempDir, name: &str, bytes: &[u8]) -> std::path::PathBuf {+ let path = dir.path().join(name);+ std::fs::write(&path, bytes).unwrap();+ path+}++fn has(body: &[u8], needle: &[u8]) -> bool {+ body.windows(needle.len()).any(|w| w == needle)+}++#[test]+fn the_start_image_is_uploaded_before_the_graph_is_queued() {+ let dir = tempfile::tempdir().unwrap();+ let jpeg = write(&dir, "start.jpg", JPEG);+ let cases = [+ (+ "an inline png",+ Payload::inline("image/png", PNG.to_vec()).unwrap(),+ PNG,+ "image/png",+ ),+ (+ "a local jpeg file",+ file_handle(&jpeg, "image/jpeg"),+ JPEG,+ "image/jpeg",+ ),+ ];+ for (why, image, bytes, media) in cases {+ let server = video_server();+ let p = provider(&server, workflow(start_image()));+ submit(&p, &video(Some(image))).unwrap();+ let recorded = server.recorded();+ let sent: Vec<&str> = recorded+ .iter()+ .map(|r| r.path.as_str())+ .filter(|path| matches!(*path, "/upload/image" | "/prompt"))+ .collect();+ assert_eq!(sent, ["/upload/image", "/prompt"], "{why}");+ let upload = recorded.iter().find(|r| r.path == "/upload/image").unwrap();+ assert!(has(&upload.body, bytes), "{why}: the image bytes were sent");+ assert!(has(&upload.body, media.as_bytes()), "{why}");+ assert!(has(&upload.body, b"craft-source-"), "{why}");+ let graph = &recorded+ .iter()+ .find(|r| r.path == "/prompt")+ .unwrap()+ .body_json()["prompt"];+ assert_eq!(+ graph["2"]["inputs"]["image"], "returned-start.png [temp]",+ "{why}"+ );+ assert_eq!(graph["1"]["inputs"]["text"], "a kite over the sea", "{why}");+ }+}++#[test]+fn a_video_workflow_with_a_start_image_refuses_a_request_without_one() {+ let server = video_server();+ let p = provider(&server, workflow(start_image()));+ let models = p.models().unwrap();+ let spec = &models[0].capabilities[0];+ assert_eq!(spec.capability.as_str(), "video.generate");+ assert_eq!(spec.accepts.get(&InputRole::Source), Some(&1));+ assert!(spec.requires.contains(&InputRole::Source));+ let before = server.count();+ let error = submit(&p, &video(None)).unwrap_err();+ assert_eq!(error.kind, ErrorKind::InvalidRequest, "{error:?}");+ assert_eq!(server.count(), before, "nothing reaches ComfyUI");+}++#[test]+fn a_start_image_that_is_not_the_image_it_claims_is_never_uploaded() {+ let dir = tempfile::tempdir().unwrap();+ let secrets = write(&dir, "notes.png", b"the password is hunter2");+ let wrong_kind = write(&dir, "photo.png", JPEG);+ let empty = write(&dir, "empty.png", b"");+ let mut big = PNG.to_vec();+ big.resize(5000, 7);+ let big = write(&dir, "big.png", &big);+ let cases: Vec<(&str, Payload)> = vec![+ (+ "a text file declared as png",+ file_handle(&secrets, "image/png"),+ ),+ (+ "jpeg bytes declared as png",+ file_handle(&wrong_kind, "image/png"),+ ),+ ("an empty file", file_handle(&empty, "image/png")),+ ("a file over the limit", file_handle(&big, "image/png")),+ (+ "inline text declared as png",+ Payload::inline("image/png", b"the password is hunter2".to_vec()).unwrap(),+ ),+ (+ "inline html declared as jpeg",+ Payload::inline("image/jpeg", b"<html></html>".to_vec()).unwrap(),+ ),+ (+ "a format ComfyUI would receive mislabelled",+ Payload::inline("image/webp", b"RIFF\0\0\0\0WEBPVP8 ".to_vec()).unwrap(),+ ),+ ];+ for (why, bad) in cases {+ let server = video_server();+ let p = provider(&server, workflow(start_image()));+ let error = submit(&p, &video(Some(bad))).unwrap_err();+ assert!(+ matches!(+ error.kind,+ ErrorKind::InvalidRequest | ErrorKind::Unsupported+ ),+ "{why}: {error:?}"+ );+ assert!(+ !format!("{error:?}").contains("hunter2"),+ "{why}: the file content is not repeated"+ );+ let recorded = server.recorded();+ assert!(+ !recorded.iter().any(|r| r.path == "/upload/image"),+ "{why}: nothing is sent"+ );+ assert!(!recorded.iter().any(|r| r.path == "/prompt"), "{why}");+ }+}++#[test]+fn a_video_workflow_without_the_binding_stays_prompt_only() {+ let server = video_server();+ let p = provider(&server, workflow(json!({})));+ let models = p.models().unwrap();+ let spec = &models[0].capabilities[0];+ assert_eq!(spec.accepts.get(&InputRole::Source), None);+ assert_eq!(spec.requires, [InputRole::Prompt]);+ submit(&p, &video(None)).unwrap();+ let recorded = server.recorded();+ assert!(!recorded.iter().any(|r| r.path == "/upload/image"));+ let graph = &recorded+ .iter()+ .find(|r| r.path == "/prompt")+ .unwrap()+ .body_json()["prompt"];+ assert_eq!(graph["2"]["inputs"]["image"], "saved-start.png");+ let png = Payload::inline("image/png", PNG.to_vec()).unwrap();+ let error = submit(&p, &video(Some(png))).unwrap_err();+ assert_eq!(error.kind, ErrorKind::Unsupported, "{error:?}");+}++#[test]+fn a_stored_video_workflow_keeps_its_start_image() {+ let dir = tempfile::tempdir().unwrap();+ let lib = WorkflowLibrary::at(dir.path().join("workflows")).unwrap();+ lib.save(&WorkflowSpec::parse(&spec(start_image()), None).unwrap())+ .unwrap();+ let loaded = lib.load().unwrap();+ assert_eq!(loaded.len(), 1);+ assert_eq!(loaded[0].capability().as_str(), "video.generate");+ assert!(loaded[0].needs_uploads());+ assert!(+ loaded[0].info().capabilities[0]+ .requires+ .contains(&InputRole::Source)+ );+}++#[test]+fn a_start_image_binding_must_be_a_connected_image_loader() {+ let parse = |extra: Value| WorkflowSpec::parse(&spec(extra), None);+ let mut with_mask = start_image().as_object().unwrap().clone();+ with_mask.insert(+ "mask".into(),+ json!({"node":"2","class_type":"LoadImage","input":"image"}),+ );+ assert!(parse(with_mask.into()).is_err(), "a mask binding");+ let not_a_loader = json!({"source":{"node":"1","class_type":"CLIPTextEncode","input":"text"}});+ assert!(parse(not_a_loader).is_err(), "a text field");+ let mut stray = graph();+ stray.as_object_mut().unwrap().insert(+ "6".into(),+ json!({"class_type":"LoadImage","inputs":{"image":"unused.png"}}),+ );+ let mut unconnected = start_image();+ unconnected["source"]["node"] = json!("6");+ unconnected["graph"] = stray;+ assert!(parse(unconnected).is_err(), "a loader that feeds nothing");+}@@ -15,7 +15,7 @@ No real hosted service, download host or repository was contacted by these tests | Gemini images on Vertex express mode | `craft-ai-vertex` | An express-mode key is not a full-project Vertex credential and has no free key probe. | | ArtCraft Omni API key | `craft-ai-artcraft` | Prompt-only image and video. Account or subscription access has no adapter: no official programmatic route is documented. An API key is not evidence that a subscription covers the requests. | | Hosted media copies | `craft-ai-media` | https only (loopback only for a connection marked local), every redirect re-checked, private addresses refused, size bounded, file type taken from the bytes. A failed copy keeps the original link with a notice. A busy host is retried with a bounded, doubling pause. Not built: retries for provider API calls, expiry tracking, resume, and an input-side upload cache. |-| Generic ComfyUI workflows | `craft-ai-comfyui` | Maps a prompt field; `image.inpaint` needs mapped `LoadImage` and `LoadImageMask` fields. A workflow is offered as a model only after `/object_info` confirms its nodes and checkpoint names; submission repeats the check. Output stays inline (32 MiB) unless the app gives a directory (1 GiB, local `file:` handle, app owns cleanup). Targeted cancel is off by default because an older server may treat it as a global interrupt. Imported image.segment workflows map a description and source to a checked, source-sized grayscale mask plus the shared result. Empty masks mean no match; source uploads are remembered for selection jobs. This is not calibrated absence detection: the example Florence graph can find a box for an absent subject. No input file handles or general parameter mapping yet. |+| Generic ComfyUI workflows | `craft-ai-comfyui` | Maps a prompt field; `image.inpaint` needs mapped `LoadImage` and `LoadImageMask` fields. A `video.generate` workflow may map one `LoadImage` field as its start image, which every request must then carry. A workflow is offered as a model only after `/object_info` confirms its nodes and checkpoint names; submission repeats the check. Output stays inline (32 MiB) unless the app gives a directory (1 GiB, local `file:` handle, app owns cleanup). Targeted cancel is off by default because an older server may treat it as a global interrupt. Imported image.segment workflows map a description and source to a checked, source-sized grayscale mask plus the shared result. Empty masks mean no match; source uploads are remembered for selection jobs. This is not calibrated absence detection: the example Florence graph can find a box for an absent subject. No input file handles or general parameter mapping yet. | | Built-in engines | `craft-ai-engine`, `craft-ai-engine-candle`, `craft-ai-engine-rten`, `craft-ai-engine-burn` | Contract, registry and provider are tested with a fake runner and the shared conformance suite. SAM 3 on Candle (point and text prompts, processor, or Metal on macOS) ran on a real photograph and passes the same suite on Meta's checkpoint; the live tests skip without `CRAFT_SAM3_WEIGHTS`. It reads the Hugging Face checkpoint layout with tokenizer files, not the ComfyUI file. Pictures go in as PNG or JPEG (a JPEG's EXIF turn is applied). No Nvidia (CUDA) build, and the launcher does not download the checkpoint yet. SAM 2.1 on rten (processor only) reads the publisher's two `.onnx` files, pinned by size and SHA-256, with click and box prompts; a description is refused in plain words. Pictures are bounded by side and pixel count. With the real files in release on one Windows machine: a mask at the picture's size, and a hover through the ordinary job path with a median of about 35 ms after a first look of about 0.7 s (one picture, not a benchmark). SAM 2.1 is the first recipe for Select subject, so the plain built-in offer is SAM 2.1 on rten. No macOS or Linux run, and no app has run it through the shared binding yet. SAM 2.1 on Burn (`craft-ai-engine-burn`, cargo feature `burn`, optional, after rten in the recipe) reads weights that `craft-ai-burn-setup` prepares once on the computer; on one Windows machine with a graphics card its mask matched rten's (best-mask IoU 1.0, one click and one box, one picture, not a benchmark). Given the downloaded `encoder.onnx`, the runner prepares the saved copy itself on first load (in `burn-prepared/` beside the model folder; no progress and no Cancel during that load). No app has run it through the shared binding yet, and there has been no live run on macOS or Linux. See `docs/BURN-ENGINE.md`. | | First-run setup flow | `craft-ai-client`, `craft-ai-egui`, `craft-ai-features` | One start button on `SetupSession` (checks complete files on disk, otherwise downloads; `blocker` says why it does nothing), a licence tick only where a file's licence needs one, `FolderDesk::ensure_default` for the suggested folder, `ToolDesk::adopt` for checked files only, a try-it sample that clicks for built-in selection models and fails a selection that chose nothing, status and picker that agree when a model's file has gone, and engine advice that does not call a measured-quick built-in engine (rten, one machine) slow. Scripted tests and a fake provider only: no real download, no model run through it, no app has mounted it, no macOS or Linux run. | | Declarative adapter | `craft-ai-declarative` | Request/response and queue flows from a data file; prompt-only inputs. No launcher screen to add a spec yet. |@@ -261,6 +261,15 @@ UPLOAD_ORDER_NEW = """let source = self.read_role(request, InputRole::Source)?; MASK_JPEG_OLD = """|| (role == InputRole::Source && h.media_type.as_str() == "image/jpeg") => {}""" MASK_JPEG_NEW = """|| h.media_type.as_str() == "image/jpeg" => {}"""+START_IMAGE_OLD = """let source = self.read_role(request, InputRole::Source)?;+ let source = self.upload(InputRole::Source, &source)?;"""+# A video workflow's start image read straight from the request: no byte check, no size limit.+START_IMAGE_NEW = """let source = match &request.inputs.iter().find(|i| i.role == InputRole::Source).ok_or_else(|| AiError::invalid("missing source input"))?.payload {+ craft_ai_core::Payload::Inline { media_type, data } => crate::input::Image { media_type: media_type.as_str().to_owned(), data: data.clone() },+ craft_ai_core::Payload::Handle(h) => crate::input::Image { media_type: h.media_type.as_str().to_owned(), data: std::fs::read(url::Url::parse(&h.uri).map_err(|_| AiError::invalid("bad address"))?.to_file_path().map_err(|_| AiError::invalid("bad address"))?).map_err(|_| AiError::invalid("unreadable"))? },+ _ => return Err(AiError::invalid("an image is needed")),+ };+ let source = self.upload(InputRole::Source, &source)?;""" # p4: media going to and coming from ComfyUI is judged by its bytes (crates/ai-comfyui). P4 = [ ('output-not-checked', CF_OUT, 'if sniff::media_type(head).map(|(media, _)| media) == Some(expected) {', 'if true {', CF, 'media_checks', 'an_output_that_is'),@@ -281,6 +290,7 @@ P4 = [ ('input-file-read-unbounded', CF_IN, 'reader.take(max.saturating_add(1))', 'reader.take(u64::MAX)', CF, 'lib', 'a_reader_is_read_no_further'), ('input-sent-before-all-checked', CF_PROV, UPLOAD_ORDER_OLD, UPLOAD_ORDER_NEW, CF, 'media_checks', 'never_uploaded'), ('input-jpeg-mask-accepted', CF_FLOW, MASK_JPEG_OLD, MASK_JPEG_NEW, CF, 'media_checks', 'mask_must_be_a_png'),+ ('start-image-sent-unchecked', CF_PROV, START_IMAGE_OLD, START_IMAGE_NEW, CF, 'video_start_image', 'never_uploaded'), ] MEDIA = C + 'ai-media/src/store.rs'