Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 26 additions & 13 deletions local/interp.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
"""Frame interpolation via RIFE (rife-ncnn-vulkan, Apple GPU through MoltenVK) — 2x a video's frame rate.
"""Frame interpolation via RIFE (rife-ncnn-vulkan, Apple GPU through MoltenVK) — Nx a video's frame rate.

Used to render Wan clips at HALF the frames (faster denoise + VAE) then interpolate back up to the target
fps with real motion. RIFE at 2x (small frame gap) is near-ground-truth on cinematic pans/walking. MIT
licensed (wrapper + weights). Falls back to nothing here — the engine handles the ffmpeg fallback.
Used to render Wan clips at a fraction of the timeline frames (faster denoise + VAE) then interpolate back
up with real motion. RIFE v4.x is timestep-conditioned, so an Nx pass synthesizes N-1 evenly spaced frames
per gap in ONE pass — near-ground-truth on cinematic pans/walking at these small gaps. MIT licensed
(wrapper + weights). Falls back to nothing here — the engine handles the ffmpeg fallback.

run_interpolate(req): {video, out, [model], [gpuid], [out_fps]} -> {ok, frames_in, frames_out}. Doubles the
frame count (a midpoint between each pair + the last frame repeated once = 2n frames), muxed at out_fps
(pass 2x the source fps) so the clip keeps exactly its source duration with real in-between motion.
run_interpolate(req): {video, out, [factor], [model], [gpuid], [out_fps]} -> {ok, frames_in, frames_out}.
Multiplies the frame count by `factor` (default 2, clamped to 2..4): N-1 midpoints between each pair, plus
the last frame repeated factor-1 times = n*factor frames, muxed at out_fps (pass factor x the source fps)
so the clip keeps exactly its source duration with real in-between motion.

