Python + Rust Without an HTTP API: Video and Image Processing with Subprocess IPC

 

Python + Rust Without an HTTP API: Video and Image Processing with Subprocess IPC

When two applications need to communicate, the first solution that usually comes to mind is an API.

For example:

Python Application
        │
        │ HTTP
        ▼
Rust API Server
        │
        ▼
Processing

This works very well when the applications need to communicate over a network or when they need to be independently deployed and scaled.

But what if both applications are running on the same machine?

Do we really need to create another HTTP server just so Python can communicate with Rust?

In this project, I experimented with another approach:

Python
   │
   │ stdin / stdout
   ▼
Rust Worker
   │
   ▼
FFmpeg / Image Processing

There is no HTTP API between Python and Rust.

Instead, Python starts the Rust executable as a child process and communicates with it using standard input and standard output.

For large files such as videos and images, the files remain on the shared filesystem. Only the instructions and results are exchanged through the process communication channel.


Architecture

The application consists of three main layers.

                         SAME MACHINE
┌──────────────────────────────────────────────────────────────┐
│                                                              │
│   Browser                                                    │
│      │                                                       │
│      │ HTTP                                                  │
│      ▼                                                       │
│   ┌──────────────────┐                                      │
│   │ Python / Flask   │                                      │
│   │ Main Application │                                      │
│   └────────┬─────────┘                                      │
│            │                                                 │
│            │ subprocess.Popen()                              │
│            │                                                 │
│            │ JSON Lines                                      │
│            │ stdin / stdout                                  │
│            ▼                                                 │
│   ┌──────────────────┐                                      │
│   │   Rust Worker    │                                      │
│   │                  │                                      │
│   │ video_convert    │                                      │
│   │ image_convert    │                                      │
│   └────────┬─────────┘                                      │
│            │                                                 │
│            ├──────────────► FFmpeg                           │
│            │                                                 │
│            └──────────────► Image processing                 │
│                                                              │
│        uploads/                    outputs/                  │
│                                                              │
└──────────────────────────────────────────────────────────────┘

There are actually multiple communication mechanisms involved.

Browser → Python

This is normal HTTP because the browser is communicating with the Flask web application.

Python → Rust

This does not use HTTP.

Python starts Rust as a subprocess and communicates through:

  • stdin

  • stdout

  • stderr

Python/Rust → Filesystem

The actual video and image files are stored on disk.

This is important because we don't want to send a large video through JSON or convert it to Base64 just to transfer it between the two processes.


How the Communication Works

The Rust worker is started once when the Python application starts.

Python uses subprocess.Popen():

process = subprocess.Popen(
    [rust_binary],
    stdin=subprocess.PIPE,
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    text=True,
    bufsize=1
)

This gives Python communication pipes connected to the Rust process.

Conceptually:

Python Process
      │
      ├──────── stdin ──────────► Rust Process
      │
      ├──────── stdout ◄──────── Rust Process
      │
      └──────── stderr ◄──────── Rust Process

The communication protocol is JSON Lines.

A request looks like:

{
    "id": "123",
    "operation": "video_convert",
    "input": "/app/uploads/input.mp4",
    "output": "/app/outputs/output.mp4"
}

Python writes this JSON followed by a newline.

Rust reads one line at a time.

Rust processes the request and sends a response:

{
    "id": "123",
    "success": true,
    "result": {
        "output": "/app/outputs/output.mp4"
    },
    "error": null
}

The request ID allows Python to associate the response with the original request.


1. Python Flask Application

The Flask application is responsible for:

  • receiving uploads

  • saving files

  • creating output paths

  • communicating with Rust

  • returning the result to the browser

The important part is that Flask does not perform the actual video or image conversion.

It delegates that work to Rust.

A simplified version of the application looks like this:

from pathlib import Path
import os
import uuid

from flask import Flask, jsonify, request, send_from_directory
from werkzeug.utils import secure_filename

from rust_worker import RustWorker


app = Flask(__name__)

BASE_DIR = Path(__file__).resolve().parent

UPLOAD_DIR = BASE_DIR / "uploads"
OUTPUT_DIR = BASE_DIR / "outputs"

UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)


worker = RustWorker()


