← Knowledge Notes
Content Creation / Knowledge note · English

AI Video Post-Production Head Float (A Deterministic Solution for POV Anthropomorphism)

Generative models resist "continuous subtle camera shake" (Veo hits 1/3 / Seedance locks to a static frame) → stop rolling the dice; do post compositing: 10% upscale + triple-frequency sine dynamic crop + segmented amplitude envelope (large when walking / small when standing still / halved when holding breath). Includes zero-dependency script, acceptance parameters, quantitative self-check method, Veo vs Seedance behavior comparison, and five core principles for POV prompts. Tested across 10 rounds and 40+ generations on StoReel; two deliverables passed acceptance.

Source collection:Cross-source synthesis · Published here:2026-09-26 · Note updated:2026-07-02

AI VideoPost-production compositingCamera Movement

When to use this: You are making AI-generated videos where "the camera = the character's eyes" (otome first-person / pet POV / VR-style subjective shots), and the output looks as dead still as if mounted on a tripod, or you are planning to repeatedly tweak prompts and reroll to get a "breathing feel".

Core idea in one sentence:The model produces the content; post-production adds the float. Video generation models resist instructions like "continuous subtle camera shake" — instead of gambling on rerolls, let the model only produce stable, good content, and synthesize the float with ffmpeg: upscale by 10% → dynamically crop back to original size along a triple-frequency sine trajectory. Deterministic, adjustable, zero rerolling required.

Source: StoReel narrative video acceptance criteria task (2026-07-02), 5 POV prompts × 10 rounds of iteration × 40+ generation tests; this method was the decisive turning point in round 8, and the two deliverable videos B2 (walking POV) / B3 (heartbeat feel) passed acceptance thanks to it.

1. Why the prompt route does not work (give up on it first)

Model Response to "continuous subtle shake" instruction Measured data
Google Veo 3.1 Occasionally obeys, random Hit rate for "documentary-style handheld subtle shake" is roughly 1/31/3(2 hits out of 6 rolls), same prompt can fail once and succeed once
Seedance 2.0 (Jimeng) Directly ignores the instruction Best content compliance, can lock composition, but gives you a tripod-like locked-frame feel

We tried every wording: "handheld camera" (triggers selfie arm artifacts) → "slight shake" (ignored) → "documentary handheld" (Veo occasionally hits) → "action camera strapped to the forehead" (large movements are followed correctly — walking bumps, head turns rotate the view, but when standing still the continuous subtle float is still not produced consistently).

Conclusion: Camera movement following large actions can be obtained via prompts; low-amplitude high-frequency effects like "always having a little float even when stationary" cannot be obtained. This is not because your prompt is poorly written — it is because in the model's training distribution, "good video = stable".

2. Method: upscale + triple-frequency sine + segmented envelope

Four steps, all completed in a single ffmpeg filter chain:

  1. Upscale:scale to 1.10× (720×1280 → 792×1408), leaving crop headroom of ±36px horizontally / ±64px vertically. The cost is roughly 17% resolution loss — acceptable to the naked eye for a 720p source; if you are sensitive to sharpness, generate at a higher resolution first then downscale.
  2. Triple-frequency sine trajectory: Stack 3 sine waves each for x and y (slow sway + breathing + micro-tremor).A single frequency looks as fake as a pendulum; after stacking three frequencies the trajectory does not repeat, and looks like a real person. Acceptance-ready parameters:
    • x: 0.40Hz×0.6 + 1.10Hz×0.3 + 2.70Hz×0.1, base amplitude 14px
    • y: 0.55Hz×0.6 + 1.30Hz×0.3 + 3.10Hz×0.1, base amplitude 20px
    • y is larger than x(breathing is primarily vertical); x and y frequencies are not in integer ratios, to avoid the trajectory tracing a repeating "figure 8"
  3. Segmented amplitude envelope: Float strength follows the narrative —amplitude itself is emotional language: | Narrative segment | Level | Why | |---|---|---| | Walking | ≈1.8 | Step bounce | | Standing still | 1.0 | Only breathing remains | | Holding breath (emotional climax) | 0.5 | "He leans in, 'I' dare not move" — halving the float gives the physical sensation of holding one's breath | Use 1-second linear fades between segments to avoid jerks from amplitude jumps.
  4. Dynamic crop:crop=720:1280:x='中心+包络(t)*dx(t)':y='中心+包络(t)*dy(t)', audio -c:a copy is kept as-is.