Why the caller may ask for 3x rather than 2x: the engine's timeline is 24fps and the 14B saves at 16fps.
2x lands on 32fps, which conforms to 24 by dropping 1 frame in 4 at uneven phase (visible cadence break);
3x lands on 48fps, an exact 2:1 decimation to 24. See rifeFactor() in src/engine/localVideo.ts.
"""
import os
import shutil
Expand Down Expand Up @@ -48,6 +54,9 @@ def run_interpolate(req: dict) -> dict:
out = req["out"]
model = req.get("model") or _default_model()
gpuid = int(req.get("gpuid", 0))
# Clamped: 1 would be a no-op pass, and past 4 the synthesized-to-real frame ratio stops buying
# smoothness while the per-clip cost keeps growing.
factor = max(2, min(4, int(req.get("factor", 2) or 2)))

work = video + "_interp"
# A previous FAILED/killed run leaves stale frames here; ffmpeg's image2 demuxer would happily append
Expand All @@ -67,19 +76,23 @@ def run_interpolate(req: dict) -> dict:
# No caching: the sidecar runs each job in a fresh subprocess (MoltenVK isolation), so nothing persists.
rife = Rife(gpuid=gpuid, model=model, scale=2, width=w, height=h)

# interleave: f0, mid(f0,f1), f1, mid(f1,f2), f2, ..., fN, fN -> 2n frames. The final frame repeats
# once so the clip keeps EXACTLY its source duration at 2x fps (2n-1 frames would run 1/(2*fps) short
# per clip — enough to break the chained-total >= scene-window invariant and drift the timeline).
# interleave: f0, [factor-1 midpoints], f1, [factor-1 midpoints], f2, ..., fN, then fN repeated
# factor-1 times -> exactly n*factor frames. The tail repeat keeps the clip at EXACTLY its source
# duration at factor x fps (n*factor-(factor-1) frames would run short per clip — enough to break the
# chained-total >= scene-window invariant and drift the timeline).
oi = 0
prev = first
Image.open(os.path.join(fin, frames[0])).convert("RGB").save(os.path.join(fout, f"o_{oi:05d}.png")); oi += 1
for i in range(1, n):
cur = Image.open(os.path.join(fin, frames[i])).convert("RGB")
mid = rife.process(prev, cur, timestep=0.5)
mid.save(os.path.join(fout, f"o_{oi:05d}.png")); oi += 1
for j in range(1, factor):
# RIFE v4.x takes an arbitrary timestep, so 3x is one pass with two midpoints per gap — not a
# recursive 2x-of-2x (which would interpolate already-synthesized frames and compound error).
rife.process(prev, cur, timestep=j / factor).save(os.path.join(fout, f"o_{oi:05d}.png")); oi += 1
cur.save(os.path.join(fout, f"o_{oi:05d}.png")); oi += 1
prev = cur
prev.save(os.path.join(fout, f"o_{oi:05d}.png")); oi += 1
for _ in range(factor - 1):
prev.save(os.path.join(fout, f"o_{oi:05d}.png")); oi += 1

fps = float(req.get("out_fps", 24))
subprocess.run([
Expand Down
32 changes: 25 additions & 7 deletions src/engine/backends/local/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// per native sub-clip, chained from the previous clip's real last frame; the clip is TRIMMED to the frame
// grid (never stretched). See DUAL_BACKEND_PLAN.md §1.7.
import { env, envInt, envBool } from '../../config';
import { lastFrame, concatClips } from '../../ffmpeg';
import { lastFrame, concatClips, matchLevels } from '../../ffmpeg';
import * as P from '../../stages';
import * as S from '../../storage';
import { genVideoLocal, localNativeFps, localMaxFrames } from '../../localVideo';
Expand All @@ -22,13 +22,14 @@ import {
workers,
kfWorkers,
} from '../sceneShared';
import { TIMELINE_RES } from '../../../shared/videoRes';
import type { VideoBackend, SceneRenderCtx, Emit, Cancelled } from '../types';

/** Render a local scene as ONE continuous shot: chain native-length sub-clips — each i2v from the previous
* clip's last frame (the first from the keyframe) — to fill the scene, then concatenate. This fills a long
* scene with real motion instead of stretching one short clip (slow-motion), so we can use fewer/longer
* scenes (fewer cuts). A scene that already fits in one native clip just renders directly. */
async function renderLocalScene(pid: string, k: number, kfFirst: string, clipPrompt: string, wdur: number, raw: string, seed: number, emit: Emit): Promise<[boolean, string]> {
async function renderLocalScene(pid: string, k: number, kfFirst: string, clipPrompt: string, wdur: number, raw: string, seed: number, emit: Emit, anchor: string | null): Promise<[boolean, string]> {
// Native clip budget from the SELECTED model (the 14B is 16fps — assuming 24 here used to overestimate
// nativeSec, so chained totals could come out SHORTER than the scene window).
const fps = Math.max(1, localNativeFps());
Expand All @@ -52,14 +53,21 @@ async function renderLocalScene(pid: string, k: number, kfFirst: string, clipPro
subs.push(subOut);
emit({ event: 'subclip', index: k, sub: i + 1, total: nSub });
if (i < nSub - 1) {
const lf = await lastFrame(subOut, S.tmp(`lf_${pid}_${k}_${i}.png`));
let lf = await lastFrame(subOut, S.tmp(`lf_${pid}_${k}_${i}.png`));
// Anti-drift: this frame was GENERATED (and re-encoded), and the next sub-clip is generated from it —
// exposure/saturation error compounds link by link with nothing bounding it (VB_LOCAL_CHAIN_MAX caps
// the chain across SCENES, never within one). A clamped pull back toward the scene's anchor keyframe
// is a no-op while there is no drift, so it costs nothing on short scenes.
if (lf && anchor && envBool('VB_LOCAL_CHAIN_MATCH', true)) {
lf = await matchLevels(lf, anchor, S.tmp(`lfm_${pid}_${k}_${i}.png`));
}
if (lf) startImg = lf; // continue the motion from the last frame
}
}
return (await concatClips(subs, raw)) ? [true, ''] : [false, 'failed to assemble chained sub-clips'];
}

async function renderClip(pid: string, k: number, p: any, kfFirst: string, emit: Emit, seed = 42): Promise<[boolean, string]> {
async function renderClip(pid: string, k: number, p: any, kfFirst: string, emit: Emit, seed = 42, anchor: string | null = null): Promise<[boolean, string]> {
const sc = S.getScene(pid, k) || {};
const wdur = Math.max(0.4, Number(sc.endSec || 0) - Number(sc.startSec || 0) || 4);
// The storyboard's per-scene motion direction (explicit camera move + chained subject action) leads the
Expand All @@ -74,7 +82,9 @@ async function renderClip(pid: string, k: number, p: any, kfFirst: string, emit:
const clipPrompt = stripWrittenText(`${motion}. ${sc.prompt || ''}, cinematic${vstyle ? ', ' + vstyle : ''}`);
// Wan renders each scene as one continuous shot: a single start frame per native clip, chained into a
// long shot (renderLocalScene) so a long scene has real motion instead of one stretched slow-mo clip.
const [ok, err] = await renderLocalScene(pid, k, kfFirst, clipPrompt, wdur, raw, seed, emit);
// The drift anchor is the scene's own keyframe when it has one; a chained 'continue' scene inherits the
// anchor of the CUT scene that started its run (passed in), so the whole run is pulled toward one look.
const [ok, err] = await renderLocalScene(pid, k, kfFirst, clipPrompt, wdur, raw, seed, emit, anchor || kfFirst);
if (!ok) {
const reason = P.isContentBlock(err) ? "This scene was blocked by the model's safety filter." : `Scene render failed: ${(err || '').slice(0, 160)}`;
putSceneMerged(pid, k, sc, { status: 'failed', error: reason });
Expand Down Expand Up @@ -115,22 +125,30 @@ async function renderScenesLocalChained(pid: string, p: any, toRender: number[],
// clip pass — SEQUENTIAL: a continue scene starts from the previous scene's last frame.
emit({ event: 'stage', stage: 'clips', total: toRender.length });
let prevLast: string | null = null;
// The look every scene in the current run is pulled back toward: the keyframe of the CUT scene that
// opened the run. Reset at each cut, so a deliberate new look never gets dragged toward the previous one.
let anchor: string | null = null;
for (const k of toRender) {
checkCancel(cancelled);
let start = cut[k] ? kfPaths[k] : prevLast;
if (!start) start = await buildKeyframe(pid, k, p, toon); // chain broke (or keyframe failed) → fresh keyframe
if (cut[k]) anchor = start || null;
const sc = S.getScene(pid, k) || {};
if (!start) {
putSceneMerged(pid, k, sc, { status: 'failed', error: 'Could not get a start frame for this scene.' });
emit({ event: 'scene', index: k, status: 'failed', error: 'no start frame' });
prevLast = null;
continue;
}
const [ok] = await renderClip(pid, k, p, start, emit);
const [ok] = await renderClip(pid, k, p, start, emit, 42, anchor);
prevLast = null;
if (ok) {
const clipKey = `${pid}/clips/scene_${k}.mp4`;
if (S.mediaExists(clipKey)) prevLast = await lastFrame(S.mediaPath(clipKey), S.tmp(`chain_last_${pid}_${k}.png`));
// Same clamped anti-drift correction as the within-scene chain, at the scene seam.
if (prevLast && anchor && envBool('VB_LOCAL_CHAIN_MATCH', true)) {
prevLast = await matchLevels(prevLast, anchor, S.tmp(`chain_lastm_${pid}_${k}.png`));
}
}
const d = S.listScenes(pid).filter((s) => s.status === 'done').length;
S.updateProject(pid, { scenesDone: d, progress: Math.round((0.3 + (0.6 * Math.min(d, target)) / Math.max(1, target)) * 1000) / 1000 });
Expand Down Expand Up @@ -208,7 +226,7 @@ export const localVideo: VideoBackend = {
return [true, ''];
},

timelineRes: () => ({ w: 832, h: 480 }),
timelineRes: () => TIMELINE_RES.local,
// Real-ESRGAN → 1080 finish. VB_LOCAL_UPSCALE=0 opts a power user out (preserves the pre-C4 gate).
needsUpscale: () => envBool('VB_LOCAL_UPSCALE', true),
needsGpu: () => true,
Expand Down
3 changes: 2 additions & 1 deletion src/engine/cloud/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
checkCancel,
workers,
} from '../backends/sceneShared';
import { TIMELINE_RES } from '../../shared/videoRes';
import type { VideoBackend, SceneRenderCtx, Emit } from '../backends/types';

const CLIP_MIN_SEC = 3;
Expand Down Expand Up @@ -199,7 +200,7 @@ export const cloudVideo: VideoBackend = {
return [true, ''];
},

timelineRes: () => ({ w: 1280, h: 720 }),
timelineRes: () => TIMELINE_RES.cloud,
needsUpscale: () => false,
needsGpu: () => false,
};
15 changes: 14 additions & 1 deletion src/engine/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,21 @@
// values are also usable in dev/tests.
const CFG: Record<string, string> = {};

/** Merge an injected env map (decrypted keys + settings) into the runtime config. Last write wins. */
/** REPLACE the runtime config with an injected env map (decrypted keys + settings), once per engine op.
*
* Clearing first is required, not cosmetic. The resolver emits several keys only for one model/backend
* combination, and some ops inject a one-shot override — so a merge lets a previous op's value survive into
* the next one and silently change how the model runs:
* - `VB_LOCAL_WAN_STEPS` is emitted only for the 5B (autoconfig) and injected only by `rerender-clips`
* (main/index.ts). Leaking it into a later 14B run forces that step count over the Lightning 4-step
* distillation AND makes `isHd` read false, so the HD path silently drops to a few-step, no-LoRA,
* tiny-VAE render — strictly worse than both Fast and HD.
* - `VB_LOCAL_WAN_DIR` / `VB_LOCAL_QUALITY` likewise persisted after the setting that produced them was
* cleared.
* Every op passes the full resolved env (main/index.ts `streamOp` spreads `sidecarEnv()`), so a replace is
* always complete. Reads still fall back to `process.env`, which stays the power-user override channel. */
export function setEnv(map: Record<string, string | undefined | null>): void {
for (const k of Object.keys(CFG)) delete CFG[k];
for (const [k, v] of Object.entries(map)) {
if (v != null && v !== '') CFG[k] = String(v);
}
Expand Down
Loading
Loading