@app.route("/video", methods=["POST"])
def video_convert():

    if "file" not in request.files:
        return jsonify({
            "success": False,
            "error": "No video file provided"
        }), 400

    file = request.files["file"]

    if not file.filename:
        return jsonify({
            "success": False,
            "error": "No filename provided"
        }), 400

    filename = secure_filename(file.filename)

    input_name = f"{uuid.uuid4()}_{filename}"
    output_name = f"{Path(filename).stem}_converted.mp4"

    input_path = UPLOAD_DIR / input_name
    output_path = OUTPUT_DIR / output_name

    try:
        # Save uploaded video
        file.save(input_path)

        # Ask Rust to perform the conversion
        result = worker.call({
            "operation": "video_convert",
            "input": str(input_path),
            "output": str(output_path)
        })

        if not result.get("success"):
            return jsonify(result), 500

        return jsonify({
            "success": True,
            "output": f"/downloads/{output_name}"
        })

    finally:
        # Input is no longer needed after conversion
        if input_path.exists():
            input_path.unlink()


@app.route("/image", methods=["POST"])
def image_convert():

    if "file" not in request.files:
        return jsonify({
            "success": False,
            "error": "No image file provided"
        }), 400

    file = request.files["file"]

    if not file.filename:
        return jsonify({
            "success": False,
            "error": "No filename provided"
        }), 400

    filename = secure_filename(file.filename)

    input_name = f"{uuid.uuid4()}_{filename}"
    output_name = f"{Path(filename).stem}_converted.webp"

    input_path = UPLOAD_DIR / input_name
    output_path = OUTPUT_DIR / output_name

    try:
        file.save(input_path)

        result = worker.call({
            "operation": "image_convert",
            "input": str(input_path),
            "output": str(output_path),
            "format": "webp"
        })

        if not result.get("success"):
            return jsonify(result), 500

        return jsonify({
            "success": True,
            "output": f"/downloads/{output_name}"
        })

    finally:
        if input_path.exists():
            input_path.unlink()


@app.route("/downloads/<filename>")
def download_file(filename):
    return send_from_directory(
        OUTPUT_DIR,
        filename,
        as_attachment=True
    )


if __name__ == "__main__":
    app.run(
        host="0.0.0.0",
        port=5000,
        debug=False,
        threaded=True
    )

The important thing here is that the Flask route only coordinates the operation.

For example:

result = worker.call({
    "operation": "video_convert",
    "input": str(input_path),
    "output": str(output_path)
})

This doesn't call a Rust function directly.

Instead, it sends a message to the Rust process.


2. Python Rust Worker

The RustWorker class is the bridge between the Flask application and the Rust executable.

Its responsibilities are:

  1. Start Rust.

  2. Keep Rust running.

  3. Send JSON requests.

  4. Read JSON responses.

  5. Match responses using request IDs.

  6. Handle stderr/logging.

  7. Prevent multiple Python threads from corrupting the communication stream.

Here is the important implementation:

import json
import subprocess
import threading
import uuid


class RustWorker:

    def __init__(self, binary_path, log_dir):

        self.binary_path = binary_path

        env = {
            **os.environ,
            "LOG_DIR": str(log_dir)
        }

        self.process = subprocess.Popen(
            [str(binary_path)],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            bufsize=1,
            env=env
        )

        self.lock = threading.Lock()

        self.stderr_thread = threading.Thread(
            target=self._read_stderr,
            daemon=True
        )

        self.stderr_thread.start()


    def _read_stderr(self):

        if not self.process.stderr:
            return

        for line in self.process.stderr:

            line = line.strip()

            if line:
                print(f"[RUST] {line}")


    def call(self, payload):

        with self.lock:

            if self.process.poll() is not None:
                raise RuntimeError(
                    "Rust worker is no longer running"
                )

            if not self.process.stdin:
                raise RuntimeError(
                    "Rust stdin is unavailable"
                )

            if not self.process.stdout:
                raise RuntimeError(
                    "Rust stdout is unavailable"
                )

            request_id = str(uuid.uuid4())

            payload["id"] = request_id

            message = json.dumps(payload)

            # Send JSON to Rust
            self.process.stdin.write(message + "\n")
            self.process.stdin.flush()

            # Wait for Rust response
            while True:

                line = self.process.stdout.readline()

                if not line:
                    raise RuntimeError(
                        "Rust worker closed stdout"
                    )

                line = line.strip()

                if not line:
                    continue

                try:
                    response = json.loads(line)

                except json.JSONDecodeError:
                    continue

                if response.get("id") != request_id:
                    continue

                return response


    def close(self):

        if self.process.stdin:
            self.process.stdin.close()

        if self.process.poll() is None:

            self.process.terminate()

            try:
                self.process.wait(timeout=3)

            except subprocess.TimeoutExpired:

                self.process.kill()
                self.process.wait()

