Vo Hoang Minh commited on
Commit ·
03dbefe
1
Parent(s): 1bae598
- .gitattributes +0 -35
- app.py +110 -57
- assets/3d_perspective.json +0 -146
- assets/light_effect.json +0 -205
- kenburns.py +252 -0
- motion.json +171 -0
.gitattributes
DELETED
|
@@ -1,35 +0,0 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
-
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
-
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
-
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
CHANGED
|
@@ -1,63 +1,116 @@
|
|
|
|
|
| 1 |
import gradio as gr
|
| 2 |
-
import
|
|
|
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
for e in json.load(f).get("effects", []):
|
| 10 |
-
effects[e["name"]] = e["command"]
|
| 11 |
-
return effects
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
def run_ffmpeg(input_path, command_str, output_ext):
|
| 18 |
-
out = tempfile.NamedTemporaryFile(suffix=output_ext, delete=False).name
|
| 19 |
-
args = ["ffmpeg", "-y", "-i", input_path] + shlex.split(command_str) + ["-preset", "ultrafast", out]
|
| 20 |
try:
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
import gradio as gr
|
| 3 |
+
import kenburns as kb
|
| 4 |
+
import tempfile, shutil
|
| 5 |
|
| 6 |
+
# ---------------------------------------------------------------------------
|
| 7 |
+
# 1. Helpers
|
| 8 |
+
# ---------------------------------------------------------------------------
|
| 9 |
+
TMP_DIR = Path(tempfile.gettempdir()) / "kb_ui"
|
| 10 |
+
TMP_DIR.mkdir(exist_ok=True, parents=True)
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
+
EXAMPLE_DIR = Path(__file__).parent.parent / "examples"
|
| 13 |
+
EXAMPLE_SETS = [
|
| 14 |
+
[str(p) for p in sorted((EXAMPLE_DIR / "set1").glob("*.jpg"))],
|
| 15 |
+
[str(p) for p in sorted((EXAMPLE_DIR / "set2").glob("*.jpg"))],
|
| 16 |
+
]
|
| 17 |
|
| 18 |
+
STYLE_OPTIONS = [
|
| 19 |
+
("Random all", "*"),
|
| 20 |
+
("Random zoom preset (one for ALL slides)", "zoom_*"),
|
| 21 |
+
("Random zoom preset (DIFFERENT per slide)", "zoom_*!"),
|
| 22 |
+
("Random pan preset (all)", "pan_*"),
|
| 23 |
+
("Random pan preset (diff per slide)", "pan_*!"),
|
| 24 |
+
]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def build_preview(files):
|
| 28 |
+
"""Return list of file paths for Gallery"""
|
| 29 |
+
return [f.name for f in files] if files else None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
# Gradio runner -------------------------------------------------------------
|
| 33 |
+
|
| 34 |
+
def run_slideshow(files, dur, style_pattern, fps, cf, seed, progress=gr.Progress()):
|
| 35 |
+
if not files:
|
| 36 |
+
return None, None, "⚠️ Please upload or select example images."
|
| 37 |
+
|
| 38 |
+
# ensure reproducible paths & order
|
| 39 |
+
image_paths = [f.name for f in files]
|
| 40 |
+
durations = [dur] * len(image_paths)
|
| 41 |
+
|
| 42 |
+
out_path = TMP_DIR / "slideshow.mp4"
|
| 43 |
|
|
|
|
|
|
|
|
|
|
| 44 |
try:
|
| 45 |
+
# show spinner/progress while FFmpeg runs
|
| 46 |
+
with progress.tqdm(desc="Rendering", total=1) as pbar:
|
| 47 |
+
vid_path, cmd = kb.generate_video(
|
| 48 |
+
image_paths,
|
| 49 |
+
durations,
|
| 50 |
+
style=style_pattern,
|
| 51 |
+
fps=fps,
|
| 52 |
+
cf=cf,
|
| 53 |
+
seed=(seed if seed else None),
|
| 54 |
+
out=str(out_path),
|
| 55 |
+
)
|
| 56 |
+
pbar.update()
|
| 57 |
+
return str(vid_path), cmd, "✅ Done"
|
| 58 |
+
except Exception as exc:
|
| 59 |
+
return None, None, f"❌ Error: {exc}"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# Gradio interface ----------------------------------------------------------
|
| 63 |
+
|
| 64 |
+
def launch():
|
| 65 |
+
with gr.Blocks(title="Ken Burns Slideshow (v2)") as demo:
|
| 66 |
+
gr.Markdown("## 🖼️ Ken Burns Slideshow Generator")
|
| 67 |
+
|
| 68 |
+
# --- Upload and preview ------------------------------------------
|
| 69 |
+
with gr.Row():
|
| 70 |
+
file_uploader = gr.File(
|
| 71 |
+
label="Upload images (drag to reorder in your OS before upload)",
|
| 72 |
+
file_count="multiple",
|
| 73 |
+
interactive=True,
|
| 74 |
+
)
|
| 75 |
+
gallery = gr.Gallery(label="Preview", show_label=True).style(grid=4)
|
| 76 |
+
file_uploader.upload(build_preview, file_uploader, gallery)
|
| 77 |
+
|
| 78 |
+
# --- Examples ----------------------------------------------------
|
| 79 |
+
gr.Examples(
|
| 80 |
+
examples=EXAMPLE_SETS,
|
| 81 |
+
inputs=file_uploader,
|
| 82 |
+
label="🖼️ Use example image sets (click to load)",
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
# --- Parameters --------------------------------------------------
|
| 86 |
+
with gr.Column():
|
| 87 |
+
dur_slider = gr.Slider(3, 15, value=6, step=0.5, label="Seconds per image")
|
| 88 |
+
style_dropdown = gr.Dropdown(
|
| 89 |
+
choices=[o[1] for o in STYLE_OPTIONS],
|
| 90 |
+
value="*",
|
| 91 |
+
label="Style pattern",
|
| 92 |
+
info="* = fully random | zoom_* = random zoom preset | zoom_*! = random zoom per slide",
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
with gr.Accordion("Advanced settings", open=False):
|
| 96 |
+
fps_slider = gr.Slider(15, 60, value=30, step=5, label="FPS")
|
| 97 |
+
cf_slider = gr.Slider(0.05, 0.3, value=0.15, step=0.01, label="Cross‑fade factor (0‑1)")
|
| 98 |
+
seed_num = gr.Number(value=0, label="Random seed (0 = random each run)")
|
| 99 |
+
|
| 100 |
+
# --- Output ------------------------------------------------------
|
| 101 |
+
out_video = gr.Video(label="Output video")
|
| 102 |
+
ff_cmd_box = gr.Textbox(label="FFmpeg command", lines=3)
|
| 103 |
+
status_box = gr.Textbox(label="Status / Errors", interactive=False)
|
| 104 |
+
|
| 105 |
+
run_btn = gr.Button("Generate 🎬")
|
| 106 |
+
run_btn.click(
|
| 107 |
+
run_slideshow,
|
| 108 |
+
inputs=[file_uploader, dur_slider, style_dropdown, fps_slider, cf_slider, seed_num],
|
| 109 |
+
outputs=[out_video, ff_cmd_box, status_box],
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
demo.launch()
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
if __name__ == "__main__":
|
| 116 |
+
launch()
|
assets/3d_perspective.json
DELETED
|
@@ -1,146 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"category": "3d_perspective",
|
| 3 |
-
"tags": ["3d", "perspective", "fisheye", "barrel", "distortion", "warp", "transform", "projection"],
|
| 4 |
-
"effects": [
|
| 5 |
-
{
|
| 6 |
-
"name": "Fisheye Lens Effect",
|
| 7 |
-
"command": "-vf \"v360=input=flat:output=fisheye:ih_fov=60:iv_fov=60\"",
|
| 8 |
-
"preview_url": "",
|
| 9 |
-
"description": "Create fisheye lens distortion effect",
|
| 10 |
-
"input": "video or image",
|
| 11 |
-
"rating": {
|
| 12 |
-
"popularity": 3.8,
|
| 13 |
-
"visual_impact": 4.5,
|
| 14 |
-
"ease_of_use": 3.5,
|
| 15 |
-
"creativity": 4.2
|
| 16 |
-
},
|
| 17 |
-
"tags": ["fisheye", "distortion", "wide-angle", "curved"]
|
| 18 |
-
},
|
| 19 |
-
{
|
| 20 |
-
"name": "Barrel Distortion",
|
| 21 |
-
"command": "-vf \"lenscorrection=k1=0.15:k2=0.05\"",
|
| 22 |
-
"preview_url": "",
|
| 23 |
-
"description": "Barrel distortion for vintage lens effect",
|
| 24 |
-
"input": "video or image",
|
| 25 |
-
"rating": {
|
| 26 |
-
"popularity": 3.2,
|
| 27 |
-
"visual_impact": 3.8,
|
| 28 |
-
"ease_of_use": 3.0,
|
| 29 |
-
"creativity": 4.0
|
| 30 |
-
},
|
| 31 |
-
"tags": ["barrel", "vintage", "lens", "curved"]
|
| 32 |
-
},
|
| 33 |
-
{
|
| 34 |
-
"name": "Pincushion Distortion",
|
| 35 |
-
"command": "-vf \"lenscorrection=k1=-0.15:k2=-0.05\"",
|
| 36 |
-
"preview_url": "",
|
| 37 |
-
"description": "Pincushion distortion effect",
|
| 38 |
-
"input": "video or image",
|
| 39 |
-
"rating": {
|
| 40 |
-
"popularity": 2.8,
|
| 41 |
-
"visual_impact": 3.5,
|
| 42 |
-
"ease_of_use": 3.0,
|
| 43 |
-
"creativity": 3.8
|
| 44 |
-
},
|
| 45 |
-
"tags": ["pincushion", "inward", "distortion", "telephoto"]
|
| 46 |
-
},
|
| 47 |
-
{
|
| 48 |
-
"name": "Fisheye to Flat",
|
| 49 |
-
"command": "-vf \"v360=input=fisheye:output=flat:ih_fov=180:iv_fov=180\"",
|
| 50 |
-
"preview_url": "",
|
| 51 |
-
"description": "Convert fisheye footage to flat perspective",
|
| 52 |
-
"input": "fisheye video or image",
|
| 53 |
-
"rating": {
|
| 54 |
-
"popularity": 4.0,
|
| 55 |
-
"visual_impact": 3.2,
|
| 56 |
-
"ease_of_use": 3.8,
|
| 57 |
-
"creativity": 2.5
|
| 58 |
-
},
|
| 59 |
-
"tags": ["correction", "flat", "unfisheye", "normalize"]
|
| 60 |
-
},
|
| 61 |
-
{
|
| 62 |
-
"name": "360 to Equirectangular",
|
| 63 |
-
"command": "-vf \"v360=input=dfisheye:output=e:yaw=-90\"",
|
| 64 |
-
"preview_url": "",
|
| 65 |
-
"description": "Convert dual fisheye 360 to equirectangular format",
|
| 66 |
-
"input": "360 dual fisheye video",
|
| 67 |
-
"rating": {
|
| 68 |
-
"popularity": 3.5,
|
| 69 |
-
"visual_impact": 3.0,
|
| 70 |
-
"ease_of_use": 3.2,
|
| 71 |
-
"creativity": 2.8
|
| 72 |
-
},
|
| 73 |
-
"tags": ["360", "equirectangular", "vr", "panorama"]
|
| 74 |
-
},
|
| 75 |
-
{
|
| 76 |
-
"name": "Perspective Rotation",
|
| 77 |
-
"command": "-vf \"v360=input=flat:output=flat:pitch=20:yaw=15:roll=5\"",
|
| 78 |
-
"preview_url": "",
|
| 79 |
-
"description": "Rotate perspective in 3D space",
|
| 80 |
-
"input": "video or image",
|
| 81 |
-
"rating": {
|
| 82 |
-
"popularity": 2.5,
|
| 83 |
-
"visual_impact": 3.8,
|
| 84 |
-
"ease_of_use": 3.0,
|
| 85 |
-
"creativity": 4.5
|
| 86 |
-
},
|
| 87 |
-
"tags": ["3d", "rotation", "pitch", "yaw", "roll"]
|
| 88 |
-
},
|
| 89 |
-
{
|
| 90 |
-
"name": "Lens Correction",
|
| 91 |
-
"command": "-vf \"lenscorrection=k1=-0.227:k2=-0.022\"",
|
| 92 |
-
"preview_url": "",
|
| 93 |
-
"description": "Correct lens distortion from real cameras",
|
| 94 |
-
"input": "distorted video or image",
|
| 95 |
-
"rating": {
|
| 96 |
-
"popularity": 3.8,
|
| 97 |
-
"visual_impact": 2.5,
|
| 98 |
-
"ease_of_use": 2.8,
|
| 99 |
-
"creativity": 2.0
|
| 100 |
-
},
|
| 101 |
-
"tags": ["correction", "undistort", "calibration", "fix"]
|
| 102 |
-
},
|
| 103 |
-
{
|
| 104 |
-
"name": "Spherical Perspective",
|
| 105 |
-
"command": "-vf \"v360=input=flat:output=sg:h_fov=90:v_fov=90\"",
|
| 106 |
-
"preview_url": "",
|
| 107 |
-
"description": "Map flat image to spherical perspective",
|
| 108 |
-
"input": "video or image",
|
| 109 |
-
"rating": {
|
| 110 |
-
"popularity": 2.2,
|
| 111 |
-
"visual_impact": 4.0,
|
| 112 |
-
"ease_of_use": 2.8,
|
| 113 |
-
"creativity": 4.7
|
| 114 |
-
},
|
| 115 |
-
"tags": ["spherical", "planet", "globe", "wrap"]
|
| 116 |
-
},
|
| 117 |
-
{
|
| 118 |
-
"name": "VR Projection",
|
| 119 |
-
"command": "-vf \"v360=input=equirect:output=cubemap_3_2\"",
|
| 120 |
-
"preview_url": "",
|
| 121 |
-
"description": "Convert equirectangular to VR cubemap format",
|
| 122 |
-
"input": "360 equirectangular video",
|
| 123 |
-
"rating": {
|
| 124 |
-
"popularity": 3.0,
|
| 125 |
-
"visual_impact": 3.2,
|
| 126 |
-
"ease_of_use": 3.5,
|
| 127 |
-
"creativity": 3.5
|
| 128 |
-
},
|
| 129 |
-
"tags": ["vr", "cubemap", "projection", "format"]
|
| 130 |
-
},
|
| 131 |
-
{
|
| 132 |
-
"name": "Tiny Planet Effect",
|
| 133 |
-
"command": "-vf \"v360=input=equirect:output=sg:pitch=90\"",
|
| 134 |
-
"preview_url": "",
|
| 135 |
-
"description": "Create tiny planet effect from 360 footage",
|
| 136 |
-
"input": "360 equirectangular video",
|
| 137 |
-
"rating": {
|
| 138 |
-
"popularity": 3.8,
|
| 139 |
-
"visual_impact": 4.8,
|
| 140 |
-
"ease_of_use": 4.0,
|
| 141 |
-
"creativity": 4.9
|
| 142 |
-
},
|
| 143 |
-
"tags": ["tiny-planet", "stereographic", "miniature", "creative"]
|
| 144 |
-
}
|
| 145 |
-
]
|
| 146 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
assets/light_effect.json
DELETED
|
@@ -1,205 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"category": "light_effects",
|
| 3 |
-
"tags": [
|
| 4 |
-
"light",
|
| 5 |
-
"glow",
|
| 6 |
-
"blur",
|
| 7 |
-
"flare",
|
| 8 |
-
"radial",
|
| 9 |
-
"lens",
|
| 10 |
-
"bright",
|
| 11 |
-
"illuminate"
|
| 12 |
-
],
|
| 13 |
-
"effects": [
|
| 14 |
-
{
|
| 15 |
-
"name": "Gaussian Blur Glow",
|
| 16 |
-
"command": "-vf \"gblur=sigma=10\"",
|
| 17 |
-
"preview_url": "",
|
| 18 |
-
"description": "Soft gaussian blur for dreamy glow effect",
|
| 19 |
-
"input": "video or image",
|
| 20 |
-
"rating": {
|
| 21 |
-
"popularity": 4.2,
|
| 22 |
-
"visual_impact": 3.8,
|
| 23 |
-
"ease_of_use": 5.0,
|
| 24 |
-
"creativity": 3.0
|
| 25 |
-
},
|
| 26 |
-
"tags": [
|
| 27 |
-
"gaussian",
|
| 28 |
-
"blur",
|
| 29 |
-
"soft",
|
| 30 |
-
"dreamy"
|
| 31 |
-
]
|
| 32 |
-
},
|
| 33 |
-
{
|
| 34 |
-
"name": "Box Blur Glow",
|
| 35 |
-
"command": "-vf \"boxblur=10\"",
|
| 36 |
-
"preview_url": "",
|
| 37 |
-
"description": "Box blur for bokeh-like camera effect",
|
| 38 |
-
"input": "video or image",
|
| 39 |
-
"rating": {
|
| 40 |
-
"popularity": 3.8,
|
| 41 |
-
"visual_impact": 3.5,
|
| 42 |
-
"ease_of_use": 5.0,
|
| 43 |
-
"creativity": 2.8
|
| 44 |
-
},
|
| 45 |
-
"tags": [
|
| 46 |
-
"box",
|
| 47 |
-
"blur",
|
| 48 |
-
"bokeh",
|
| 49 |
-
"camera"
|
| 50 |
-
]
|
| 51 |
-
},
|
| 52 |
-
{
|
| 53 |
-
"name": "Intense Glow",
|
| 54 |
-
"command": "-vf \"gblur=sigma=20:steps=3\"",
|
| 55 |
-
"preview_url": "",
|
| 56 |
-
"description": "Strong glow effect with multiple blur steps",
|
| 57 |
-
"input": "video or image",
|
| 58 |
-
"rating": {
|
| 59 |
-
"popularity": 3.5,
|
| 60 |
-
"visual_impact": 4.2,
|
| 61 |
-
"ease_of_use": 4.5,
|
| 62 |
-
"creativity": 3.5
|
| 63 |
-
},
|
| 64 |
-
"tags": [
|
| 65 |
-
"intense",
|
| 66 |
-
"glow",
|
| 67 |
-
"strong",
|
| 68 |
-
"multiple"
|
| 69 |
-
]
|
| 70 |
-
},
|
| 71 |
-
{
|
| 72 |
-
"name": "Radial Blur Zoom",
|
| 73 |
-
"command": "-vf \"crop=iw-40:ih-40:20:20,scale=iw:ih,gblur=sigma=5\"",
|
| 74 |
-
"preview_url": "",
|
| 75 |
-
"description": "Radial blur effect simulating motion towards center",
|
| 76 |
-
"input": "video or image",
|
| 77 |
-
"rating": {
|
| 78 |
-
"popularity": 3.2,
|
| 79 |
-
"visual_impact": 4.0,
|
| 80 |
-
"ease_of_use": 3.8,
|
| 81 |
-
"creativity": 4.2
|
| 82 |
-
},
|
| 83 |
-
"tags": [
|
| 84 |
-
"radial",
|
| 85 |
-
"motion",
|
| 86 |
-
"zoom",
|
| 87 |
-
"center"
|
| 88 |
-
]
|
| 89 |
-
},
|
| 90 |
-
{
|
| 91 |
-
"name": "Brightness Explosion",
|
| 92 |
-
"command": "-vf \"eq=brightness=0.3:contrast=1.5,gblur=sigma=8\"",
|
| 93 |
-
"preview_url": "",
|
| 94 |
-
"description": "Bright explosion effect with glow",
|
| 95 |
-
"input": "video or image",
|
| 96 |
-
"rating": {
|
| 97 |
-
"popularity": 3.0,
|
| 98 |
-
"visual_impact": 4.3,
|
| 99 |
-
"ease_of_use": 4.0,
|
| 100 |
-
"creativity": 4.0
|
| 101 |
-
},
|
| 102 |
-
"tags": [
|
| 103 |
-
"explosion",
|
| 104 |
-
"bright",
|
| 105 |
-
"dramatic",
|
| 106 |
-
"energy"
|
| 107 |
-
]
|
| 108 |
-
},
|
| 109 |
-
{
|
| 110 |
-
"name": "Selective Blur Glow",
|
| 111 |
-
"command": "-vf \"smartblur=1.0:0.8:0\"",
|
| 112 |
-
"preview_url": "",
|
| 113 |
-
"description": "Smart blur that preserves edges while creating glow",
|
| 114 |
-
"input": "video or image",
|
| 115 |
-
"rating": {
|
| 116 |
-
"popularity": 2.8,
|
| 117 |
-
"visual_impact": 3.8,
|
| 118 |
-
"ease_of_use": 3.5,
|
| 119 |
-
"creativity": 3.8
|
| 120 |
-
},
|
| 121 |
-
"tags": [
|
| 122 |
-
"smart",
|
| 123 |
-
"selective",
|
| 124 |
-
"edges",
|
| 125 |
-
"preserve"
|
| 126 |
-
]
|
| 127 |
-
},
|
| 128 |
-
{
|
| 129 |
-
"name": "Soft Light Halo",
|
| 130 |
-
"command": "-vf \"gblur=sigma=15,eq=brightness=0.1:contrast=0.9\"",
|
| 131 |
-
"preview_url": "",
|
| 132 |
-
"description": "Soft halo effect around subjects",
|
| 133 |
-
"input": "video or image",
|
| 134 |
-
"rating": {
|
| 135 |
-
"popularity": 3.5,
|
| 136 |
-
"visual_impact": 4.0,
|
| 137 |
-
"ease_of_use": 4.2,
|
| 138 |
-
"creativity": 3.7
|
| 139 |
-
},
|
| 140 |
-
"tags": [
|
| 141 |
-
"halo",
|
| 142 |
-
"soft",
|
| 143 |
-
"angelic",
|
| 144 |
-
"portrait"
|
| 145 |
-
]
|
| 146 |
-
},
|
| 147 |
-
{
|
| 148 |
-
"name": "Lens Flare Simulation",
|
| 149 |
-
"command": "-vf \"eq=brightness=0.2:contrast=1.3,gblur=sigma=12\"",
|
| 150 |
-
"preview_url": "",
|
| 151 |
-
"description": "Basic lens flare simulation with brightness and blur",
|
| 152 |
-
"input": "video or image",
|
| 153 |
-
"rating": {
|
| 154 |
-
"popularity": 4.0,
|
| 155 |
-
"visual_impact": 4.2,
|
| 156 |
-
"ease_of_use": 4.5,
|
| 157 |
-
"creativity": 3.8
|
| 158 |
-
},
|
| 159 |
-
"tags": [
|
| 160 |
-
"lens-flare",
|
| 161 |
-
"simulation",
|
| 162 |
-
"cinematic",
|
| 163 |
-
"bright"
|
| 164 |
-
]
|
| 165 |
-
},
|
| 166 |
-
{
|
| 167 |
-
"name": "Moonlight Glow",
|
| 168 |
-
"command": "-vf \"eq=brightness=-0.1:contrast=0.8,gblur=sigma=6,hue=h=240\"",
|
| 169 |
-
"preview_url": "",
|
| 170 |
-
"description": "Soft moonlight effect with blue tint and glow",
|
| 171 |
-
"input": "video or image",
|
| 172 |
-
"rating": {
|
| 173 |
-
"popularity": 3.2,
|
| 174 |
-
"visual_impact": 4.1,
|
| 175 |
-
"ease_of_use": 4.0,
|
| 176 |
-
"creativity": 4.3
|
| 177 |
-
},
|
| 178 |
-
"tags": [
|
| 179 |
-
"moonlight",
|
| 180 |
-
"blue",
|
| 181 |
-
"night",
|
| 182 |
-
"romantic"
|
| 183 |
-
]
|
| 184 |
-
},
|
| 185 |
-
{
|
| 186 |
-
"name": "Golden Hour Glow",
|
| 187 |
-
"command": "-vf \"eq=brightness=0.15:saturation=1.2,gblur=sigma=4,colortemperature=temperature=3000\"",
|
| 188 |
-
"preview_url": "",
|
| 189 |
-
"description": "Warm golden hour lighting with soft glow",
|
| 190 |
-
"input": "video or image",
|
| 191 |
-
"rating": {
|
| 192 |
-
"popularity": 4.5,
|
| 193 |
-
"visual_impact": 4.6,
|
| 194 |
-
"ease_of_use": 4.2,
|
| 195 |
-
"creativity": 4.0
|
| 196 |
-
},
|
| 197 |
-
"tags": [
|
| 198 |
-
"golden-hour",
|
| 199 |
-
"warm",
|
| 200 |
-
"sunset",
|
| 201 |
-
"cinematic"
|
| 202 |
-
]
|
| 203 |
-
}
|
| 204 |
-
]
|
| 205 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
kenburns.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import fnmatch
|
| 5 |
+
import random
|
| 6 |
+
import shlex
|
| 7 |
+
import subprocess
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from functools import lru_cache
|
| 10 |
+
from math import cos, pi
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Dict, List, Sequence, Tuple, Union
|
| 13 |
+
|
| 14 |
+
################################################################################
|
| 15 |
+
# Domain models
|
| 16 |
+
################################################################################
|
| 17 |
+
|
| 18 |
+
@dataclass(frozen=True, slots=True)
|
| 19 |
+
class Preset:
|
| 20 |
+
kind: str
|
| 21 |
+
start: float | None = None
|
| 22 |
+
end: float | None = None
|
| 23 |
+
anchor: str | None = None
|
| 24 |
+
dx: float | None = None
|
| 25 |
+
dy: float | None = None
|
| 26 |
+
rot_start: float | None = None
|
| 27 |
+
rot_end: float | None = None
|
| 28 |
+
ease: str = "linear"
|
| 29 |
+
|
| 30 |
+
@staticmethod
|
| 31 |
+
def from_dict(d: Dict) -> "Preset":
|
| 32 |
+
return Preset(**{k: v for k, v in d.items() if k in Preset.__dataclass_fields__})
|
| 33 |
+
|
| 34 |
+
@dataclass(slots=True)
|
| 35 |
+
class Slide:
|
| 36 |
+
path: str
|
| 37 |
+
duration: float
|
| 38 |
+
preset_name: str
|
| 39 |
+
|
| 40 |
+
################################################################################
|
| 41 |
+
# Template loader & style picker
|
| 42 |
+
################################################################################
|
| 43 |
+
|
| 44 |
+
ROOT = Path(__file__).resolve().parent
|
| 45 |
+
_TEMPLATE_FILE = ROOT / "motion.json"
|
| 46 |
+
|
| 47 |
+
@lru_cache(maxsize=1)
|
| 48 |
+
def load_templates(path: Union[str, Path] = _TEMPLATE_FILE) -> Dict[str, Preset]:
|
| 49 |
+
try:
|
| 50 |
+
data = json.loads(Path(path).read_text("utf-8"))
|
| 51 |
+
return {k: Preset.from_dict(v) for k, v in data.items()}
|
| 52 |
+
except (FileNotFoundError, json.JSONDecodeError, TypeError) as e:
|
| 53 |
+
raise ValueError(f"Failed to load templates: {e}")
|
| 54 |
+
|
| 55 |
+
def pick_styles(n: int, pattern: str = "*", *, seed: int | None = None,
|
| 56 |
+
presets: Dict[str, Preset] | None = None) -> List[str]:
|
| 57 |
+
if n <= 0:
|
| 58 |
+
raise ValueError(f"n must be positive, got {n}")
|
| 59 |
+
|
| 60 |
+
db = presets or load_templates()
|
| 61 |
+
many = pattern.endswith("!")
|
| 62 |
+
pat = pattern[:-1] if many else pattern
|
| 63 |
+
pool = [k for k in db if pat == "*" or fnmatch.fnmatch(k, pat)]
|
| 64 |
+
|
| 65 |
+
if not pool:
|
| 66 |
+
available = list(db.keys())[:5] # Show first 5
|
| 67 |
+
raise ValueError(f"No preset matches '{pat}'. Available: {available}...")
|
| 68 |
+
|
| 69 |
+
rng = random.Random(seed)
|
| 70 |
+
return [rng.choice(pool) for _ in range(n)] if (many or pat == "*") else [rng.choice(pool)] * n
|
| 71 |
+
|
| 72 |
+
################################################################################
|
| 73 |
+
# Low‑level helpers
|
| 74 |
+
################################################################################
|
| 75 |
+
|
| 76 |
+
def _ease(expr: str, mode: str) -> str:
|
| 77 |
+
easing_funcs = {
|
| 78 |
+
"linear": lambda e: e,
|
| 79 |
+
"ease-in": lambda e: f"({e})*({e})",
|
| 80 |
+
"ease-out": lambda e: f"1-((1-({e}))*(1-({e})))",
|
| 81 |
+
"ease-in-out": lambda e: f"(0.5-0.5*cos(pi*({e})))"
|
| 82 |
+
}
|
| 83 |
+
return easing_funcs.get(mode, easing_funcs["linear"])(expr)
|
| 84 |
+
|
| 85 |
+
_ANCHOR_MAP = {
|
| 86 |
+
"center": ("iw/2-(iw/zoom/2)", "ih/2-(ih/zoom/2)"),
|
| 87 |
+
"left_top": ("0", "0"),
|
| 88 |
+
"right_bottom": ("iw-iw/zoom", "ih-ih/zoom"),
|
| 89 |
+
"left_center": ("0", "ih/2-(ih/zoom/2)"),
|
| 90 |
+
"right_center": ("iw-iw/zoom", "ih/2-(ih/zoom/2)"),
|
| 91 |
+
"center_top": ("iw/2-(iw/zoom/2)", "0"),
|
| 92 |
+
"center_bottom": ("iw/2-(iw/zoom/2)", "ih-ih/zoom"),
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
def _anchor(a: str) -> Tuple[str, str]:
|
| 96 |
+
return _ANCHOR_MAP.get(a, _ANCHOR_MAP["center"])
|
| 97 |
+
|
| 98 |
+
################################################################################
|
| 99 |
+
# Preset → zoompan string
|
| 100 |
+
################################################################################
|
| 101 |
+
|
| 102 |
+
def to_zoompan(p: Preset, frames: int) -> str:
|
| 103 |
+
if frames <= 0:
|
| 104 |
+
raise ValueError(f"frames must be positive, got {frames}")
|
| 105 |
+
|
| 106 |
+
t = _ease(f"in/{frames}", p.ease)
|
| 107 |
+
|
| 108 |
+
# Validation helpers
|
| 109 |
+
def require(*fields):
|
| 110 |
+
missing = [f for f in fields if getattr(p, f) is None]
|
| 111 |
+
if missing:
|
| 112 |
+
raise ValueError(f"{p.kind} preset missing: {missing}")
|
| 113 |
+
|
| 114 |
+
# Zoom operations
|
| 115 |
+
if p.kind == "zoom":
|
| 116 |
+
require("start", "end")
|
| 117 |
+
z = f"'{p.start}+({p.end-p.start})*({t})'"
|
| 118 |
+
x, y = _anchor(p.anchor or "center")
|
| 119 |
+
return f"zoompan=z={z}:x='{x}':y='{y}':d={frames},setsar=1"
|
| 120 |
+
|
| 121 |
+
# Pan operations
|
| 122 |
+
elif p.kind == "pan":
|
| 123 |
+
require("dx", "dy")
|
| 124 |
+
x, y = f"'{p.dx}*iw*({t})'", f"'{p.dy}*ih*({t})'"
|
| 125 |
+
return f"zoompan=z='1':x={x}:y={y}:d={frames},setsar=1"
|
| 126 |
+
|
| 127 |
+
# Combined operations
|
| 128 |
+
elif p.kind == "zoom_pan":
|
| 129 |
+
require("start", "end", "dx", "dy")
|
| 130 |
+
z = f"'{p.start}+({p.end-p.start})*({t})'"
|
| 131 |
+
x = f"'iw/2-(iw/zoom/2)+{p.dx}*iw*({t})'"
|
| 132 |
+
y = f"'ih/2-(ih/zoom/2)+{p.dy}*ih*({t})'"
|
| 133 |
+
return f"zoompan=z={z}:x={x}:y={y}:d={frames},setsar=1"
|
| 134 |
+
|
| 135 |
+
elif p.kind == "zoom_rotate":
|
| 136 |
+
require("start", "end", "rot_start", "rot_end")
|
| 137 |
+
z = f"'{p.start}+({p.end-p.start})*({t})'"
|
| 138 |
+
x, y = _anchor(p.anchor or "center")
|
| 139 |
+
rot = f"rotate={p.rot_start}+({p.rot_end-p.rot_start})*({t})"
|
| 140 |
+
return f"zoompan=z={z}:x='{x}':y='{y}':d={frames},setsar=1,{rot}"
|
| 141 |
+
|
| 142 |
+
elif p.kind == "zoom_pan_rotate":
|
| 143 |
+
require("start", "end", "dx", "dy", "rot_start", "rot_end")
|
| 144 |
+
z = f"'{p.start}+({p.end-p.start})*({t})'"
|
| 145 |
+
x = f"'iw/2-(iw/zoom/2)+{p.dx}*iw*({t})'"
|
| 146 |
+
y = f"'ih/2-(ih/zoom/2)+{p.dy}*ih*({t})'"
|
| 147 |
+
rot = f"rotate={p.rot_start}+({p.rot_end-p.rot_start})*({t})"
|
| 148 |
+
return f"zoompan=z={z}:x={x}:y={y}:d={frames},setsar=1,{rot}"
|
| 149 |
+
|
| 150 |
+
else:
|
| 151 |
+
valid_kinds = {"zoom", "pan", "zoom_pan", "zoom_rotate", "zoom_pan_rotate"}
|
| 152 |
+
raise ValueError(f"Unsupported kind '{p.kind}'. Valid: {valid_kinds}")
|
| 153 |
+
|
| 154 |
+
################################################################################
|
| 155 |
+
# FFmpeg command builder / runner
|
| 156 |
+
################################################################################
|
| 157 |
+
|
| 158 |
+
def _xfade_info(durs: Sequence[float], fx: float, lo: float = 0.1, hi: float = 1.0) -> Tuple[List[float], List[float]]:
|
| 159 |
+
if len(durs) < 2:
|
| 160 |
+
return [], []
|
| 161 |
+
|
| 162 |
+
# Clamp crossfade duration to reasonable bounds
|
| 163 |
+
cf = [max(lo, min(hi, fx * min(durs[i], durs[i+1]))) for i in range(len(durs)-1)]
|
| 164 |
+
off = [durs[0]]
|
| 165 |
+
for i in range(1, len(durs)-1):
|
| 166 |
+
off.append(off[-1] + durs[i] - cf[i-1])
|
| 167 |
+
return cf, off
|
| 168 |
+
|
| 169 |
+
def build_cmd(images: Sequence[str], durations: Sequence[float], *,
|
| 170 |
+
style: str = "*", fps: int = 30, cf_factor: float = 0.15,
|
| 171 |
+
seed: int | None = None, out: Union[str, Path] = "slideshow.mp4",
|
| 172 |
+
presets: Dict[str, Preset] | None = None) -> str:
|
| 173 |
+
|
| 174 |
+
# Input validation
|
| 175 |
+
if len(images) != len(durations):
|
| 176 |
+
raise ValueError(f"Mismatch: {len(images)} images vs {len(durations)} durations")
|
| 177 |
+
if not images:
|
| 178 |
+
raise ValueError("No images provided")
|
| 179 |
+
if any(d <= 0 for d in durations):
|
| 180 |
+
bad_durs = [d for d in durations if d <= 0]
|
| 181 |
+
raise ValueError(f"All durations must be positive, got: {bad_durs}")
|
| 182 |
+
if fps <= 0:
|
| 183 |
+
raise ValueError(f"fps must be positive, got {fps}")
|
| 184 |
+
if not (0 <= cf_factor <= 1):
|
| 185 |
+
raise ValueError(f"cf_factor must be 0-1, got {cf_factor}")
|
| 186 |
+
|
| 187 |
+
# Load presets and pick styles
|
| 188 |
+
db = presets or load_templates()
|
| 189 |
+
styles = pick_styles(len(images), style, seed=seed, presets=db)
|
| 190 |
+
|
| 191 |
+
# Verify all styles exist
|
| 192 |
+
missing = [s for s in styles if s not in db]
|
| 193 |
+
if missing:
|
| 194 |
+
raise ValueError(f"Styles not found: {missing}")
|
| 195 |
+
|
| 196 |
+
# Build filters
|
| 197 |
+
filters = []
|
| 198 |
+
for i, (img, dur, sname) in enumerate(zip(images, durations, styles)):
|
| 199 |
+
frames = max(1, int(dur * fps)) # Ensure at least 1 frame
|
| 200 |
+
filter_str = to_zoompan(db[sname], frames)
|
| 201 |
+
filters.append(f"[{i}:v]{filter_str}[v{i}]")
|
| 202 |
+
|
| 203 |
+
# Handle single image case
|
| 204 |
+
if len(images) == 1:
|
| 205 |
+
fc = filters[0].replace("[v0]", "[vout]")
|
| 206 |
+
else:
|
| 207 |
+
# Build crossfade chain
|
| 208 |
+
cfs, ofs = _xfade_info(durations, cf_factor)
|
| 209 |
+
chain = []
|
| 210 |
+
for i in range(len(images) - 1):
|
| 211 |
+
left = "[v0]" if i == 0 else f"[x{i}]"
|
| 212 |
+
right = f"[v{i+1}]"
|
| 213 |
+
output = f"[x{i+1}]" if i < len(images) - 2 else "[vout]"
|
| 214 |
+
duration = cfs[i] if i < len(cfs) else 0.5
|
| 215 |
+
offset = ofs[i] if i < len(ofs) else 0.0
|
| 216 |
+
chain.append(f"{left}{right}xfade=transition=fade:duration={duration:.3f}:offset={offset:.3f}{output}")
|
| 217 |
+
fc = ";".join(filters + chain)
|
| 218 |
+
|
| 219 |
+
# Build command
|
| 220 |
+
inputs = []
|
| 221 |
+
for img, d in zip(images, durations):
|
| 222 |
+
inputs.extend(['-loop', '1', '-t', str(d), '-i', str(img)])
|
| 223 |
+
|
| 224 |
+
cmd_parts = [
|
| 225 |
+
"ffmpeg", *inputs,
|
| 226 |
+
"-filter_complex", fc,
|
| 227 |
+
"-map", "[vout]",
|
| 228 |
+
"-r", str(fps),
|
| 229 |
+
"-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p",
|
| 230 |
+
"-y", str(out)
|
| 231 |
+
]
|
| 232 |
+
|
| 233 |
+
return shlex.join(cmd_parts)
|
| 234 |
+
|
| 235 |
+
def generate_video(images: Sequence[str], durations: Sequence[float], **kw) -> Tuple[Path, str]:
|
| 236 |
+
# Check images exist
|
| 237 |
+
missing = [img for img in images if not Path(img).exists()]
|
| 238 |
+
if missing:
|
| 239 |
+
raise FileNotFoundError(f"Images not found: {missing}")
|
| 240 |
+
|
| 241 |
+
cmd = build_cmd(images, durations, **kw)
|
| 242 |
+
output_path = Path(kw.get("out", "slideshow.mp4"))
|
| 243 |
+
|
| 244 |
+
try:
|
| 245 |
+
subprocess.run(cmd, shell=True, check=True,
|
| 246 |
+
stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL)
|
| 247 |
+
except subprocess.CalledProcessError as e:
|
| 248 |
+
raise RuntimeError(f"FFmpeg failed (code {e.returncode}). Check: images exist, ffmpeg installed")
|
| 249 |
+
except FileNotFoundError:
|
| 250 |
+
raise FileNotFoundError("FFmpeg not found. Install ffmpeg and add to PATH")
|
| 251 |
+
|
| 252 |
+
return output_path, cmd
|
motion.json
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"zoom_in_center": {
|
| 3 |
+
"kind": "zoom", "start": 1.00, "end": 1.30, "anchor": "center",
|
| 4 |
+
"ease": "linear",
|
| 5 |
+
"tags": ["focus", "calm", "intro"],
|
| 6 |
+
"desc": "Zoom-in 30 % giữ tâm ảnh, làm nổi bật nhân vật/đối tượng chính."
|
| 7 |
+
},
|
| 8 |
+
"zoom_out_center": {
|
| 9 |
+
"kind": "zoom", "start": 1.35, "end": 1.00, "anchor": "center",
|
| 10 |
+
"ease": "linear",
|
| 11 |
+
"tags": ["context", "reveal", "calm"],
|
| 12 |
+
"desc": "Zoom-out mở rộng khung cảnh, dùng sau cận cảnh để hé lộ bối cảnh."
|
| 13 |
+
},
|
| 14 |
+
"zoom_in_leftTop": {
|
| 15 |
+
"kind": "zoom", "start": 1.00, "end": 1.25, "anchor": "left_top",
|
| 16 |
+
"ease": "linear",
|
| 17 |
+
"tags": ["rule_of_thirds", "tension", "focus"],
|
| 18 |
+
"desc": "Zoom-in vào góc 1/3 trái-trên, tạo cảm giác bí ẩn hoặc căng thẳng nhẹ."
|
| 19 |
+
},
|
| 20 |
+
"zoom_in_rightBottom": {
|
| 21 |
+
"kind": "zoom", "start": 1.00, "end": 1.25, "anchor": "right_bottom",
|
| 22 |
+
"ease": "linear",
|
| 23 |
+
"tags": ["rule_of_thirds", "tension", "focus"],
|
| 24 |
+
"desc": "Zoom-in góc 1/3 phải-dưới, thích hợp nhấn mạnh chi tiết nhỏ."
|
| 25 |
+
},
|
| 26 |
+
"zoom_out_leftBottom": {
|
| 27 |
+
"kind": "zoom", "start": 1.30, "end": 1.00, "anchor": "left_bottom",
|
| 28 |
+
"ease": "linear",
|
| 29 |
+
"tags": ["reveal", "dramatic"],
|
| 30 |
+
"desc": "Zoom-out từ góc trái-dưới để lộ bố cục rộng."
|
| 31 |
+
},
|
| 32 |
+
"zoom_out_rightTop": {
|
| 33 |
+
"kind": "zoom", "start": 1.30, "end": 1.00, "anchor": "right_top",
|
| 34 |
+
"ease": "linear",
|
| 35 |
+
"tags": ["reveal", "dramatic"],
|
| 36 |
+
"desc": "Zoom-out từ góc phải-trên, tạo cảm giác thoát lên cao."
|
| 37 |
+
},
|
| 38 |
+
|
| 39 |
+
"pan_left": {
|
| 40 |
+
"kind": "pan", "dx": -0.25, "dy": 0.00,
|
| 41 |
+
"ease": "linear",
|
| 42 |
+
"tags": ["flow", "dynamic", "reading_dir"],
|
| 43 |
+
"desc": "Trượt trái 25 % khung, dẫn mắt thuận chiều đọc (trái → phải)."
|
| 44 |
+
},
|
| 45 |
+
"pan_right": {
|
| 46 |
+
"kind": "pan", "dx": 0.25, "dy": 0.00,
|
| 47 |
+
"ease": "linear",
|
| 48 |
+
"tags": ["flow", "dynamic", "reverse"],
|
| 49 |
+
"desc": "Trượt phải, hay dùng cho hồi tưởng hoặc nhân vật rời khung."
|
| 50 |
+
},
|
| 51 |
+
"pan_up": {
|
| 52 |
+
"kind": "pan", "dx": 0.00, "dy": -0.25,
|
| 53 |
+
"ease": "linear",
|
| 54 |
+
"tags": ["vertical", "reveal"],
|
| 55 |
+
"desc": "Pan-up 25 %: từ đất lên trời, mở khung cao."
|
| 56 |
+
},
|
| 57 |
+
"pan_down": {
|
| 58 |
+
"kind": "pan", "dx": 0.00, "dy": 0.25,
|
| 59 |
+
"ease": "linear",
|
| 60 |
+
"tags": ["vertical", "reveal"],
|
| 61 |
+
"desc": "Pan-down 25 %: từ trời xuống nhân vật/chi tiết."
|
| 62 |
+
},
|
| 63 |
+
"pan_diagonal_ul": {
|
| 64 |
+
"kind": "pan", "dx": -0.20, "dy": -0.20,
|
| 65 |
+
"ease": "linear",
|
| 66 |
+
"tags": ["diagonal", "energy", "comic"],
|
| 67 |
+
"desc": "Pan chéo lên-trái 20 %, tăng nhịp hành động."
|
| 68 |
+
},
|
| 69 |
+
"pan_diagonal_dr": {
|
| 70 |
+
"kind": "pan", "dx": 0.20, "dy": 0.20,
|
| 71 |
+
"ease": "linear",
|
| 72 |
+
"tags": ["diagonal", "energy", "comic"],
|
| 73 |
+
"desc": "Pan chéo xuống-phải, tạo cảm giác rơi/tuột nhanh."
|
| 74 |
+
},
|
| 75 |
+
|
| 76 |
+
"zoom_pan_left": {
|
| 77 |
+
"kind": "zoom_pan",
|
| 78 |
+
"start": 1.00, "end": 1.25, "dx": -0.20, "dy": 0.00,
|
| 79 |
+
"ease": "linear",
|
| 80 |
+
"tags": ["combo", "immersive"],
|
| 81 |
+
"desc": "Vừa pan trái vừa zoom-in 25 %, hiệu ứng dolly-tracking nhẹ."
|
| 82 |
+
},
|
| 83 |
+
"zoom_pan_right": {
|
| 84 |
+
"kind": "zoom_pan",
|
| 85 |
+
"start": 1.00, "end": 1.25, "dx": 0.20, "dy": 0.00,
|
| 86 |
+
"ease": "linear",
|
| 87 |
+
"tags": ["combo", "immersive"],
|
| 88 |
+
"desc": "Pan phải + zoom-in, hướng ánh nhìn theo chuyển động nhân vật."
|
| 89 |
+
},
|
| 90 |
+
"zoom_pan_up": {
|
| 91 |
+
"kind": "zoom_pan",
|
| 92 |
+
"start": 1.00, "end": 1.25, "dx": 0.00, "dy": -0.20,
|
| 93 |
+
"ease": "linear",
|
| 94 |
+
"tags": ["combo", "vertical"],
|
| 95 |
+
"desc": "Kéo lên trên và phóng to nhẹ, gợi cảm giác vươn cao."
|
| 96 |
+
},
|
| 97 |
+
"zoom_pan_down": {
|
| 98 |
+
"kind": "zoom_pan",
|
| 99 |
+
"start": 1.00, "end": 1.25, "dx": 0.00, "dy": 0.20,
|
| 100 |
+
"ease": "linear",
|
| 101 |
+
"tags": ["combo", "vertical"],
|
| 102 |
+
"desc": "Kéo xuống và phóng to nhẹ – phù hợp chuyển cảnh sky-to-subject."
|
| 103 |
+
},
|
| 104 |
+
"zoom_pan_diag_ul": {
|
| 105 |
+
"kind": "zoom_pan",
|
| 106 |
+
"start": 1.00, "end": 1.30, "dx": -0.15, "dy": -0.15,
|
| 107 |
+
"ease": "linear",
|
| 108 |
+
"tags": ["combo", "diagonal", "adventure"],
|
| 109 |
+
"desc": "Zoom + pan chéo lên-trái; dùng cho đoạn cao trào, phiêu lưu."
|
| 110 |
+
},
|
| 111 |
+
"zoom_pan_diag_dr": {
|
| 112 |
+
"kind": "zoom_pan",
|
| 113 |
+
"start": 1.00, "end": 1.30, "dx": 0.15, "dy": 0.15,
|
| 114 |
+
"ease": "linear",
|
| 115 |
+
"tags": ["combo", "diagonal", "adventure"],
|
| 116 |
+
"desc": "Zoom + pan chéo xuống-phải; nhấn tốc độ và hướng rơi."
|
| 117 |
+
},
|
| 118 |
+
|
| 119 |
+
"rotate_zoom_cw": {
|
| 120 |
+
"kind": "zoom_rotate",
|
| 121 |
+
"start": 1.00, "end": 1.25,
|
| 122 |
+
"rot_start": 0, "rot_end": 3,
|
| 123 |
+
"anchor": "center",
|
| 124 |
+
"ease": "linear",
|
| 125 |
+
"tags": ["drama", "unsettle"],
|
| 126 |
+
"desc": "Zoom-in kèm xoay 3° chiều kim đồng hồ, gợi cảm giác bất ổn."
|
| 127 |
+
},
|
| 128 |
+
"rotate_zoom_ccw": {
|
| 129 |
+
"kind": "zoom_rotate",
|
| 130 |
+
"start": 1.00, "end": 1.25,
|
| 131 |
+
"rot_start": 0, "rot_end": -3,
|
| 132 |
+
"anchor": "center",
|
| 133 |
+
"ease": "linear",
|
| 134 |
+
"tags": ["drama", "unsettle"],
|
| 135 |
+
"desc": "Zoom-in kèm xoay 3° ngược kim đồng hồ, tăng căng thẳng."
|
| 136 |
+
},
|
| 137 |
+
|
| 138 |
+
"parallax_left": {
|
| 139 |
+
"kind": "zoom_pan_rotate",
|
| 140 |
+
"start": 1.00, "end": 1.20,
|
| 141 |
+
"dx": -0.12, "dy": 0.00,
|
| 142 |
+
"rot_start": 0, "rot_end": 2,
|
| 143 |
+
"anchor": "center",
|
| 144 |
+
"ease": "linear",
|
| 145 |
+
"tags": ["parallax", "depth", "immersive"],
|
| 146 |
+
"desc": "Hiệu ứng parallax nhẹ: zoom-in + pan trái + xoay 2°."
|
| 147 |
+
},
|
| 148 |
+
"parallax_right": {
|
| 149 |
+
"kind": "zoom_pan_rotate",
|
| 150 |
+
"start": 1.00, "end": 1.20,
|
| 151 |
+
"dx": 0.12, "dy": 0.00,
|
| 152 |
+
"rot_start": 0, "rot_end": -2,
|
| 153 |
+
"anchor": "center",
|
| 154 |
+
"ease": "linear",
|
| 155 |
+
"tags": ["parallax", "depth", "immersive"],
|
| 156 |
+
"desc": "Parallax zoom-in + pan phải + xoay −2°."
|
| 157 |
+
},
|
| 158 |
+
|
| 159 |
+
"dolly_in_slow": {
|
| 160 |
+
"kind": "zoom", "start": 1.00, "end": 1.15, "anchor": "center",
|
| 161 |
+
"ease": "ease-in-out",
|
| 162 |
+
"tags": ["slow", "emotional", "close_up"],
|
| 163 |
+
"desc": "Zoom-in 15 % rất chậm (ease-in-out) cho khoảnh khắc cảm xúc."
|
| 164 |
+
},
|
| 165 |
+
"dolly_out_fast": {
|
| 166 |
+
"kind": "zoom", "start": 1.40, "end": 1.00, "anchor": "center",
|
| 167 |
+
"ease": "ease-out",
|
| 168 |
+
"tags": ["fast", "shock", "reveal"],
|
| 169 |
+
"desc": "Zoom-out 40 % nhanh (ease-out) tạo cú giật lùi, lộ plot-twist."
|
| 170 |
+
}
|
| 171 |
+
}
|