forked from 1b-agent/fieldcraft-upstream
docs: a flow-past gallery clip and solved still
Add the start-up-transient clip and a solved still of the flow-past sample to the examples gallery: air flowing past the L-bracket, the wake forming behind the upright and the drag settling over the 16 s run (it does not shed; that needs a finer grid or a collision operator stable near the lattice's stability limit, #557). tools/flow-clip/make.py regenerates them: it drives the desktop app over the control channel, solves the lattice Boltzmann step with a dense frame output, renders the model-only viewport at each frame with a fixed legend clamp (results.frame then ui.render), and encodes an MP4 and a GIF with ffmpeg, each frame captioned with the elapsed flow time. The geometry is the sample's own upright-bracket recipe; nothing comes from a commercial product. Part of #164.
This commit is contained in:
1 parent
6996fd71b0
commit
8201f9a954
7 files changed
+306
-2
No files matched your search
+3
-1
@@ -25,7 +25,9 @@ is committed and listed here with its exports.
|
|||||||
| docs/media/examples/crank-arm.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-arm sample (docs/examples.md) | MIT OR Apache-2.0 |
|
| docs/media/examples/crank-arm.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-arm sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||||
| docs/media/examples/crank-mirror.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-mirror sample (docs/examples.md) | MIT OR Apache-2.0 |
|
| docs/media/examples/crank-mirror.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved crank-mirror sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||||
| docs/meshing/skill/analytic-cylinder.png, docs/meshing/skill/analytic-pipe.png, docs/meshing/skill/analytic-plate-hole.png, docs/meshing/skill/analytic-sphere.png, docs/meshing/skill/analytic-torus.png, docs/meshing/skill/bent-tube.png, docs/meshing/skill/bolted-assembly.png, docs/meshing/skill/cantilever-i-beam.png, docs/meshing/skill/crank-arm.png, docs/meshing/skill/cup.png, docs/meshing/skill/filleted-bracket-blocks.png, docs/meshing/skill/filleted-bracket.png, docs/meshing/skill/i-beam.png, docs/meshing/skill/loft-transition.png, docs/meshing/skill/perforated-plate.png, docs/meshing/skill/pi5-base.png, docs/meshing/skill/pi5-lid.png, docs/meshing/skill/pinhole-block.png, docs/meshing/skill/plain-cup.png, docs/meshing/skill/rounded-box.png, docs/meshing/skill/shallow-groove.png, docs/meshing/skill/sharp-wedge.png, docs/meshing/skill/sphere-on-plate.png, docs/meshing/skill/spline-vase.png, docs/meshing/skill/stepped-block.png, docs/meshing/skill/thin-ribs.png, docs/meshing/skill/thin-wall-enclosure.png, docs/meshing/skill/upright-bracket.png | FieldCraft contributors | Original: section pictures of the corpus parts and samples meshed by the meshing skill, drawn by `fieldcraft-cli snapshot` (`cargo xtask skill-results`, docs/meshing/skill-results.md) | MIT OR Apache-2.0 |
|
| docs/meshing/skill/analytic-cylinder.png, docs/meshing/skill/analytic-pipe.png, docs/meshing/skill/analytic-plate-hole.png, docs/meshing/skill/analytic-sphere.png, docs/meshing/skill/analytic-torus.png, docs/meshing/skill/bent-tube.png, docs/meshing/skill/bolted-assembly.png, docs/meshing/skill/cantilever-i-beam.png, docs/meshing/skill/crank-arm.png, docs/meshing/skill/cup.png, docs/meshing/skill/filleted-bracket-blocks.png, docs/meshing/skill/filleted-bracket.png, docs/meshing/skill/i-beam.png, docs/meshing/skill/loft-transition.png, docs/meshing/skill/perforated-plate.png, docs/meshing/skill/pi5-base.png, docs/meshing/skill/pi5-lid.png, docs/meshing/skill/pinhole-block.png, docs/meshing/skill/plain-cup.png, docs/meshing/skill/rounded-box.png, docs/meshing/skill/shallow-groove.png, docs/meshing/skill/sharp-wedge.png, docs/meshing/skill/sphere-on-plate.png, docs/meshing/skill/spline-vase.png, docs/meshing/skill/stepped-block.png, docs/meshing/skill/thin-ribs.png, docs/meshing/skill/thin-wall-enclosure.png, docs/meshing/skill/upright-bracket.png | FieldCraft contributors | Original: section pictures of the corpus parts and samples meshed by the meshing skill, drawn by `fieldcraft-cli snapshot` (`cargo xtask skill-results`, docs/meshing/skill-results.md) | MIT OR Apache-2.0 |
|
||||||
| docs/media/examples/flow-past.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the flow-past sample (its bracket; the viewport does not draw flow results yet) (docs/examples.md) | MIT OR Apache-2.0 |
|
| docs/media/examples/flow-past.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the flow-past sample before solving (its bracket as the sample opens) (docs/examples.md) | MIT OR Apache-2.0 |
|
||||||
|
| docs/media/examples/flow-past-flow.png | FieldCraft contributors | Original: the solved flow-past sample rendered by FieldCraft (`ui.render`, light theme), velocity on the mid-slice with the bracket and upstream streamlines, generated by `tools/flow-clip/make.py` | MIT OR Apache-2.0 |
|
||||||
|
| docs/media/examples/flow-past-transient.mp4 | FieldCraft contributors | Original: the flow-past start-up transient rendered frame by frame by FieldCraft (`ui.render`), generated by `tools/flow-clip/make.py` (captions drawn by `tools/demo-video/caption.py` in Inter, OFL, crates/ui-egui/fonts) | MIT OR Apache-2.0 |
|
||||||
| docs/media/examples/cantilever.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved cantilever sample (docs/examples.md) | MIT OR Apache-2.0 |
|
| docs/media/examples/cantilever.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved cantilever sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||||
| docs/media/examples/impact.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved impact sample (docs/examples.md) | MIT OR Apache-2.0 |
|
| docs/media/examples/impact.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved impact sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||||
| docs/media/examples/ibeam.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved ibeam sample (docs/examples.md) | MIT OR Apache-2.0 |
|
| docs/media/examples/ibeam.png | FieldCraft contributors | Original: the FieldCraft window captured (`ui.capture`) with the solved ibeam sample (docs/examples.md) | MIT OR Apache-2.0 |
|
||||||
|
|||||||
+10
@@ -130,3 +130,13 @@ Temperature is available on fluid slices, obstacle surfaces and streamline colou
|
|||||||
saved frames carry it. Signed face-average wall heat follows recorded histories and the
|
saved frames carry it. Signed face-average wall heat follows recorded histories and the
|
||||||
model's power/area units. Commands, the Results panel and headless pictures share the view;
|
model's power/area units. Commands, the Results panel and headless pictures share the view;
|
||||||
initial frames without recorded wall heat remain uncoloured.
|
initial frames without recorded wall heat remain uncoloured.
|
||||||
|
|
||||||
|
### Flow start-up transient and gallery clip (#164)
|
||||||
|
|
||||||
|
`results.animate` now plays a fluid step's frames in order, the start-up transient from the
|
||||||
|
uniform initial field to the settled flow, the way an explicit step plays its frames; the fluid Results
|
||||||
|
panel gains an Animate control. The `flow-past` gallery sample gains a clip and a solved still of
|
||||||
|
the air flowing past the L-bracket, regenerated by `tools/flow-clip/make.py` (the app rendered
|
||||||
|
frame by frame over the control channel, encoded with ffmpeg). The wake forms and the drag
|
||||||
|
settles; it does not shed (that needs a finer grid or a collision operator stable near τ⁺ = 0.5,
|
||||||
|
#557).
|
||||||
+9
-1
@@ -52,7 +52,7 @@ contact (#161) the ring will be pressed by contact against a groove.
|
|||||||
Air (ρ = 1.2 kg/m³, μ = 1.8 × 10⁻⁵ Pa s) at 25 mm/s past the same L-bracket standing on the
|
Air (ρ = 1.2 kg/m³, μ = 1.8 × 10⁻⁵ Pa s) at 25 mm/s past the same L-bracket standing on the
|
||||||
floor, the upright's back to the wind. A 240 × 120 × 100 mm box of 2.5 mm cells (184 320),
|
floor, the upright's back to the wind. A 240 × 120 × 100 mm box of 2.5 mm cells (184 320),
|
||||||
uniform inlet, outlet at zero pressure, a no-slip floor and slip walls at the sides and top;
|
uniform inlet, outlet at zero pressure, a no-slip floor and slip walls at the sides and top;
|
||||||
D3Q19 lattice Boltzmann (TRT) for 16 s, about 6.7 flow-throughs, ten frames. Reynolds number
|
D3Q19 lattice Boltzmann (TRT) for 16 s, about 1.7 passes through the 240 mm box, ten frames. Reynolds number
|
||||||
83 on the upright's 50 mm height: laminar, a steady wake. About 10 s.
|
83 on the upright's 50 mm height: laminar, a steady wake. About 10 s.
|
||||||
|
|
||||||
**Look at:** the step's report (`solve.fluid`, or Results after Solve): the force on every
|
**Look at:** the step's report (`solve.fluid`, or Results after Solve): the force on every
|
||||||
@@ -72,6 +72,14 @@ velocity magnitude on a slice through the middle of the box (`results.slice`; pr
|
|||||||
`field: "pressure"`), the bracket coloured by the flow beside it (`results.surface`) and
|
`field: "pressure"`), the bracket coloured by the flow beside it (`results.surface`) and
|
||||||
streamlines seeded upstream of it, coloured by speed (`results.streamlines`).
|
streamlines seeded upstream of it, coloured by speed (`results.streamlines`).
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
**Watch it start up:** a fluid step animates its frames (Results ▸ Animate, or `results.animate`),
|
||||||
|
playing the start-up transient from the uniform 25 mm/s initial field to the settled wake — the
|
||||||
|
wake forming behind the upright and the drag settling over the 16 s run. A clip is at
|
||||||
|
[flow-past-transient.mp4](media/examples/flow-past-transient.mp4), regenerated by
|
||||||
|
[tools/flow-clip](../tools/flow-clip/README.md).
|
||||||
|
|
||||||
## Heated bracket (`heated-bracket`)
|
## Heated bracket (`heated-bracket`)
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
Binary file not shown.
@@ -0,0 +1,39 @@
|
|||||||
|
# Flow-past clip
|
||||||
|
|
||||||
|
`make.py` regenerates the gallery clip and still of the `flow-past` sample (docs/examples.md):
|
||||||
|
`docs/media/examples/flow-past-transient.mp4` (the start-up transient of air flowing past the
|
||||||
|
L-bracket, from the uniform 25 mm/s initial field to the settled wake) and
|
||||||
|
`docs/media/examples/flow-past-flow.png` (a clean iso render of the settled flow).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/flow-clip/make.py # build, run, encode into docs/media/examples
|
||||||
|
python3 tools/flow-clip/make.py --no-build # with the release binaries already built
|
||||||
|
python3 tools/flow-clip/make.py --frames /tmp/f # also keep the rendered pictures
|
||||||
|
python3 tools/flow-clip/make.py --gif # also write a GIF (not committed; larger)
|
||||||
|
```
|
||||||
|
|
||||||
|
Needs Python 3 (standard library only), a display for the FieldCraft window (the pictures are
|
||||||
|
drawn offscreen with `ui.render`, so a locked or virtual display is fine — on a headless machine
|
||||||
|
run it under `xvfb-run`), and `ffmpeg` with libx264 on the `PATH` (or `FFMPEG`). It honours
|
||||||
|
`CARGO_TARGET_DIR`; set `FIELDCRAFT_GPU=software` for a software renderer.
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
|
||||||
|
1. Builds `fieldcraft` and `fieldcraft-cli` (release) and starts the app with `--control PORT`.
|
||||||
|
2. Opens the `flow-past` sample and, so the transient plays smoothly, edits its fluid step to
|
||||||
|
store `FLOW_CLIP_FRAMES` frames (48 by default) over the 16 s run (`model.step.edit`), then
|
||||||
|
solves the lattice Boltzmann step on the CPU (`solve.fluid`).
|
||||||
|
3. Fixes the velocity legend clamp (0 to 45 mm/s) so the colours mean the same thing from frame
|
||||||
|
to frame, sets a light theme and an iso view, and renders the model-only viewport at each
|
||||||
|
field frame (`results.frame` then `ui.render`): velocity on the mid-slice, the bracket
|
||||||
|
coloured by the flow beside it, and streamlines seeded upstream, coloured by speed.
|
||||||
|
4. Captions each frame with the elapsed flow time in a band below, drawn by the demo-video tool's
|
||||||
|
standard-library TrueType caption drawer (`tools/demo-video/caption.py`, Inter SemiBold).
|
||||||
|
5. Encodes the frames at 12 fps (the settled wake held at the end) into the MP4 (H.264) and writes
|
||||||
|
the settled still. `--gif` also writes a GIF (800 px, its own palette); it is not committed
|
||||||
|
because it is several times larger than the MP4.
|
||||||
|
|
||||||
|
The clip shows the wake forming behind the upright and the drag settling; it does not shed, which
|
||||||
|
needs a finer grid or a collision operator stable near τ⁺ = 0.5 (see issue #557). The geometry is
|
||||||
|
the original upright-bracket SolidCraft recipe (the `flow-past` sample); nothing comes from a
|
||||||
|
commercial product.
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Regenerate the flow-past gallery clip and still image: the start-up transient of air flowing
|
||||||
|
past the L-bracket, from the uniform 25 mm/s initial field to the settled wake.
|
||||||
|
|
||||||
|
The FieldCraft desktop app opens the `flow-past` sample, solves the lattice Boltzmann step with a
|
||||||
|
dense frame output, and draws the flow (velocity on a mid-slice, the bracket coloured by the flow
|
||||||
|
beside it, streamlines seeded upstream). Each frame of the step is rendered through the control
|
||||||
|
channel (`ui.render`, the model only, no window furniture), with a fixed legend clamp so the
|
||||||
|
colours mean the same thing from frame to frame. ffmpeg encodes the frames as `docs/media/examples/
|
||||||
|
flow-past-transient.mp4`, captioned with the elapsed flow time, and a clean iso render of the
|
||||||
|
settled flow is written as `docs/media/examples/flow-past-flow.png`. `--gif` also writes a GIF (not
|
||||||
|
committed — it is several times larger than the MP4; use it for hosting the clip elsewhere).
|
||||||
|
|
||||||
|
Python standard library only; ffmpeg encodes the frames. caption.py (the demo-video tool's
|
||||||
|
standard-library TrueType caption drawer) draws the time bars.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT / "tools" / "demo-video"))
|
||||||
|
import caption # noqa: E402 (the demo-video caption drawer, standard library only)
|
||||||
|
|
||||||
|
# The flow-past sample: air at 25 mm/s past the upright L-bracket (Reynolds number 83 on the
|
||||||
|
# 50 mm upright). The sample solves 16 s (about 1.7 passes through the 240 mm box at 25 mm/s); the
|
||||||
|
# clip plays that start-up transient. FRAMES field frames are stored (the sample's 10 is too coarse
|
||||||
|
# for a smooth clip), so the clip holds FRAMES + 1 pictures from the uniform initial field (frame 0,
|
||||||
|
# 25 mm/s everywhere) to the settled flow.
|
||||||
|
SAMPLE = "flow-past"
|
||||||
|
STEP = "Flow"
|
||||||
|
DOMAIN = "Wind"
|
||||||
|
END_TIME = float(os.environ.get("FLOW_CLIP_END_TIME", 16.0))
|
||||||
|
FRAMES = int(os.environ.get("FLOW_CLIP_FRAMES", 48))
|
||||||
|
|
||||||
|
# The render: a clean iso view of the domain, each frame the model only (no window chrome). The
|
||||||
|
# legend clamp is fixed over the whole run so the wake's colours are comparable frame to frame;
|
||||||
|
# 45 mm/s sits just above the settled peak (about 42 mm/s), 0 for the still fluid in the wake.
|
||||||
|
WIDTH, HEIGHT = 1280, 720
|
||||||
|
CLAMP = (0.0, 45.0)
|
||||||
|
CAMERA = {"yaw": -40.0, "pitch": 24.0, "target": {"center_of": "bbox"}, "fit": True}
|
||||||
|
|
||||||
|
# The video: frames per second, and how long the settled flow is held at the end.
|
||||||
|
FPS = 12
|
||||||
|
HOLD_END = 18 # frames (1.5 s) on the settled wake
|
||||||
|
|
||||||
|
# The caption bar, a band under each picture (so it never hides the flow).
|
||||||
|
CAPTION_FONT = ROOT / "crates" / "ui-egui" / "fonts" / "Inter-SemiBold.ttf"
|
||||||
|
CAPTION_HEIGHT = 44
|
||||||
|
|
||||||
|
|
||||||
|
def log(msg):
|
||||||
|
print(f"flow-clip: {msg}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def target_dir():
|
||||||
|
return Path(os.environ.get("CARGO_TARGET_DIR", ROOT / "target"))
|
||||||
|
|
||||||
|
|
||||||
|
def build():
|
||||||
|
cargo = os.environ.get("CARGO", "cargo")
|
||||||
|
subprocess.run([cargo, "build", "--release", "-p", "fieldcraft", "-p", "fieldcraft-cli"], cwd=ROOT, check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def free_port():
|
||||||
|
with socket.socket() as s:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
|
class Control:
|
||||||
|
"""A client of the FieldCraft control channel (one JSON line per request and reply)."""
|
||||||
|
|
||||||
|
def __init__(self, port, timeout=180.0):
|
||||||
|
end = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
self.sock = socket.create_connection(("127.0.0.1", port), timeout=5.0)
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
if time.monotonic() > end:
|
||||||
|
raise SystemExit(f"the control channel on port {port} did not open within {timeout:.0f} s")
|
||||||
|
time.sleep(0.5)
|
||||||
|
self.sock.settimeout(600.0)
|
||||||
|
self.file = self.sock.makefile("rwb")
|
||||||
|
self.next_id = 0
|
||||||
|
|
||||||
|
def send(self, method, params):
|
||||||
|
self.next_id += 1
|
||||||
|
self.file.write((json.dumps({"id": self.next_id, "method": method, "params": params}) + "\n").encode())
|
||||||
|
self.file.flush()
|
||||||
|
reply = json.loads(self.file.readline())
|
||||||
|
if not reply.get("ok"):
|
||||||
|
raise SystemExit(f"{method} {params}: {reply.get('error')}")
|
||||||
|
return reply.get("result")
|
||||||
|
|
||||||
|
def execute(self, command, params=None):
|
||||||
|
return self.send("engine.execute", {"command": command, "params": params or {}})
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
try:
|
||||||
|
self.send("app.quit", {})
|
||||||
|
except (OSError, SystemExit):
|
||||||
|
pass
|
||||||
|
self.file.close()
|
||||||
|
self.sock.close()
|
||||||
|
|
||||||
|
|
||||||
|
def solve(ctl):
|
||||||
|
"""Open the sample, make the step store FRAMES frames, and solve the flow."""
|
||||||
|
ctl.execute("file.sample", {"name": SAMPLE})
|
||||||
|
# The sample's step stores 10 frames; the clip wants a smoother transient.
|
||||||
|
ctl.execute("model.step.edit", {"name": STEP, "procedure": {"type": "fluid", "domain": DOMAIN, "end_time": END_TIME, "frames": FRAMES}})
|
||||||
|
log("solving the flow (lattice Boltzmann) …")
|
||||||
|
report = ctl.execute("solve.fluid", {"backend": "cpu"})
|
||||||
|
forces = report.get("forces") or report.get("faces")
|
||||||
|
log(f"solved: {json.dumps(report.get('stats', report.get('summary', {})))[:200]}")
|
||||||
|
return forces
|
||||||
|
|
||||||
|
|
||||||
|
def render_frames(ctl, frames_dir):
|
||||||
|
"""Render the model-only viewport at each field frame, with a fixed legend clamp and the iso
|
||||||
|
view, and draw its time caption. Return the list of (picture, caption) pairs."""
|
||||||
|
# The flow display the sample opens with: velocity on the mid-slice, the obstacle coloured and
|
||||||
|
# streamlines upstream. Fix the legend clamp so the colours are comparable across frames.
|
||||||
|
# A light background and shaded part, to match the gallery's still pictures.
|
||||||
|
ctl.send("ui.set", {"dark": False, "style": "shaded_edges", "show_tree": False})
|
||||||
|
ctl.execute("results.contour", {"field": "velocity", "range": {"min": CLAMP[0], "max": CLAMP[1]}})
|
||||||
|
ctl.execute("results.surface", {"show": True})
|
||||||
|
ctl.execute("results.streamlines", {"show": True})
|
||||||
|
ctl.send("ui.camera", CAMERA)
|
||||||
|
font = caption.Font.load(CAPTION_FONT)
|
||||||
|
shots = []
|
||||||
|
for k in range(FRAMES + 1):
|
||||||
|
ctl.execute("results.frame", {"frame": k})
|
||||||
|
shot = frames_dir / f"frame{k:04d}.png"
|
||||||
|
ctl.send("ui.render", {"path": str(shot), "width": WIDTH, "height": HEIGHT})
|
||||||
|
t = END_TIME * k / FRAMES
|
||||||
|
bar = frames_dir / f"cap{k:04d}.png"
|
||||||
|
bar.write_bytes(caption.caption_bar(font, (WIDTH, CAPTION_HEIGHT), "flow past a bracket", f"t = {t:4.1f} s · Re 83"))
|
||||||
|
shots.append((shot, bar))
|
||||||
|
return shots
|
||||||
|
|
||||||
|
|
||||||
|
def sequence(shots, folder):
|
||||||
|
"""Lay the clip out as numbered frames and captions (the last picture held), returning the two
|
||||||
|
ffmpeg input patterns and the frame count."""
|
||||||
|
shutil.rmtree(folder, ignore_errors=True)
|
||||||
|
(folder / "caps").mkdir(parents=True)
|
||||||
|
n = 0
|
||||||
|
held = [(shot, bar, 1) for shot, bar in shots]
|
||||||
|
held[-1] = (held[-1][0], held[-1][1], HOLD_END)
|
||||||
|
for shot, bar, count in held:
|
||||||
|
for _ in range(count):
|
||||||
|
os.link(shot, folder / f"{n:05d}.png")
|
||||||
|
os.link(bar, folder / "caps" / f"{n:05d}.png")
|
||||||
|
n += 1
|
||||||
|
return str(folder / "%05d.png"), str(folder / "caps" / "%05d.png"), n
|
||||||
|
|
||||||
|
|
||||||
|
def encode(shots, frames_dir, mp4, gif=None):
|
||||||
|
"""Encode the frames into the MP4 (committed to the repo) and, when `gif` is given, a GIF too
|
||||||
|
(not committed — it is several times larger; use it for hosting the clip elsewhere)."""
|
||||||
|
ffmpeg = os.environ.get("FFMPEG", shutil.which("ffmpeg") or "ffmpeg")
|
||||||
|
pattern, caps, count = sequence(shots, frames_dir / "sequence")
|
||||||
|
log(f"encoding {count} frames ({count / FPS:.1f} s) into {mp4.name}" + (f" and {gif.name}" if gif else ""))
|
||||||
|
src = ["-framerate", str(FPS), "-i", pattern, "-framerate", str(FPS), "-i", caps]
|
||||||
|
# Each picture padded with a band below, the caption bar laid over the band.
|
||||||
|
captioned = f"[0:v]pad={WIDTH}:{HEIGHT + CAPTION_HEIGHT}:0:0[w];[w][1:v]overlay=0:{HEIGHT}"
|
||||||
|
common = [ffmpeg, "-hide_banner", "-loglevel", "error", "-y"]
|
||||||
|
mp4.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
subprocess.run(
|
||||||
|
[*common, *src, "-filter_complex", captioned, "-c:v", "libx264", "-preset", "veryslow", "-crf", "26",
|
||||||
|
"-pix_fmt", "yuv420p", "-movflags", "+faststart", str(mp4)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
if gif:
|
||||||
|
subprocess.run(
|
||||||
|
[*common, *src, "-filter_complex",
|
||||||
|
f"{captioned},scale=800:-1:flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];"
|
||||||
|
"[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle", str(gif)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def still(ctl, path):
|
||||||
|
"""A clean iso render of the settled flow for the gallery still."""
|
||||||
|
ctl.execute("results.frame", {"frame": FRAMES})
|
||||||
|
ctl.send("ui.render", {"path": str(path), "width": WIDTH, "height": HEIGHT})
|
||||||
|
log(f"wrote the still {path}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--out", type=Path, default=ROOT / "docs" / "media" / "examples", help="folder for the clip and still")
|
||||||
|
ap.add_argument("--frames", type=Path, help="keep the rendered pictures in this folder")
|
||||||
|
ap.add_argument("--gif", action="store_true", help="also write the GIF (not committed; for hosting the clip elsewhere)")
|
||||||
|
ap.add_argument("--no-build", action="store_true", help="use the binaries already built")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not args.no_build:
|
||||||
|
build()
|
||||||
|
bin_dir = target_dir() / "release"
|
||||||
|
work = Path(tempfile.mkdtemp(prefix="fieldcraft-flow-clip-"))
|
||||||
|
frames_dir = args.frames.resolve() if args.frames else work / "frames"
|
||||||
|
frames_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.out.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
port = free_port()
|
||||||
|
app_log = open(work / "app.log", "w")
|
||||||
|
env = {**os.environ, "FIELDCRAFT_GPU": os.environ.get("FIELDCRAFT_GPU", "software")}
|
||||||
|
app = subprocess.Popen([str(bin_dir / "fieldcraft"), "--control", str(port)], stdout=app_log, stderr=subprocess.STDOUT, env=env)
|
||||||
|
ctl = None
|
||||||
|
try:
|
||||||
|
ctl = Control(port)
|
||||||
|
forces = solve(ctl)
|
||||||
|
if forces:
|
||||||
|
log(f"face forces: {json.dumps(forces)[:400]}")
|
||||||
|
shots = render_frames(ctl, frames_dir)
|
||||||
|
still(ctl, args.out / "flow-past-flow.png")
|
||||||
|
gif = args.out / "flow-past-transient.gif" if args.gif else None
|
||||||
|
encode(shots, frames_dir, args.out / "flow-past-transient.mp4", gif)
|
||||||
|
finally:
|
||||||
|
if ctl:
|
||||||
|
ctl.close()
|
||||||
|
app.terminate()
|
||||||
|
try:
|
||||||
|
app.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
app.kill()
|
||||||
|
app_log.close()
|
||||||
|
if not args.frames:
|
||||||
|
shutil.rmtree(work, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in new issue
Block a user