The most important lines are:

self.process.stdin.write(message + "\n")
self.process.stdin.flush()

and:

line = self.process.stdout.readline()

These are the actual communication points.


What Happens During a Video Conversion?

Suppose the user uploads:

movie.mp4

The browser sends the file to Flask.

Browser
   │
   │ HTTP + video bytes
   ▼
Flask

Flask saves it:

uploads/
└── movie.mp4

Flask then creates a JSON request:

{
    "id": "abc123",
    "operation": "video_convert",
    "input": "/app/uploads/movie.mp4",
    "output": "/app/outputs/movie_converted.mp4"
}

Only this small JSON message is sent to Rust.

The video itself is not sent through stdin.

The filesystem is shared by both processes.


3. Rust Worker

The Rust worker continuously waits for requests.

The main structure is:

use std::io::{self, BufRead, Write};

fn main() {

    let stdin = io::stdin();
    let mut stdout = io::stdout();

    for line in stdin.lock().lines() {

        let line = match line {
            Ok(line) => line,
            Err(_) => break,
        };

        if line.trim().is_empty() {
            continue;
        }

        let request: Request =
            match serde_json::from_str(&line) {
                Ok(request) => request,

                Err(error) => {

                    let response = Response {
                        id: String::new(),
                        success: false,
                        result: None,
                        error: Some(error.to_string()),
                    };

                    let json =
                        serde_json::to_string(&response)
                            .unwrap();

                    writeln!(stdout, "{}", json).unwrap();
                    stdout.flush().unwrap();

                    continue;
                }
            };

        let response = handle(request);

        let serialized =
            serde_json::to_string(&response)
                .unwrap();

        writeln!(stdout, "{}", serialized)
            .unwrap();

        stdout.flush().unwrap();
    }
}

The important part is:

for line in stdin.lock().lines()

Rust continuously reads incoming requests.

Then:

serde_json::from_str(&line)

converts the JSON text into a Rust structure.

The request is then passed to:

handle(request)

which determines which operation should be performed.


Request Structure

The worker uses a request structure similar to:

use serde::{Deserialize, Serialize};
use serde_json::Value;


#[derive(Debug, Deserialize)]
struct Request {

    id: String,

    operation: String,

    a: Option<f64>,
    b: Option<f64>,
    operator: Option<String>,

    input: Option<String>,
    output: Option<String>,
    format: Option<String>,

    text: Option<String>,
    mode: Option<String>,

    items: Option<Vec<Value>>,
    json_str: Option<String>,
}

The response structure is:

#[derive(Debug, Serialize)]
struct Response {

    id: String,

    success: bool,

    result: Option<Value>,

    error: Option<String>,
}

This gives both sides a predictable communication format.


4. Rust Operation Dispatcher

The dispatcher decides which Rust function should handle the request.

fn handle(request: Request) -> Response {

    match request.operation.as_str() {

        "video_convert" =>
            video_convert(&request),

        "image_convert" =>
            image_convert(&request),

        "calculate" =>
            calculate(&request),

        "text_transform" =>
            text_transform(&request),

        "hash_text" =>
            hash_text(&request),

        "json_process" =>
            json_process(&request),

        "batch_calculate" =>
            batch_calculate(&request),

        _ => Response {
            id: request.id,
            success: false,
            result: None,
            error: Some(
                "Unknown operation".to_string()
            ),
        }
    }
}

For our two main examples, the important operations are:

video_convert
image_convert

5. Video Conversion in Rust

The Rust worker doesn't implement a video encoder itself.

Instead, it launches FFmpeg.

use std::process::Command;


fn video_convert(request: &Request) -> Response {

    let input = match &request.input {
        Some(value) => value,
        None => {
            return Response {
                id: request.id.clone(),
                success: false,
                result: None,
                error: Some(
                    "Missing input path".to_string()
                ),
            };
        }
    };

    let output = match &request.output {
        Some(value) => value,
        None => {
            return Response {
                id: request.id.clone(),
                success: false,
                result: None,
                error: Some(
                    "Missing output path".to_string()
                ),
            };
        }
    };


    let result = Command::new("ffmpeg")
        .args([
            "-y",
            "-i",
            input,
            "-c:v",
            "libx264",
            "-preset",
            "medium",
            "-crf",
            "23",
            "-c:a",
            "aac",
            "-movflags",
            "+faststart",
            output,
        ])
        .output();


    match result {

        Ok(result) if result.status.success() => {

            Response {
                id: request.id.clone(),
                success: true,

                result: Some(serde_json::json!({
                    "output": output
                })),

                error: None,
            }
        }


        Ok(result) => {

            let error =
                String::from_utf8_lossy(
                    &result.stderr
                );

            Response {
                id: request.id.clone(),
                success: false,
                result: None,
                error: Some(
                    error.to_string()
                ),
            }
        }


        Err(error) => {

            Response {
                id: request.id.clone(),
                success: false,
                result: None,
                error: Some(
                    error.to_string()
                ),
            }
        }
    }
}