3. Script (zero dependencies, system ffmpeg is enough)

Original archive: Cockpit repo tasks/2026-07-02-storeel-video-acceptance-standard/assets/prompt验证/_工具脚本/head_float.py. Usage:

python3 head_float.py in.mp4 out.mp4                             # 全程站定档
python3 head_float.py in.mp4 out.mp4 --segments "0:1.0,10:0.5"   # 心跳感:10s 起屏息减半(B3 验收参数)
python3 head_float.py in.mp4 out.mp4 --segments "0:1.8,8:1.0"    # 走路 POV:0-8s 走路档(B2 验收参数)
# --zoom 放大倍数 / --amp-x --amp-y 基准振幅 / --ramp 段间渐变秒数

Full source code (matches the task archive, can be saved directly for use):

#!/usr/bin/env python3
"""后期头部浮动合成:给 AI 生成的"死静止"POV 视频叠加拟人头部浮动。"""
import argparse
import math
import subprocess
import sys


def build_envelope(segments, ramp):
    """分段幅度 → ffmpeg 表达式 e(t):段内恒定,段边界处 ramp 秒线性渐变。"""
    pts = sorted(segments)
    expr = f"{pts[-1][1]}"
    for (t0, a0), (t1, a1) in zip(reversed(pts[:-1]), reversed(pts[1:])):
        blend = f"({a0}+({a1}-{a0})*(t-{t1})/{ramp})"
        expr = f"if(lt(t,{t1}),{a0},if(lt(t,{t1 + ramp}),{blend},{expr}))"
    return expr


def build_offset(amp, freqs, phases, weights):
    """多频正弦偏移 d(t)(不含包络),单位 px。"""
    terms = [
        f"{amp * w:.3f}*sin({2 * math.pi * f:.6f}*t+{p:.3f})"
        for f, p, w in zip(freqs, phases, weights)
    ]
    return "(" + "+".join(terms) + ")"


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("input")
    ap.add_argument("output")
    ap.add_argument("--zoom", type=float, default=1.10)
    ap.add_argument("--amp-x", type=float, default=14.0)
    ap.add_argument("--amp-y", type=float, default=20.0)
    ap.add_argument("--segments", default="0:1.0")
    ap.add_argument("--ramp", type=float, default=1.0)
    args = ap.parse_args()

    segments = []
    for part in args.segments.split(","):
        t, a = part.split(":")
        segments.append((float(t), float(a)))

    # 三频叠加:慢摆 / 呼吸 / 微颤;x、y 频率错开避免轨迹走"8 字"重复
    dx = build_offset(args.amp_x, freqs=(0.40, 1.10, 2.70), phases=(0.0, 1.3, 0.7), weights=(0.6, 0.3, 0.1))
    dy = build_offset(args.amp_y, freqs=(0.55, 1.30, 3.10), phases=(0.9, 2.1, 1.7), weights=(0.6, 0.3, 0.1))
    env = build_envelope(segments, args.ramp)

    probe = subprocess.run(
        ["ffmpeg", "-hide_banner", "-i", args.input], capture_output=True, text=True
    )
    import re
    m = re.search(r"(\d{3,4})x(\d{3,4})", probe.stderr)
    if not m:
        sys.exit("探不到分辨率: " + args.input)
    w, h = int(m.group(1)), int(m.group(2))
    zw, zh = int(w * args.zoom) // 2 * 2, int(h * args.zoom) // 2 * 2
    max_dx, max_dy = (zw - w) / 2, (zh - h) / 2

    worst = max(a for _, a in segments)
    if args.amp_x * worst > max_dx or args.amp_y * worst > max_dy:
        sys.exit(f"振幅超出余量(x≤{max_dx:.0f}px y≤{max_dy:.0f}px):加大 --zoom 或调小振幅/档位")

    vf = (
        f"scale={zw}:{zh}:flags=lanczos,"
        f"crop={w}:{h}"
        f":x='({zw}-{w})/2+({env})*{dx}'"
        f":y='({zh}-{h})/2+({env})*{dy}'"
    )
    subprocess.run([
        "ffmpeg", "-y", "-hide_banner", "-loglevel", "warning",
        "-i", args.input, "-vf", vf,
        "-c:v", "libx264", "-preset", "slow", "-crf", "18",
        "-c:a", "copy", args.output,
    ], check=True)
    print("完成:", args.output)


