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:
- Upscale:
scaleto 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. - 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"
- 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.
- Dynamic crop:
crop=720:1280:x='中心+包络(t)*dx(t)':y='中心+包络(t)*dy(t)', audio-c:a copyis 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):
- 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".
- 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.
- 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).
- 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"). - 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.