The actual process chain is therefore:

Python
   │
   │ JSON request
   ▼
Rust Worker
   │
   │ Command::new("ffmpeg")
   ▼
FFmpeg
   │
   │ reads
   ▼
input.mp4
   │
   │ converts
   ▼
output.mp4

Rust is acting as the worker/orchestration layer while FFmpeg performs the actual video encoding.


6. Image Conversion in Rust

For image processing, the Rust worker can use the Rust image crate instead of launching another external application.

use image::ImageFormat;


fn image_convert(request: &Request) -> Response {

    let input = match &request.input {
        Some(value) => value,
        None => {
            return Response {
                id: request.id.clone(),
                success: false,
                result: None,
                error: Some(
                    "Missing input path".to_string()
                ),
            };
        }
    };


    let output = match &request.output {
        Some(value) => value,
        None => {
            return Response {
                id: request.id.clone(),
                success: false,
                result: None,
                error: Some(
                    "Missing output path".to_string()
                ),
            };
        }
    };


    let format = match request.format.as_deref() {

        Some("png") =>
            ImageFormat::Png,

        Some("jpeg") =>
            ImageFormat::Jpeg,

        Some("webp") =>
            ImageFormat::WebP,

        _ =>
            ImageFormat::WebP,
    };


    match image::open(input) {

        Ok(image) => {

            match image.save_with_format(
                output,
                format
            ) {

                Ok(_) => Response {

                    id: request.id.clone(),
                    success: true,

                    result: Some(
                        serde_json::json!({
                            "output": output
                        })
                    ),

                    error: None,
                },


                Err(error) => Response {

                    id: request.id.clone(),
                    success: false,
                    result: None,

                    error: Some(
                        error.to_string()
                    ),
                }
            }
        }


        Err(error) => Response {

            id: request.id.clone(),
            success: false,
            result: None,

            error: Some(
                error.to_string()
            ),
        }
    }
}

Here the process is slightly different.

There is no FFmpeg subprocess.

Instead:

Rust
 │
 │ image::open()
 ▼
Image
 │
 │ save_with_format()
 ▼
Output Image

Complete Video Request Flow

Putting everything together:

1. Browser uploads video
             │
             ▼
2. Flask receives HTTP request
             │
             ▼
3. Flask saves video to uploads/
             │
             ▼
4. Python creates JSON request
             │
             ▼
5. Python writes JSON to Rust stdin
             │
             ▼
6. Rust reads one line
             │
             ▼
7. Rust parses JSON
             │
             ▼
8. Rust sees "video_convert"
             │
             ▼
9. Rust launches FFmpeg
             │
             ▼
10. FFmpeg reads input file
             │
             ▼
11. FFmpeg creates output file
             │
             ▼
12. Rust creates JSON response
             │
             ▼
13. Rust writes response to stdout
             │
             ▼
14. Python reads stdout
             │
             ▼
15. Python checks request ID
             │
             ▼
16. Flask returns result to browser
             │
             ▼
17. Browser downloads output

What Data Is Actually Transferred?

This is one of the most important parts of the architecture.

The video itself is not transferred between Python and Rust through the pipe.

Instead, there are two different types of data.

Control data

Transferred through stdin/stdout:

{
    "operation": "video_convert",
    "input": "/uploads/input.mp4",
    "output": "/outputs/output.mp4",
    "id": "123"
}

This is small structured data.

Actual media data

Transferred through the filesystem:

uploads/input.mp4
        │
        ▼
      FFmpeg
        │
        ▼
outputs/output.mp4

This avoids putting large binary files into the JSON communication protocol.


Why Use stderr?

There is another important detail.

stdout is being used as the communication channel.

Therefore, Rust should not print normal logs to stdout.

For example, this would be dangerous:

println!("Starting conversion...");