if __name__ == "__main__":
    main()

4. Quantitative self-check: no need to argue by eye about "whether there is a static feel"

Take two frames 0.25 seconds apart, run a difference on a static corner region of the frame(blend=difference then check the YAVG luminance average in signalstats — larger values mean greater difference between the two frames):

ffmpeg -i a.png -i b.png -filter_complex \
  "[0:v]crop=200:200:10:10[a];[1:v]crop=200:200:10:10[b];[a][b]blend=difference,signalstats,metadata=print" \
  -f null - 2>&1 | grep YAVG

Measured anchor points (B3 heartbeat feel, standing segment): source clip 2.73 (dead still) / after adding float 4.4~5.4 (acceptance threshold). The breath-holding segment should be noticeably lower than the standing segment. This difference method is also used for self-check item in acceptance criteria appendix C: "randomly drag to 3 points, pause, then resume playback, confirm the frame always has slight float".

5. Upstream supporting setup: five core POV prompt principles + the two models' quirks

Float only solves the "is it moving or not" problem; at the content level these five rules determine whether you can get a good clip worth stacking float on (all learned from hard-won mistakes):

  1. Human eye perspective = "camera strapped to the forehead". Do not write "handheld camera" (it will draw selfie arms), and do not only write "slight shake" (it gets ignored); write "the camera is like an action camera strapped to 'my' forehead, the frame fully follows head movement".
  2. The controlling body can enter frame, but its state must be locked down("hands folded flat on the table edge, completely still") — leave even a little freedom, and the model will draw arms reaching toward the camera.
  3. For Seedance you must write "real-person live-action footage / real actor / real skin texture"; miss one word and you get CG game-rendered faces (one-word difference empirically verified: B3 v7→v8).
  4. Separate negative constraints by model: Veo has an independent negativePrompt parameter (only list English nouns:selfie, vlog, arm reaching toward camera lens, black bars); Seedance does not, so negatives must be converted to positive state descriptions ("hands naturally hanging at the sides, below the field of view").
  5. The model produces the content; post-production adds the float(i.e. this wiki).
Seedance 2.0 (Jimeng) Google Veo 3.1
Strengths Good compliance with complex instructions, 15 seconds per segment, never adds black bars Cheap and fast to output clips (good for rerolling), supports negativePrompt, image-to-video pins the first frame tightly
Watch out for Overly stable frame (static locked-frame feel) → saved by this method; missing "real person" produces CG faces Strong prior for selfie arms; random top/bottom black bars (crop before delivery); only 8 seconds per segment

6. Applicable and not applicable

Scenario Use or not Notes
POV narrative videos (otome / pet / VR feel) ✅ Home turf Generate stable content + stack float
Adding life to static cinemagraphs ✅ Turn amplitude down Be cautious with looping videos: float breaks the first/last frame loop closure; apply pingpong first then stack float overall, or do not stack
Source footage already has large camera movement ❌ Float will conflict with original camera movement
Wanting "gritty handheld feel" (vlog / war zone) ❌ Same parameter logic but completely different levels That is high-frequency large-amplitude shake, adjust separately

7. Related

  • Full task file (5 acceptance prompt originals / videos / self-check frames / difference images): Cockpit repo tasks/2026-07-02-storeel-video-acceptance-standard/
  • Sister technique pingpong loop(interactive node waiting screen): after generation take the first 1.5s played forward + reversed to splice a 3-second clip, first and last frames match at pixel level, looped actions only retain blinks / eyelashes / breathing
  • AI Video Generation SOP (cross-author topic synthesis) — cross-author AI video production SOP compilation (learning area); this wiki is its "firsthand testing" supplementary perspective

History

  • 2026-07-02: First published. Derived from 10 rounds of testing on the StoReel acceptance criteria task, documented on the day the B2/B3 deliverables passed acceptance.

Sources and related materials