animations
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { createWriteStream } from 'node:fs';
|
||||
import { access, mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { extname, resolve } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
|
||||
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.webm', '.m4v']);
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return new Promise((resolveRun, reject) => {
|
||||
const child = spawn(command, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk) => { stdout = (stdout + chunk).slice(-16000); });
|
||||
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-32000); });
|
||||
child.once('error', reject);
|
||||
child.once('close', (code) => {
|
||||
if (code === 0) resolveRun({ stdout: stdout.trim(), stderr: stderr.trim() });
|
||||
else reject(new Error((stderr || stdout || `${command} exited with ${code}`).trim()));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function freeMocapBridge({ projectRoot }) {
|
||||
const bundledPython = resolve(projectRoot, '.freemocap-venv/bin/python');
|
||||
const worker = resolve(projectRoot, 'tools/freemocap_worker.py');
|
||||
const python = process.env.TILT_FREEMOCAP_PYTHON || bundledPython;
|
||||
|
||||
async function workerStatus() {
|
||||
try {
|
||||
await access(python);
|
||||
const result = await run(python, ['-c', 'import freemocap; print(freemocap.__version__)']);
|
||||
const version = result.stdout.split('\n').at(-1)?.replace(/\x1b\[[0-9;]*m/g, '') || 'installed';
|
||||
return { available: true, version };
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
message: `FreeMoCap worker is not installed (${error.message.split('\n').at(-1)})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'tilt-freemocap-bridge',
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/api/freemocap/status', async (_request, response) => {
|
||||
const status = await workerStatus();
|
||||
response.statusCode = status.available ? 200 : 503;
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify(status));
|
||||
});
|
||||
|
||||
server.middlewares.use('/api/freemocap/process', async (request, response) => {
|
||||
if (request.method !== 'POST') {
|
||||
response.statusCode = 405;
|
||||
response.end('POST a video file to this endpoint');
|
||||
return;
|
||||
}
|
||||
const status = await workerStatus();
|
||||
if (!status.available) {
|
||||
response.statusCode = 503;
|
||||
response.end(`${status.message}. Run npm run freemocap:setup.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(request.url || '/', 'http://localhost');
|
||||
const requestedName = url.searchParams.get('filename') || 'reference.mp4';
|
||||
const requestedExtension = extname(requestedName).toLowerCase();
|
||||
const extension = VIDEO_EXTENSIONS.has(requestedExtension) ? requestedExtension : '.mp4';
|
||||
const fps = Math.max(1, Math.min(60, Number(url.searchParams.get('fps')) || 30));
|
||||
const jobRoot = await mkdtemp(resolve(tmpdir(), 'tilt-freemocap-'));
|
||||
const input = resolve(jobRoot, `upload${extension}`);
|
||||
const recording = resolve(jobRoot, 'recording');
|
||||
const output = resolve(jobRoot, 'result.csv');
|
||||
const metadata = resolve(jobRoot, 'result.json');
|
||||
try {
|
||||
await pipeline(request, createWriteStream(input));
|
||||
server.config.logger.info(`FreeMoCap processing ${requestedName}…`);
|
||||
await run(python, [
|
||||
worker,
|
||||
'--input', input,
|
||||
'--recording', recording,
|
||||
'--output', output,
|
||||
'--metadata', metadata,
|
||||
'--fps', String(fps),
|
||||
], { cwd: projectRoot });
|
||||
const csv = await readFile(output);
|
||||
const resultMetadata = JSON.parse(await readFile(metadata, 'utf8'));
|
||||
response.statusCode = 200;
|
||||
response.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
response.setHeader('X-Tilt-Mocap-Backend', `FreeMoCap ${status.version}`);
|
||||
response.setHeader('X-Tilt-Source-Fps', String(resultMetadata.fps || fps));
|
||||
response.end(csv);
|
||||
} catch (error) {
|
||||
server.config.logger.error(error.stack || error.message);
|
||||
response.statusCode = 500;
|
||||
response.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
response.end(error.message);
|
||||
} finally {
|
||||
await rm(jobRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Run FreeMoCap's stable headless pipeline for one uploaded reference video."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--input", required=True, type=Path)
|
||||
parser.add_argument("--recording", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument("--metadata", required=True, type=Path)
|
||||
parser.add_argument("--fps", required=True, type=float)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def prepare_mp4(source: Path, destination: Path) -> None:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
if source.suffix.lower() == ".mp4":
|
||||
shutil.copyfile(source, destination)
|
||||
return
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y", "-i", str(source), "-an",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", str(destination),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
synchronized = args.recording / "synchronized_videos"
|
||||
prepared_video = synchronized / "camera_0.mp4"
|
||||
prepare_mp4(args.input, prepared_video)
|
||||
|
||||
# FreeMoCap 1.8.x exposes this stable headless entry point. Blender and the
|
||||
# notebook are deliberately disabled: Tilt only needs its filtered body XYZ.
|
||||
from freemocap.core_processes.process_motion_capture_videos.process_recording_headless import (
|
||||
process_recording_headless,
|
||||
)
|
||||
from freemocap.data_layer.recording_models.post_processing_parameter_models import (
|
||||
ProcessingParameterModel,
|
||||
)
|
||||
import cv2
|
||||
|
||||
capture = cv2.VideoCapture(str(prepared_video))
|
||||
detected_fps = float(capture.get(cv2.CAP_PROP_FPS))
|
||||
capture.release()
|
||||
source_fps = detected_fps if detected_fps > 0 else args.fps
|
||||
|
||||
parameters = ProcessingParameterModel()
|
||||
parameters.post_processing_parameters_model.framerate = source_fps
|
||||
parameters.post_processing_parameters_model.butterworth_filter_parameters.sampling_rate = source_fps
|
||||
# Preserve MediaPipe's monocular depth estimate; the default flattened mode
|
||||
# throws that axis away before FreeMoCap's interpolation and rigid-bone pass.
|
||||
parameters.anipose_triangulate_3d_parameters_model.flatten_single_camera_data = False
|
||||
process_recording_headless(
|
||||
recording_path=args.recording,
|
||||
recording_processing_parameter_model=parameters,
|
||||
run_blender=False,
|
||||
make_jupyter_notebook=False,
|
||||
use_tqdm=False,
|
||||
)
|
||||
|
||||
result = args.recording / f"{args.recording.name}_by_frame.csv"
|
||||
if not result.is_file():
|
||||
matches = sorted(args.recording.glob("*_by_frame.csv"))
|
||||
if not matches:
|
||||
raise FileNotFoundError("FreeMoCap completed without producing a by-frame CSV")
|
||||
result = matches[0]
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(result, args.output)
|
||||
args.metadata.write_text(json.dumps({"fps": source_fps}), encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
uv venv --python 3.12 .freemocap-venv
|
||||
uv pip install --python .freemocap-venv/bin/python "freemocap==1.8.2"
|
||||
.freemocap-venv/bin/python -c "import freemocap; print('FreeMoCap', freemocap.__version__, 'ready')"
|
||||
Reference in New Issue
Block a user