because Python might receive:

Starting conversion...
{"id":"123","success":true}

That isn't valid JSON Lines anymore.

Instead, logs are written to stderr:

eprintln!("Starting conversion...");

So we have:

Rust
 │
 ├──── stdout ────► JSON responses
 │
 └──── stderr ────► Logs

Python can read stderr separately.

This keeps the protocol clean.


Why a Persistent Worker?

Another design decision is keeping Rust alive.

A simpler implementation could start Rust for every request:

Request
   ↓
Start Rust
   ↓
Process request
   ↓
Exit

But this means starting a new process every time.

Instead, this project starts Rust once:

Application starts
       │
       ▼
Rust worker starts
       │
       ▼
Rust stays alive
       │
       ├── request
       ├── response
       │
       ├── request
       ├── response
       │
       └── request
           response

This makes the Rust executable behave like a persistent local worker.


Why Is There a Lock?

Flask can handle multiple requests using multiple threads.

However, the current implementation has one Rust worker.

Therefore, multiple Python threads shouldn't simultaneously write to the same stdin pipe and read from the same stdout stream.

The worker uses a lock:

with self.lock:
    ...

So requests are currently serialized:

Request A
    │
    ▼
   LOCK
    │
    ▼
Python → Rust → Response
    │
    ▼
 UNLOCK
    │
    ▼
Request B

If the application needs higher concurrency, the architecture could be extended to use multiple Rust workers:

                Python
                   │
          ┌────────┼────────┐
          ▼        ▼        ▼
       Rust 1   Rust 2   Rust 3
       Worker   Worker   Worker

This would effectively create a local worker pool.


Advantages

This architecture has several advantages when the requirements fit it.

No HTTP layer between Python and Rust

Python doesn't need to connect to a Rust HTTP server.

Simple local communication

The operating system already provides stdin, stdout and stderr.

Language independent

Rust could theoretically be replaced with another executable as long as it follows the same protocol.

Process isolation

Rust runs as a separate process from Python.

Good for local workers

This can be useful for:

  • video processing

  • image processing

  • compilers

  • command-line tools

  • local AI inference

  • data processing

  • desktop applications

Large files remain outside the protocol

Instead of transferring binary media through JSON, the processes communicate using file paths.


Disadvantages

This approach also has limitations.

It is designed for local communication

If Rust needs to move to another machine, stdin/stdout is no longer the appropriate boundary.

You would need something such as:

HTTP
gRPC
TCP
message queue

depending on the application.

Worker lifecycle must be managed

The Python application needs to deal with:

  • worker crashes

  • worker restarts

  • shutdown

  • timeouts

  • invalid responses

The protocol is custom

You are responsible for defining and maintaining the JSON request/response format.

Current implementation serializes requests

One Rust worker processes one request at a time.

Higher concurrency requires multiple workers or a different architecture.

Scaling is more custom

An HTTP service already has a large ecosystem around load balancing, health checks, monitoring and scaling.

With a local process worker, some of these mechanisms need to be implemented by the application.


When Should You Use This?

This approach makes sense when:

Same machine
      +
Tightly coupled components
      +
One application controls the worker
      +
Local processing

For example:

Python
   │
   ├── Rust image processor
   ├── Rust video processor
   └── Rust computation worker

An HTTP API becomes more attractive when:

Different machines
        OR
Multiple independent clients
        OR
Independent deployment
        OR
Independent scaling
        OR
Public service boundary

Final Takeaway

The important lesson from this experiment isn't that APIs are unnecessary.

APIs are extremely useful when applications need a network-accessible and independently managed communication boundary.

The lesson is that HTTP is not the only way for two programs to communicate.

On the same machine, one process can start another process and communicate through operating-system mechanisms such as stdin/stdout.

In this project:

Browser
   │
   │ HTTP
   ▼
Python / Flask
   │
   │ JSON Lines
   │ stdin / stdout
   ▼
Rust Worker
   │
   ├── Image processing
   │
   └── FFmpeg
          │
          ▼
      Video output

The Python application handles the web layer and orchestration.

The Rust process handles the processing work.

The filesystem handles the large media files.

And stdin/stdout provides the communication channel between the two processes.

So the real question isn't:

“API or no API?”

It is:

“What is the appropriate communication mechanism for these two components, given where they run and how they need to scale?”

For a tightly coupled worker running on the same machine, process-based IPC can be a surprisingly simple and effective solution.

Post a Comment

0 Comments