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:
Start Rust.
Keep Rust running.
Send JSON requests.
Read JSON responses.
Match responses using request IDs.
Handle stderr/logging.
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.
0 Comments