Replace batch DAG system with streaming architecture
- Remove legacy_tasks.py, hybrid_state.py, render.py - Remove old task modules (analyze, execute, execute_sexp, orchestrate) - Add streaming interpreter from test repo - Add sexp_effects with primitives and video effects - Add streaming Celery task with CID-based asset resolution - Support both CID and friendly name references for assets - Add .dockerignore to prevent local clones from conflicting Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
595
streaming/compositor.py
Normal file
595
streaming/compositor.py
Normal file
@@ -0,0 +1,595 @@
|
||||
"""
|
||||
Streaming video compositor.
|
||||
|
||||
Main entry point for the streaming pipeline. Combines:
|
||||
- Multiple video sources (with looping)
|
||||
- Per-source effect chains
|
||||
- Layer compositing
|
||||
- Optional live audio analysis
|
||||
- Output to display/file/stream
|
||||
"""
|
||||
|
||||
import time
|
||||
import sys
|
||||
import numpy as np
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
from pathlib import Path
|
||||
|
||||
from .sources import Source, VideoSource
|
||||
from .backends import Backend, NumpyBackend, get_backend
|
||||
from .output import Output, DisplayOutput, FileOutput, MultiOutput
|
||||
|
||||
|
||||
class StreamingCompositor:
|
||||
"""
|
||||
Real-time streaming video compositor.
|
||||
|
||||
Reads frames from multiple sources, applies effects, composites layers,
|
||||
and outputs the result - all frame-by-frame without intermediate files.
|
||||
|
||||
Example:
|
||||
compositor = StreamingCompositor(
|
||||
sources=["video1.mp4", "video2.mp4"],
|
||||
effects_per_source=[
|
||||
[{"effect": "rotate", "angle": 45}],
|
||||
[{"effect": "zoom", "amount": 1.5}],
|
||||
],
|
||||
compositor_config={"mode": "alpha", "weights": [0.5, 0.5]},
|
||||
)
|
||||
compositor.run(output="preview", duration=60)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sources: List[Union[str, Source]],
|
||||
effects_per_source: List[List[Dict]] = None,
|
||||
compositor_config: Dict = None,
|
||||
analysis_data: Dict = None,
|
||||
backend: str = "numpy",
|
||||
recipe_dir: Path = None,
|
||||
fps: float = 30,
|
||||
audio_source: str = None,
|
||||
):
|
||||
"""
|
||||
Initialize the streaming compositor.
|
||||
|
||||
Args:
|
||||
sources: List of video paths or Source objects
|
||||
effects_per_source: List of effect chains, one per source
|
||||
compositor_config: How to blend layers (mode, weights)
|
||||
analysis_data: Pre-computed analysis data for bindings
|
||||
backend: "numpy" or "glsl"
|
||||
recipe_dir: Directory for resolving relative effect paths
|
||||
fps: Output frame rate
|
||||
audio_source: Path to audio file for streaming analysis
|
||||
"""
|
||||
self.fps = fps
|
||||
self.recipe_dir = recipe_dir or Path(".")
|
||||
self.analysis_data = analysis_data or {}
|
||||
|
||||
# Initialize streaming audio analyzer if audio source provided
|
||||
self._audio_analyzer = None
|
||||
self._audio_source = audio_source
|
||||
if audio_source:
|
||||
from .audio import StreamingAudioAnalyzer
|
||||
self._audio_analyzer = StreamingAudioAnalyzer(audio_source)
|
||||
print(f"Streaming audio: {audio_source}", file=sys.stderr)
|
||||
|
||||
# Initialize sources
|
||||
self.sources: List[Source] = []
|
||||
for src in sources:
|
||||
if isinstance(src, Source):
|
||||
self.sources.append(src)
|
||||
elif isinstance(src, (str, Path)):
|
||||
self.sources.append(VideoSource(str(src), target_fps=fps))
|
||||
else:
|
||||
raise ValueError(f"Unknown source type: {type(src)}")
|
||||
|
||||
# Effect chains (default: no effects)
|
||||
self.effects_per_source = effects_per_source or [[] for _ in self.sources]
|
||||
if len(self.effects_per_source) != len(self.sources):
|
||||
raise ValueError(
|
||||
f"effects_per_source length ({len(self.effects_per_source)}) "
|
||||
f"must match sources length ({len(self.sources)})"
|
||||
)
|
||||
|
||||
# Compositor config (default: equal blend)
|
||||
self.compositor_config = compositor_config or {
|
||||
"mode": "alpha",
|
||||
"weights": [1.0 / len(self.sources)] * len(self.sources),
|
||||
}
|
||||
|
||||
# Initialize backend
|
||||
self.backend: Backend = get_backend(
|
||||
backend,
|
||||
recipe_dir=self.recipe_dir,
|
||||
)
|
||||
|
||||
# Load effects
|
||||
self._load_effects()
|
||||
|
||||
def _load_effects(self):
|
||||
"""Pre-load all effect definitions."""
|
||||
for effects in self.effects_per_source:
|
||||
for effect_config in effects:
|
||||
effect_path = effect_config.get("effect_path")
|
||||
if effect_path:
|
||||
full_path = self.recipe_dir / effect_path
|
||||
if full_path.exists():
|
||||
self.backend.load_effect(full_path)
|
||||
|
||||
def _create_output(
|
||||
self,
|
||||
output: Union[str, Output],
|
||||
size: tuple,
|
||||
) -> Output:
|
||||
"""Create output target from string or Output object."""
|
||||
if isinstance(output, Output):
|
||||
return output
|
||||
|
||||
if output == "preview":
|
||||
return DisplayOutput("Streaming Preview", size,
|
||||
audio_source=self._audio_source, fps=self.fps)
|
||||
elif output == "null":
|
||||
from .output import NullOutput
|
||||
return NullOutput()
|
||||
elif isinstance(output, str):
|
||||
return FileOutput(output, size, fps=self.fps, audio_source=self._audio_source)
|
||||
else:
|
||||
raise ValueError(f"Unknown output type: {output}")
|
||||
|
||||
def run(
|
||||
self,
|
||||
output: Union[str, Output] = "preview",
|
||||
duration: float = None,
|
||||
audio_analyzer=None,
|
||||
show_fps: bool = True,
|
||||
recipe_executor=None,
|
||||
):
|
||||
"""
|
||||
Run the streaming compositor.
|
||||
|
||||
Args:
|
||||
output: Output target - "preview", filename, or Output object
|
||||
duration: Duration in seconds (None = run until quit)
|
||||
audio_analyzer: Optional AudioAnalyzer for live audio reactivity
|
||||
show_fps: Show FPS counter in console
|
||||
recipe_executor: Optional StreamingRecipeExecutor for full recipe logic
|
||||
"""
|
||||
# Determine output size from first source
|
||||
output_size = self.sources[0].size
|
||||
|
||||
# Create output
|
||||
out = self._create_output(output, output_size)
|
||||
|
||||
# Determine duration
|
||||
if duration is None:
|
||||
# Run until stopped (or min source duration if not looping)
|
||||
duration = min(s.duration for s in self.sources)
|
||||
if duration == float('inf'):
|
||||
duration = 3600 # 1 hour max for live sources
|
||||
|
||||
total_frames = int(duration * self.fps)
|
||||
frame_time = 1.0 / self.fps
|
||||
|
||||
print(f"Streaming: {len(self.sources)} sources -> {output}", file=sys.stderr)
|
||||
print(f"Duration: {duration:.1f}s, {total_frames} frames @ {self.fps}fps", file=sys.stderr)
|
||||
print(f"Output size: {output_size[0]}x{output_size[1]}", file=sys.stderr)
|
||||
print(f"Press 'q' to quit (if preview)", file=sys.stderr)
|
||||
|
||||
# Frame loop
|
||||
start_time = time.time()
|
||||
frame_count = 0
|
||||
fps_update_interval = 30 # Update FPS display every N frames
|
||||
last_fps_time = start_time
|
||||
last_fps_count = 0
|
||||
|
||||
try:
|
||||
for frame_num in range(total_frames):
|
||||
if not out.is_open:
|
||||
print(f"\nOutput closed at frame {frame_num}", file=sys.stderr)
|
||||
break
|
||||
|
||||
t = frame_num * frame_time
|
||||
|
||||
try:
|
||||
# Update analysis data from streaming audio (file-based)
|
||||
energy = 0.0
|
||||
is_beat = False
|
||||
if self._audio_analyzer:
|
||||
self._update_from_audio(self._audio_analyzer, t)
|
||||
energy = self.analysis_data.get("live_energy", {}).get("values", [0])[0]
|
||||
is_beat = self.analysis_data.get("live_beat", {}).get("values", [0])[0] > 0.5
|
||||
elif audio_analyzer:
|
||||
self._update_from_audio(audio_analyzer, t)
|
||||
energy = self.analysis_data.get("live_energy", {}).get("values", [0])[0]
|
||||
is_beat = self.analysis_data.get("live_beat", {}).get("values", [0])[0] > 0.5
|
||||
|
||||
# Read frames from all sources
|
||||
frames = [src.read_frame(t) for src in self.sources]
|
||||
|
||||
# Process through recipe executor if provided
|
||||
if recipe_executor:
|
||||
result = self._process_with_executor(
|
||||
frames, recipe_executor, energy, is_beat, t
|
||||
)
|
||||
else:
|
||||
# Simple backend processing
|
||||
result = self.backend.process_frame(
|
||||
frames,
|
||||
self.effects_per_source,
|
||||
self.compositor_config,
|
||||
t,
|
||||
self.analysis_data,
|
||||
)
|
||||
|
||||
# Output
|
||||
out.write(result, t)
|
||||
frame_count += 1
|
||||
|
||||
# FPS display
|
||||
if show_fps and frame_count % fps_update_interval == 0:
|
||||
now = time.time()
|
||||
elapsed = now - last_fps_time
|
||||
if elapsed > 0:
|
||||
current_fps = (frame_count - last_fps_count) / elapsed
|
||||
progress = frame_num / total_frames * 100
|
||||
print(
|
||||
f"\r {progress:5.1f}% | {current_fps:5.1f} fps | "
|
||||
f"frame {frame_num}/{total_frames}",
|
||||
end="", file=sys.stderr
|
||||
)
|
||||
last_fps_time = now
|
||||
last_fps_count = frame_count
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nError at frame {frame_num}, t={t:.1f}s: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
break
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted", file=sys.stderr)
|
||||
finally:
|
||||
out.close()
|
||||
for src in self.sources:
|
||||
if hasattr(src, 'close'):
|
||||
src.close()
|
||||
|
||||
# Final stats
|
||||
elapsed = time.time() - start_time
|
||||
avg_fps = frame_count / elapsed if elapsed > 0 else 0
|
||||
print(f"\nCompleted: {frame_count} frames in {elapsed:.1f}s ({avg_fps:.1f} fps avg)", file=sys.stderr)
|
||||
|
||||
def _process_with_executor(
|
||||
self,
|
||||
frames: List[np.ndarray],
|
||||
executor,
|
||||
energy: float,
|
||||
is_beat: bool,
|
||||
t: float,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Process frames using the recipe executor for full pipeline.
|
||||
|
||||
Implements:
|
||||
1. process-pair: two clips per source with effects, blended
|
||||
2. cycle-crossfade: dynamic composition with zoom and weights
|
||||
3. Final effects: whole-spin, ripple
|
||||
"""
|
||||
import cv2
|
||||
|
||||
# Target size from first source
|
||||
target_h, target_w = frames[0].shape[:2]
|
||||
|
||||
# Resize all frames to target size (letterbox to preserve aspect ratio)
|
||||
resized_frames = []
|
||||
for frame in frames:
|
||||
fh, fw = frame.shape[:2]
|
||||
if (fh, fw) != (target_h, target_w):
|
||||
# Calculate scale to fit while preserving aspect ratio
|
||||
scale = min(target_w / fw, target_h / fh)
|
||||
new_w, new_h = int(fw * scale), int(fh * scale)
|
||||
resized = cv2.resize(frame, (new_w, new_h))
|
||||
# Center on black canvas
|
||||
canvas = np.zeros((target_h, target_w, 3), dtype=np.uint8)
|
||||
x_off = (target_w - new_w) // 2
|
||||
y_off = (target_h - new_h) // 2
|
||||
canvas[y_off:y_off+new_h, x_off:x_off+new_w] = resized
|
||||
resized_frames.append(canvas)
|
||||
else:
|
||||
resized_frames.append(frame)
|
||||
frames = resized_frames
|
||||
|
||||
# Update executor state
|
||||
executor.on_frame(energy, is_beat, t)
|
||||
|
||||
# Get weights to know which sources are active
|
||||
weights = executor.get_cycle_weights()
|
||||
|
||||
# Process each source as a "pair" (clip A and B with different effects)
|
||||
processed_pairs = []
|
||||
|
||||
for i, frame in enumerate(frames):
|
||||
# Skip sources with zero weight (but still need placeholder)
|
||||
if i < len(weights) and weights[i] < 0.001:
|
||||
processed_pairs.append(None)
|
||||
continue
|
||||
# Get effect params for clip A and B
|
||||
params_a = executor.get_effect_params(i, "a", energy)
|
||||
params_b = executor.get_effect_params(i, "b", energy)
|
||||
pair_params = executor.get_pair_params(i)
|
||||
|
||||
# Process clip A
|
||||
clip_a = self._apply_clip_effects(frame.copy(), params_a, t)
|
||||
|
||||
# Process clip B
|
||||
clip_b = self._apply_clip_effects(frame.copy(), params_b, t)
|
||||
|
||||
# Blend A and B using pair_mix opacity
|
||||
opacity = pair_params["blend_opacity"]
|
||||
blended = cv2.addWeighted(
|
||||
clip_a, 1 - opacity,
|
||||
clip_b, opacity,
|
||||
0
|
||||
)
|
||||
|
||||
# Apply pair rotation
|
||||
h, w = blended.shape[:2]
|
||||
center = (w // 2, h // 2)
|
||||
angle = pair_params["pair_rotation"]
|
||||
if abs(angle) > 0.5:
|
||||
matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
|
||||
blended = cv2.warpAffine(blended, matrix, (w, h))
|
||||
|
||||
processed_pairs.append(blended)
|
||||
|
||||
# Cycle-crossfade composition
|
||||
weights = executor.get_cycle_weights()
|
||||
zooms = executor.get_cycle_zooms()
|
||||
|
||||
# Apply zoom per pair and composite
|
||||
h, w = target_h, target_w
|
||||
result = np.zeros((h, w, 3), dtype=np.float32)
|
||||
|
||||
for idx, (pair, weight, zoom) in enumerate(zip(processed_pairs, weights, zooms)):
|
||||
# Skip zero-weight sources
|
||||
if pair is None or weight < 0.001:
|
||||
continue
|
||||
|
||||
orig_shape = pair.shape
|
||||
|
||||
# Apply zoom
|
||||
if zoom > 1.01:
|
||||
# Zoom in: crop center and resize up
|
||||
new_w, new_h = int(w / zoom), int(h / zoom)
|
||||
if new_w > 0 and new_h > 0:
|
||||
x1, y1 = (w - new_w) // 2, (h - new_h) // 2
|
||||
cropped = pair[y1:y1+new_h, x1:x1+new_w]
|
||||
pair = cv2.resize(cropped, (w, h))
|
||||
elif zoom < 0.99:
|
||||
# Zoom out: shrink video and center on black
|
||||
scaled_w, scaled_h = int(w * zoom), int(h * zoom)
|
||||
if scaled_w > 0 and scaled_h > 0:
|
||||
shrunk = cv2.resize(pair, (scaled_w, scaled_h))
|
||||
canvas = np.zeros((h, w, 3), dtype=np.uint8)
|
||||
x_off, y_off = (w - scaled_w) // 2, (h - scaled_h) // 2
|
||||
canvas[y_off:y_off+scaled_h, x_off:x_off+scaled_w] = shrunk
|
||||
pair = canvas.copy()
|
||||
|
||||
# Draw colored border - size indicates zoom level
|
||||
border_colors = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 0)]
|
||||
color = border_colors[idx % 4]
|
||||
thickness = max(3, int(10 * weight)) # Thicker border = higher weight
|
||||
pair = np.ascontiguousarray(pair)
|
||||
pair[:thickness, :] = color
|
||||
pair[-thickness:, :] = color
|
||||
pair[:, :thickness] = color
|
||||
pair[:, -thickness:] = color
|
||||
|
||||
result += pair.astype(np.float32) * weight
|
||||
|
||||
result = np.clip(result, 0, 255).astype(np.uint8)
|
||||
|
||||
# Apply final effects (whole-spin, ripple)
|
||||
final_params = executor.get_final_effects(energy)
|
||||
|
||||
# Whole spin
|
||||
spin_angle = final_params["whole_spin_angle"]
|
||||
if abs(spin_angle) > 0.5:
|
||||
center = (w // 2, h // 2)
|
||||
matrix = cv2.getRotationMatrix2D(center, spin_angle, 1.0)
|
||||
result = cv2.warpAffine(result, matrix, (w, h))
|
||||
|
||||
# Ripple effect
|
||||
amp = final_params["ripple_amplitude"]
|
||||
if amp > 1:
|
||||
result = self._apply_ripple(result, amp,
|
||||
final_params["ripple_cx"],
|
||||
final_params["ripple_cy"],
|
||||
t)
|
||||
|
||||
return result
|
||||
|
||||
def _apply_clip_effects(self, frame: np.ndarray, params: dict, t: float) -> np.ndarray:
|
||||
"""Apply per-clip effects: rotate, zoom, invert, hue_shift, ascii."""
|
||||
import cv2
|
||||
|
||||
h, w = frame.shape[:2]
|
||||
|
||||
# Rotate
|
||||
angle = params["rotate_angle"]
|
||||
if abs(angle) > 0.5:
|
||||
center = (w // 2, h // 2)
|
||||
matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
|
||||
frame = cv2.warpAffine(frame, matrix, (w, h))
|
||||
|
||||
# Zoom
|
||||
zoom = params["zoom_amount"]
|
||||
if abs(zoom - 1.0) > 0.01:
|
||||
new_w, new_h = int(w / zoom), int(h / zoom)
|
||||
if new_w > 0 and new_h > 0:
|
||||
x1, y1 = (w - new_w) // 2, (h - new_h) // 2
|
||||
x1, y1 = max(0, x1), max(0, y1)
|
||||
x2, y2 = min(w, x1 + new_w), min(h, y1 + new_h)
|
||||
if x2 > x1 and y2 > y1:
|
||||
cropped = frame[y1:y2, x1:x2]
|
||||
frame = cv2.resize(cropped, (w, h))
|
||||
|
||||
# Invert
|
||||
if params["invert_amount"] > 0.5:
|
||||
frame = 255 - frame
|
||||
|
||||
# Hue shift
|
||||
hue_deg = params["hue_degrees"]
|
||||
if abs(hue_deg) > 1:
|
||||
hsv = cv2.cvtColor(frame, cv2.COLOR_RGB2HSV)
|
||||
hsv[:, :, 0] = (hsv[:, :, 0].astype(np.int32) + int(hue_deg / 2)) % 180
|
||||
frame = cv2.cvtColor(hsv, cv2.COLOR_HSV2RGB)
|
||||
|
||||
# ASCII art
|
||||
if params["ascii_mix"] > 0.5:
|
||||
char_size = max(4, int(params["ascii_char_size"]))
|
||||
frame = self._apply_ascii(frame, char_size)
|
||||
|
||||
return frame
|
||||
|
||||
def _apply_ascii(self, frame: np.ndarray, char_size: int) -> np.ndarray:
|
||||
"""Apply ASCII art effect."""
|
||||
import cv2
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
h, w = frame.shape[:2]
|
||||
chars = " .:-=+*#%@"
|
||||
|
||||
# Get font
|
||||
try:
|
||||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", char_size)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# Sample cells using area interpolation (fast block average)
|
||||
rows = h // char_size
|
||||
cols = w // char_size
|
||||
if rows < 1 or cols < 1:
|
||||
return frame
|
||||
|
||||
# Crop to exact grid and downsample
|
||||
cropped = frame[:rows * char_size, :cols * char_size]
|
||||
cell_colors = cv2.resize(cropped, (cols, rows), interpolation=cv2.INTER_AREA)
|
||||
|
||||
# Compute luminance
|
||||
luminances = (0.299 * cell_colors[:, :, 0] +
|
||||
0.587 * cell_colors[:, :, 1] +
|
||||
0.114 * cell_colors[:, :, 2]) / 255.0
|
||||
|
||||
# Create output image
|
||||
out_h = rows * char_size
|
||||
out_w = cols * char_size
|
||||
output = Image.new('RGB', (out_w, out_h), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(output)
|
||||
|
||||
# Draw characters
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
lum = luminances[r, c]
|
||||
color = tuple(cell_colors[r, c])
|
||||
|
||||
# Map luminance to character
|
||||
idx = int(lum * (len(chars) - 1))
|
||||
char = chars[idx]
|
||||
|
||||
# Draw character
|
||||
x = c * char_size
|
||||
y = r * char_size
|
||||
draw.text((x, y), char, fill=color, font=font)
|
||||
|
||||
# Convert back to numpy and resize to original
|
||||
result = np.array(output)
|
||||
if result.shape[:2] != (h, w):
|
||||
result = cv2.resize(result, (w, h), interpolation=cv2.INTER_LINEAR)
|
||||
|
||||
return result
|
||||
|
||||
def _apply_ripple(self, frame: np.ndarray, amplitude: float,
|
||||
cx: float, cy: float, t: float = 0) -> np.ndarray:
|
||||
"""Apply ripple distortion effect."""
|
||||
import cv2
|
||||
|
||||
h, w = frame.shape[:2]
|
||||
center_x, center_y = cx * w, cy * h
|
||||
max_dim = max(w, h)
|
||||
|
||||
# Create coordinate grids
|
||||
y_coords, x_coords = np.mgrid[0:h, 0:w].astype(np.float32)
|
||||
|
||||
# Distance from center
|
||||
dx = x_coords - center_x
|
||||
dy = y_coords - center_y
|
||||
dist = np.sqrt(dx*dx + dy*dy)
|
||||
|
||||
# Ripple parameters (matching recipe: frequency=8, decay=2, speed=5)
|
||||
freq = 8
|
||||
decay = 2
|
||||
speed = 5
|
||||
phase = t * speed * 2 * np.pi
|
||||
|
||||
# Ripple displacement (matching original formula)
|
||||
ripple = np.sin(2 * np.pi * freq * dist / max_dim + phase) * amplitude
|
||||
|
||||
# Apply decay
|
||||
if decay > 0:
|
||||
ripple = ripple * np.exp(-dist * decay / max_dim)
|
||||
|
||||
# Displace along radial direction
|
||||
with np.errstate(divide='ignore', invalid='ignore'):
|
||||
norm_dx = np.where(dist > 0, dx / dist, 0)
|
||||
norm_dy = np.where(dist > 0, dy / dist, 0)
|
||||
|
||||
map_x = (x_coords + ripple * norm_dx).astype(np.float32)
|
||||
map_y = (y_coords + ripple * norm_dy).astype(np.float32)
|
||||
|
||||
return cv2.remap(frame, map_x, map_y, cv2.INTER_LINEAR,
|
||||
borderMode=cv2.BORDER_REFLECT)
|
||||
|
||||
def _update_from_audio(self, analyzer, t: float):
|
||||
"""Update analysis data from audio analyzer (streaming or live)."""
|
||||
# Set time for file-based streaming analyzers
|
||||
if hasattr(analyzer, 'set_time'):
|
||||
analyzer.set_time(t)
|
||||
|
||||
# Get current audio features
|
||||
energy = analyzer.get_energy() if hasattr(analyzer, 'get_energy') else 0
|
||||
beat = analyzer.get_beat() if hasattr(analyzer, 'get_beat') else False
|
||||
|
||||
# Update analysis tracks - these can be referenced by effect bindings
|
||||
self.analysis_data["live_energy"] = {
|
||||
"times": [t],
|
||||
"values": [energy],
|
||||
"duration": float('inf'),
|
||||
}
|
||||
self.analysis_data["live_beat"] = {
|
||||
"times": [t],
|
||||
"values": [1.0 if beat else 0.0],
|
||||
"duration": float('inf'),
|
||||
}
|
||||
|
||||
|
||||
def quick_preview(
|
||||
sources: List[str],
|
||||
effects: List[List[Dict]] = None,
|
||||
duration: float = 10,
|
||||
fps: float = 30,
|
||||
):
|
||||
"""
|
||||
Quick preview helper - show sources with optional effects.
|
||||
|
||||
Example:
|
||||
quick_preview(["video1.mp4", "video2.mp4"], duration=30)
|
||||
"""
|
||||
compositor = StreamingCompositor(
|
||||
sources=sources,
|
||||
effects_per_source=effects,
|
||||
fps=fps,
|
||||
)
|
||||
compositor.run(output="preview", duration=duration)
|
||||
Reference in New Issue
Block a user