CinematicEarth 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cinematicearth/__init__.py +45 -0
- cinematicearth/cli.py +176 -0
- cinematicearth/esp.py +206 -0
- cinematicearth/geo.py +130 -0
- cinematicearth/geocode.py +82 -0
- cinematicearth/io.py +208 -0
- cinematicearth/keyframes.py +105 -0
- cinematicearth/kml.py +31 -0
- cinematicearth/model.py +217 -0
- cinematicearth/overlay.py +317 -0
- cinematicearth/planner.py +552 -0
- cinematicearth/preview.py +55 -0
- cinematicearth/simulate.py +156 -0
- cinematicearth-0.2.0.dist-info/METADATA +223 -0
- cinematicearth-0.2.0.dist-info/RECORD +19 -0
- cinematicearth-0.2.0.dist-info/WHEEL +5 -0
- cinematicearth-0.2.0.dist-info/entry_points.txt +2 -0
- cinematicearth-0.2.0.dist-info/licenses/LICENSE +21 -0
- cinematicearth-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""CinematicEarth: cinematic multi-stop flyovers for Google Earth Studio.
|
|
2
|
+
|
|
3
|
+
import cinematicearth as ce
|
|
4
|
+
plan = ce.build("stops.md", out="flyover.esp") # also writes .kml + .plan.json
|
|
5
|
+
print(plan.summary())
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from .model import Settings, Stop, parse_moves, SPEED_PRESETS
|
|
13
|
+
from .io import load, parse_markdown, parse_stop_line
|
|
14
|
+
from .planner import plan_flyover, Plan
|
|
15
|
+
from .esp import build_esp, write_esp, decode_tracks
|
|
16
|
+
from .kml import build_kml, write_kml
|
|
17
|
+
|
|
18
|
+
__version__ = "0.2.0"
|
|
19
|
+
__all__ = ["Settings", "Stop", "Plan", "load", "plan_flyover", "build", "write_esp", "write_kml",
|
|
20
|
+
"build_esp", "decode_tracks", "parse_moves", "SPEED_PRESETS", "__version__"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build(source, out: Optional[str] = "flyover.esp", kml: bool = True, plan_json: bool = True,
|
|
24
|
+
template: Optional[str] = None, geocode: bool = False, elevation: bool = False,
|
|
25
|
+
verbose: bool = True, **overrides) -> Plan:
|
|
26
|
+
"""Load stops (path / markdown text / list), plan the camera, write outputs.
|
|
27
|
+
|
|
28
|
+
overrides: any Settings field, e.g. duration=150, speed="slow", stop_duration=10, orbit=45.
|
|
29
|
+
"""
|
|
30
|
+
settings, stops = load(source, **overrides)
|
|
31
|
+
if geocode or elevation:
|
|
32
|
+
from .geocode import resolve
|
|
33
|
+
resolve(stops, need_coords=geocode, need_elev=elevation, verbose=verbose)
|
|
34
|
+
plan = plan_flyover(stops, settings)
|
|
35
|
+
plan.esp_files = []
|
|
36
|
+
if out:
|
|
37
|
+
plan.esp_files = write_esp(plan, out, template)
|
|
38
|
+
base = os.path.splitext(out)[0]
|
|
39
|
+
if kml:
|
|
40
|
+
write_kml(plan, base + ".kml")
|
|
41
|
+
if plan_json:
|
|
42
|
+
plan.save_json(base + ".plan.json")
|
|
43
|
+
if verbose:
|
|
44
|
+
print(plan.summary())
|
|
45
|
+
return plan
|
cinematicearth/cli.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
cinematicearth build stops.md -o flyover.esp --speed slow --duration 180
|
|
4
|
+
cinematicearth build "Huntsville, AL; Santa Fe, NM" --geocode
|
|
5
|
+
cinematicearth preview stops.md -o preview.png
|
|
6
|
+
cinematicearth annotate flyover.plan.json render.zip -o flyover_final.mp4
|
|
7
|
+
cinematicearth annotate flyover.plan.json part01.zip part02.zip ... (split projects)
|
|
8
|
+
cinematicearth moves # print the camera-move reference
|
|
9
|
+
cinematicearth init stops.md # write an example stop file
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from . import build as _build, load, plan_flyover, __version__
|
|
18
|
+
from .model import Settings
|
|
19
|
+
|
|
20
|
+
EXAMPLE = """---
|
|
21
|
+
title: My flyover
|
|
22
|
+
speed: slow # glacial | slow | medium | fast | number
|
|
23
|
+
stop_duration: 8 # seconds circling each stop
|
|
24
|
+
orbit: 45 # degrees of orbit per stop
|
|
25
|
+
tilt: 60 # 0 = straight down, 90 = horizon
|
|
26
|
+
# duration: 150 # optional total length (s); transits are re-paced to fit
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
# Stops: name | lat, lon[, elev] | camera moves | key=value ...
|
|
30
|
+
- Huntsville, AL | 34.7304, -86.5861, 190 | orbit 60 + zoom 8km>3km | note=NASA EarthRISE
|
|
31
|
+
- Santa Fe, NM | 35.6870, -105.9378, 2130 | orbit 30 + tilt 55>68 @5s; truck 2km + zoom 2.5km
|
|
32
|
+
- Fort Collins, CO | 40.5853, -105.0844, 1525 | slow 1.5
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _overrides(a):
|
|
37
|
+
keys = ["duration", "speed", "stop_duration", "orbit", "tilt", "fps", "width", "height",
|
|
38
|
+
"range_start", "range_end", "title", "ease", "max_part_seconds", "keyframe_tol_px", "cut_km",
|
|
39
|
+
"direction"]
|
|
40
|
+
ov = {k: getattr(a, k) for k in keys if getattr(a, k, None) is not None}
|
|
41
|
+
if getattr(a, "no_intro", False):
|
|
42
|
+
ov["intro"] = False
|
|
43
|
+
if getattr(a, "no_outro", False):
|
|
44
|
+
ov["outro"] = False
|
|
45
|
+
if getattr(a, "every_frame", False):
|
|
46
|
+
ov["keyframe_every_frame"] = True
|
|
47
|
+
return ov
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _source(src):
|
|
51
|
+
if os.path.exists(src):
|
|
52
|
+
return src
|
|
53
|
+
if src == "-":
|
|
54
|
+
return sys.stdin.read()
|
|
55
|
+
# inline list: "A; B; C" or newline separated
|
|
56
|
+
return "\n".join("- " + s.strip() for s in src.replace("\n", ";").split(";") if s.strip())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _add_common(p):
|
|
60
|
+
p.add_argument("source", help=".md/.csv/.json/.yaml/.txt file, '-' for stdin, or 'A; B; C' inline list")
|
|
61
|
+
p.add_argument("--duration", type=float, help="total seconds (default: auto from pacing)")
|
|
62
|
+
p.add_argument("--speed", help="glacial | slow | medium | fast | number")
|
|
63
|
+
p.add_argument("--stop-duration", dest="stop_duration", type=float)
|
|
64
|
+
p.add_argument("--orbit", type=float, help="default orbit degrees per stop")
|
|
65
|
+
p.add_argument("--tilt", type=float)
|
|
66
|
+
p.add_argument("--range-start", dest="range_start", type=float)
|
|
67
|
+
p.add_argument("--range-end", dest="range_end", type=float)
|
|
68
|
+
p.add_argument("--fps", type=int)
|
|
69
|
+
p.add_argument("--width", type=int)
|
|
70
|
+
p.add_argument("--height", type=int)
|
|
71
|
+
p.add_argument("--ease", choices=["smooth", "sine", "linear"])
|
|
72
|
+
p.add_argument("--split", dest="max_part_seconds", type=float,
|
|
73
|
+
help="split into several .esp projects of at most N seconds (lighter for Earth Studio)")
|
|
74
|
+
p.add_argument("--keyframe-tol", dest="keyframe_tol_px", type=float, help="px drift allowed at stops (default 3)")
|
|
75
|
+
p.add_argument("--cut-km", dest="cut_km", type=float, help="hops longer than this become hard cuts")
|
|
76
|
+
p.add_argument("--direction", help="orbit direction: alternate | 1 | -1")
|
|
77
|
+
p.add_argument("--every-frame", action="store_true", help="one keyframe per frame (big .esp)")
|
|
78
|
+
p.add_argument("--title")
|
|
79
|
+
p.add_argument("--no-intro", action="store_true")
|
|
80
|
+
p.add_argument("--no-outro", action="store_true")
|
|
81
|
+
p.add_argument("--geocode", action="store_true", help="look up missing lat/lon (Nominatim)")
|
|
82
|
+
p.add_argument("--elevation", action="store_true", help="look up missing ground elevation (Open-Meteo)")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main(argv=None):
|
|
86
|
+
ap = argparse.ArgumentParser(prog="cinematicearth", description="Cinematic Google Earth Studio flyovers")
|
|
87
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
88
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
89
|
+
|
|
90
|
+
b = sub.add_parser("build", help="write .esp (+ .kml, .plan.json)")
|
|
91
|
+
_add_common(b)
|
|
92
|
+
b.add_argument("-o", "--out", default="flyover.esp")
|
|
93
|
+
b.add_argument("--template", help="an .esp exported from Earth Studio to inject keyframes into")
|
|
94
|
+
b.add_argument("--no-kml", action="store_true")
|
|
95
|
+
|
|
96
|
+
pv = sub.add_parser("preview", help="plot ground track / altitude / pacing")
|
|
97
|
+
_add_common(pv)
|
|
98
|
+
pv.add_argument("-o", "--out", default="flyover_preview.png")
|
|
99
|
+
|
|
100
|
+
an = sub.add_parser("annotate", help="burn annotations into an Earth Studio render")
|
|
101
|
+
an.add_argument("plan", help="the .plan.json written by build")
|
|
102
|
+
an.add_argument("render", nargs="+", help="Earth Studio render(s): .zip of frames, frame folder, or video; "
|
|
103
|
+
"one per project part, in order")
|
|
104
|
+
an.add_argument("-o", "--out", default="flyover_final.mp4")
|
|
105
|
+
an.add_argument("--accent", default="#FFB000")
|
|
106
|
+
an.add_argument("--no-coords", action="store_true")
|
|
107
|
+
an.add_argument("--no-counter", action="store_true")
|
|
108
|
+
an.add_argument("--attribution", default="Google Earth")
|
|
109
|
+
|
|
110
|
+
sm = sub.add_parser("simulate", help="wireframe preview MP4 of an .esp (check motion before Earth Studio)")
|
|
111
|
+
sm.add_argument("esp")
|
|
112
|
+
sm.add_argument("plan", help="the .plan.json written by build")
|
|
113
|
+
sm.add_argument("-o", "--out", default="flyover_wireframe.mp4")
|
|
114
|
+
sm.add_argument("--width", type=int, default=640)
|
|
115
|
+
sm.add_argument("--height", type=int, default=360)
|
|
116
|
+
sm.add_argument("--fps", type=int, default=15)
|
|
117
|
+
sm.add_argument("--start", type=float, default=0.0)
|
|
118
|
+
sm.add_argument("--seconds", type=float)
|
|
119
|
+
|
|
120
|
+
sk = sub.add_parser("smoketest", help="write a tiny 10 s one-city .esp to check Earth Studio import")
|
|
121
|
+
sk.add_argument("-o", "--out", default="test_tiny.esp")
|
|
122
|
+
|
|
123
|
+
sub.add_parser("moves", help="print the camera-move reference")
|
|
124
|
+
ini = sub.add_parser("init", help="write an example stops file")
|
|
125
|
+
ini.add_argument("path", nargs="?", default="stops.md")
|
|
126
|
+
|
|
127
|
+
a = ap.parse_args(argv)
|
|
128
|
+
if a.cmd == "build":
|
|
129
|
+
plan = _build(_source(a.source), out=a.out, kml=not a.no_kml, template=a.template,
|
|
130
|
+
geocode=a.geocode, elevation=a.elevation, **_overrides(a))
|
|
131
|
+
for f in plan.esp_files:
|
|
132
|
+
print(f"wrote {f}")
|
|
133
|
+
elif a.cmd == "preview":
|
|
134
|
+
from .preview import plot_plan
|
|
135
|
+
settings, stops = load(_source(a.source), **_overrides(a))
|
|
136
|
+
if a.geocode or a.elevation:
|
|
137
|
+
from .geocode import resolve
|
|
138
|
+
resolve(stops, need_coords=a.geocode, need_elev=a.elevation)
|
|
139
|
+
plan = plan_flyover(stops, settings)
|
|
140
|
+
print(plan.summary())
|
|
141
|
+
plot_plan(plan, a.out, show=False)
|
|
142
|
+
print(f"wrote {a.out}")
|
|
143
|
+
elif a.cmd == "annotate":
|
|
144
|
+
from .overlay import annotate_video, OverlayStyle
|
|
145
|
+
st = OverlayStyle(accent=a.accent, show_coords=not a.no_coords, show_counter=not a.no_counter,
|
|
146
|
+
attribution=a.attribution)
|
|
147
|
+
annotate_video(a.render if len(a.render) > 1 else a.render[0], a.plan, a.out, st)
|
|
148
|
+
print(f"wrote {a.out}")
|
|
149
|
+
elif a.cmd == "simulate":
|
|
150
|
+
import json
|
|
151
|
+
from .simulate import render_preview, PlanWindows
|
|
152
|
+
with open(a.esp) as f:
|
|
153
|
+
esp = json.load(f)
|
|
154
|
+
render_preview(esp, PlanWindows(a.plan), a.out, a.width, a.height, a.fps, a.seconds, a.start)
|
|
155
|
+
print(f"wrote {a.out}")
|
|
156
|
+
elif a.cmd == "smoketest":
|
|
157
|
+
plan = _build("- Huntsville, AL | 34.7304, -86.5861, 190 | orbit 45 + zoom 7km>3.5km @10s",
|
|
158
|
+
out=a.out, kml=False, plan_json=False, intro=False, outro=False, verbose=False,
|
|
159
|
+
title="CinematicEarth smoke test")
|
|
160
|
+
print(f"wrote {a.out}: {plan.frames} frames, {len(plan.keyframes()[0])} keyframes")
|
|
161
|
+
elif a.cmd == "moves":
|
|
162
|
+
from . import model
|
|
163
|
+
src = model.__file__
|
|
164
|
+
with open(src) as f:
|
|
165
|
+
txt = f.read()
|
|
166
|
+
start = txt.index("# ---------------- move language")
|
|
167
|
+
end = txt.index("_DUR =")
|
|
168
|
+
print("\n".join(l[2:] if l.startswith("# ") else l.lstrip("#") for l in txt[start:end].splitlines()[1:]))
|
|
169
|
+
elif a.cmd == "init":
|
|
170
|
+
with open(a.path, "w") as f:
|
|
171
|
+
f.write(EXAMPLE)
|
|
172
|
+
print(f"wrote {a.path}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
if __name__ == "__main__":
|
|
176
|
+
main()
|
cinematicearth/esp.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Write Google Earth Studio project files (.esp).
|
|
2
|
+
|
|
3
|
+
The layout and value encodings follow a real Earth Studio export
|
|
4
|
+
(modelVersion 17) as documented by mkatzef/google-studio-utils:
|
|
5
|
+
|
|
6
|
+
* keyframe ``time`` is a fraction 0..1 of the project duration
|
|
7
|
+
* longitude: value = (lon - min) / (180 - min), ``minValueRange`` = min
|
|
8
|
+
* latitude: value = (lat - min) / (90 - min), ``minValueRange`` = min
|
|
9
|
+
* altitude: value = alt_m * 1.5356706349899208e-08 (linear, not logarithmic)
|
|
10
|
+
* rotationX (pan / heading, compass deg): value = (pan - min) / (max - min), with
|
|
11
|
+
``minValueRange`` / ``maxValueRange``; pans may be unwrapped beyond 0..360
|
|
12
|
+
* rotationY (tilt, 0 = down, 90 = horizon): value = tilt / 180
|
|
13
|
+
|
|
14
|
+
Camera Target is written disabled: pan/tilt are computed exactly per keyframe,
|
|
15
|
+
so the camera always aims at the city without relying on target influence.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import copy
|
|
20
|
+
import json
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
ALT_SCALE = 1.5356706349899208e-08
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _block(label, vals, times, min_val=None, max_val=None):
|
|
29
|
+
blk = {
|
|
30
|
+
"type": label,
|
|
31
|
+
"value": {"relative": 0},
|
|
32
|
+
"keyframes": [{"time": float(t), "value": float(v)} for t, v in zip(times, vals)],
|
|
33
|
+
"intimeline": True,
|
|
34
|
+
}
|
|
35
|
+
if min_val is not None:
|
|
36
|
+
blk["value"]["minValueRange"] = float(min_val)
|
|
37
|
+
if max_val is not None:
|
|
38
|
+
blk["value"]["maxValueRange"] = float(max_val)
|
|
39
|
+
if label == "altitude":
|
|
40
|
+
blk["value"]["logarithmic"] = False
|
|
41
|
+
return blk
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def encode_tracks(times, cam, rot, duration):
|
|
45
|
+
"""Return (position_blocks, rotation_blocks) in Earth Studio encoding."""
|
|
46
|
+
tn = np.clip(np.asarray(times) / duration, 0.0, 1.0)
|
|
47
|
+
lat, lon, alt = cam[:, 0], cam[:, 1], cam[:, 2]
|
|
48
|
+
pan, tilt = rot[:, 0], rot[:, 1]
|
|
49
|
+
lon_min = float(lon.min()) - 1e-6
|
|
50
|
+
lat_min = float(lat.min()) - 1e-6
|
|
51
|
+
pmin, pmax = float(pan.min()), float(pan.max())
|
|
52
|
+
if pmax - pmin < 1e-3:
|
|
53
|
+
pmin, pmax = pmin - 1.0, pmax + 1.0
|
|
54
|
+
pos = [
|
|
55
|
+
_block("longitude", (lon - lon_min) / (180.0 - lon_min), tn, lon_min),
|
|
56
|
+
_block("latitude", (lat - lat_min) / (90.0 - lat_min), tn, lat_min),
|
|
57
|
+
_block("altitude", alt * ALT_SCALE, tn),
|
|
58
|
+
]
|
|
59
|
+
rotb = [
|
|
60
|
+
_block("rotationX", (pan - pmin) / (pmax - pmin), tn, pmin, pmax),
|
|
61
|
+
_block("rotationY", tilt / 180.0, tn),
|
|
62
|
+
{"type": "rotationZ", "value": {}},
|
|
63
|
+
]
|
|
64
|
+
return pos, rotb
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def decode_tracks(esp: dict):
|
|
68
|
+
"""Inverse of encode_tracks (for verification / round-trip tests)."""
|
|
69
|
+
found = {}
|
|
70
|
+
|
|
71
|
+
def walk(o):
|
|
72
|
+
if isinstance(o, dict):
|
|
73
|
+
if o.get("type") in ("longitude", "latitude", "altitude", "rotationX", "rotationY") and "keyframes" in o:
|
|
74
|
+
found[o["type"]] = o
|
|
75
|
+
for v in o.values():
|
|
76
|
+
walk(v)
|
|
77
|
+
elif isinstance(o, list):
|
|
78
|
+
for v in o:
|
|
79
|
+
walk(v)
|
|
80
|
+
|
|
81
|
+
walk(esp)
|
|
82
|
+
dur = esp["settings"]["duration"] / esp["settings"]["frameRate"]
|
|
83
|
+
t = np.array([k["time"] for k in found["longitude"]["keyframes"]]) * dur
|
|
84
|
+
g = lambda k: np.array([x["value"] for x in found[k]["keyframes"]])
|
|
85
|
+
lon_min = found["longitude"]["value"]["minValueRange"]
|
|
86
|
+
lat_min = found["latitude"]["value"]["minValueRange"]
|
|
87
|
+
lon = lon_min + g("longitude") * (180 - lon_min)
|
|
88
|
+
lat = lat_min + g("latitude") * (90 - lat_min)
|
|
89
|
+
alt = g("altitude") / ALT_SCALE
|
|
90
|
+
rx = found["rotationX"]["value"]
|
|
91
|
+
pan = rx["minValueRange"] + g("rotationX") * (rx["maxValueRange"] - rx["minValueRange"])
|
|
92
|
+
tilt = g("rotationY") * 180
|
|
93
|
+
return t, np.c_[lat, lon, alt], np.c_[pan, tilt]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _template(name, fps, width, height, frames, pos, rotb):
|
|
97
|
+
return {
|
|
98
|
+
"modelVersion": 17,
|
|
99
|
+
"settings": {"name": name, "frameRate": fps,
|
|
100
|
+
"dimensions": {"width": width, "height": height},
|
|
101
|
+
"duration": frames, "timeFormat": "frames"},
|
|
102
|
+
"scenes": [{
|
|
103
|
+
"animationModel": {"roving": False, "logarithmic": False, "groupedPosition": True},
|
|
104
|
+
"duration": frames,
|
|
105
|
+
"attributes": [
|
|
106
|
+
{"type": "cameraGroup", "inTimeline": True, "attributes": [
|
|
107
|
+
{"type": "cameraPositionGroup", "inTimeline": True, "attributes": pos},
|
|
108
|
+
{"type": "cameraTargetEffect", "attributes": [
|
|
109
|
+
{"type": "enabled", "value": {}},
|
|
110
|
+
{"type": "poi", "attributes": [
|
|
111
|
+
{"type": "longitudePOI", "value": {}},
|
|
112
|
+
{"type": "latitudePOI", "value": {}},
|
|
113
|
+
{"type": "altitudePOI", "value": {"logarithmic": False}}]},
|
|
114
|
+
{"type": "influence", "value": {}}]},
|
|
115
|
+
{"type": "cameraRotationGroup", "inTimeline": True, "attributes": rotb},
|
|
116
|
+
{"type": "cameraLensGroup", "attributes": [
|
|
117
|
+
{"type": "fov", "value": {}}, {"type": "exposure", "value": {}},
|
|
118
|
+
{"type": "aperture", "value": {}}, {"type": "minFocusLength", "value": {}}]},
|
|
119
|
+
]},
|
|
120
|
+
{"type": "environmentGroup", "attributes": [
|
|
121
|
+
{"type": "sunGroup", "attributes": [
|
|
122
|
+
{"type": "sunVisibility", "value": {}},
|
|
123
|
+
{"type": "worldTime", "value": {"relative": 0.5}}]},
|
|
124
|
+
{"type": "cloudGroup", "attributes": [
|
|
125
|
+
{"type": "cloudVisibility", "value": {}}, {"type": "cloudopacity", "value": {}},
|
|
126
|
+
{"type": "cloudheight", "value": {}}, {"type": "clouddate", "value": {"relative": 0.95}}]},
|
|
127
|
+
{"type": "starsPlanetsGroup", "attributes": [{"type": "starsEnabled", "value": {}}]},
|
|
128
|
+
{"type": "seawaterGroup", "attributes": [
|
|
129
|
+
{"type": "seawater", "value": {}}, {"type": "influence", "value": {"relative": 1}}]},
|
|
130
|
+
{"type": "buildingsEnabled", "value": {}},
|
|
131
|
+
]},
|
|
132
|
+
],
|
|
133
|
+
"cameraExport": {"logarithmic": False, "modelVersion": 2},
|
|
134
|
+
}],
|
|
135
|
+
"playbackManager": {"range": {"start": 0, "end": frames}},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _inject(template: dict, pos, rotb, frames, fps, width, height, name):
|
|
140
|
+
esp = copy.deepcopy(template)
|
|
141
|
+
hit = {"pos": False, "rot": False}
|
|
142
|
+
|
|
143
|
+
def walk(o):
|
|
144
|
+
if isinstance(o, dict):
|
|
145
|
+
if o.get("type") == "cameraPositionGroup":
|
|
146
|
+
o["attributes"] = pos
|
|
147
|
+
hit["pos"] = True
|
|
148
|
+
elif o.get("type") == "cameraRotationGroup":
|
|
149
|
+
o["attributes"] = rotb
|
|
150
|
+
hit["rot"] = True
|
|
151
|
+
elif o.get("type") == "cameraTargetEffect":
|
|
152
|
+
for a in o.get("attributes", []): # make sure target is OFF
|
|
153
|
+
if a.get("type") == "enabled":
|
|
154
|
+
a["value"] = {}
|
|
155
|
+
a.pop("keyframes", None)
|
|
156
|
+
for v in o.values():
|
|
157
|
+
walk(v)
|
|
158
|
+
elif isinstance(o, list):
|
|
159
|
+
for v in o:
|
|
160
|
+
walk(v)
|
|
161
|
+
|
|
162
|
+
walk(esp)
|
|
163
|
+
if not (hit["pos"] and hit["rot"]):
|
|
164
|
+
raise ValueError("template has no cameraPositionGroup/cameraRotationGroup")
|
|
165
|
+
esp.setdefault("settings", {}).update(name=name, frameRate=fps, duration=frames, timeFormat="frames",
|
|
166
|
+
dimensions={"width": width, "height": height})
|
|
167
|
+
for sc in esp.get("scenes", []):
|
|
168
|
+
sc["duration"] = frames
|
|
169
|
+
esp["playbackManager"] = {"range": {"start": 0, "end": frames}}
|
|
170
|
+
return esp
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def build_esp(plan, template: Optional[dict] = None, part: Optional[int] = None) -> dict:
|
|
174
|
+
"""Project dict for the whole plan, or for one part (see Settings.max_part_seconds)."""
|
|
175
|
+
st = plan.settings
|
|
176
|
+
parts = plan.parts()
|
|
177
|
+
a, b = parts[part or 0] if part is not None or len(parts) == 1 else (0.0, plan.frames / st.fps)
|
|
178
|
+
t, cam, rot, _ = plan.keyframes(a, b)
|
|
179
|
+
frames = int(round((b - a) * st.fps))
|
|
180
|
+
pos, rotb = encode_tracks(t, cam, rot, frames / st.fps)
|
|
181
|
+
name = st.title if len(parts) == 1 or part is None else f"{st.title} part {part + 1}"
|
|
182
|
+
if template is not None:
|
|
183
|
+
return _inject(template, pos, rotb, frames, st.fps, st.width, st.height, name)
|
|
184
|
+
return _template(name, st.fps, st.width, st.height, frames, pos, rotb)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def write_esp(plan, path, template_path: Optional[str] = None):
|
|
188
|
+
"""Write path (or path_part01.esp, ... when the plan is split). Returns list of files."""
|
|
189
|
+
import os
|
|
190
|
+
tpl = None
|
|
191
|
+
if template_path:
|
|
192
|
+
with open(template_path) as f:
|
|
193
|
+
tpl = json.load(f)
|
|
194
|
+
parts = plan.parts()
|
|
195
|
+
if len(parts) == 1:
|
|
196
|
+
with open(path, "w") as f:
|
|
197
|
+
json.dump(build_esp(plan, tpl, 0), f, separators=(",", ":"))
|
|
198
|
+
return [path]
|
|
199
|
+
base, ext = os.path.splitext(path)
|
|
200
|
+
out = []
|
|
201
|
+
for k in range(len(parts)):
|
|
202
|
+
p = f"{base}_part{k + 1:02d}{ext or '.esp'}"
|
|
203
|
+
with open(p, "w") as f:
|
|
204
|
+
json.dump(build_esp(plan, tpl, k), f, separators=(",", ":"))
|
|
205
|
+
out.append(p)
|
|
206
|
+
return out
|
cinematicearth/geo.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Geodesy helpers: spherical-earth great circles, ENU frames, LookAt <-> camera."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import math
|
|
5
|
+
from typing import Tuple
|
|
6
|
+
|
|
7
|
+
R_E = 6_371_000.0
|
|
8
|
+
rad, deg = math.radians, math.degrees
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def wrap180(a: float) -> float:
|
|
12
|
+
return (a + 180.0) % 360.0 - 180.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def unwrap_to(a: float, ref: float) -> float:
|
|
16
|
+
"""Return angle equal to `a` (mod 360) closest to `ref` (continuous headings)."""
|
|
17
|
+
return ref + wrap180(a - ref)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def haversine(lat1, lon1, lat2, lon2) -> float:
|
|
21
|
+
p1, p2 = rad(lat1), rad(lat2)
|
|
22
|
+
dp, dl = p2 - p1, rad(lon2 - lon1)
|
|
23
|
+
h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
|
24
|
+
return 2 * R_E * math.asin(min(1.0, math.sqrt(h)))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def bearing(lat1, lon1, lat2, lon2) -> float:
|
|
28
|
+
p1, p2, dl = rad(lat1), rad(lat2), rad(lon2 - lon1)
|
|
29
|
+
y = math.sin(dl) * math.cos(p2)
|
|
30
|
+
x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl)
|
|
31
|
+
return deg(math.atan2(y, x)) % 360.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def destination(lat, lon, brg, dist) -> Tuple[float, float]:
|
|
35
|
+
p1, l1, b, d = rad(lat), rad(lon), rad(brg), dist / R_E
|
|
36
|
+
p2 = math.asin(math.sin(p1) * math.cos(d) + math.cos(p1) * math.sin(d) * math.cos(b))
|
|
37
|
+
l2 = l1 + math.atan2(math.sin(b) * math.sin(d) * math.cos(p1),
|
|
38
|
+
math.cos(d) - math.sin(p1) * math.sin(p2))
|
|
39
|
+
return deg(p2), wrap180(deg(l2))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def gc_interp(lat1, lon1, lat2, lon2, f) -> Tuple[float, float]:
|
|
43
|
+
"""Point at fraction f along the great circle."""
|
|
44
|
+
p1, l1, p2, l2 = rad(lat1), rad(lon1), rad(lat2), rad(lon2)
|
|
45
|
+
d = haversine(lat1, lon1, lat2, lon2) / R_E
|
|
46
|
+
if d < 1e-12:
|
|
47
|
+
return lat1, lon1
|
|
48
|
+
a = math.sin((1 - f) * d) / math.sin(d)
|
|
49
|
+
b = math.sin(f * d) / math.sin(d)
|
|
50
|
+
x = a * math.cos(p1) * math.cos(l1) + b * math.cos(p2) * math.cos(l2)
|
|
51
|
+
y = a * math.cos(p1) * math.sin(l1) + b * math.cos(p2) * math.sin(l2)
|
|
52
|
+
z = a * math.sin(p1) + b * math.sin(p2)
|
|
53
|
+
return deg(math.atan2(z, math.hypot(x, y))), deg(math.atan2(y, x))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def gc_crosses_antimeridian(lat1, lon1, lat2, lon2, n=64) -> bool:
|
|
57
|
+
prev = lon1
|
|
58
|
+
for k in range(1, n + 1):
|
|
59
|
+
_, lo = gc_interp(lat1, lon1, lat2, lon2, k / n)
|
|
60
|
+
if abs(lo - prev) > 180:
|
|
61
|
+
return True
|
|
62
|
+
prev = lo
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------- ECEF / ENU (spherical) ----------
|
|
67
|
+
def ecef(lat, lon, alt):
|
|
68
|
+
r = R_E + alt
|
|
69
|
+
p, l = rad(lat), rad(lon)
|
|
70
|
+
return (r * math.cos(p) * math.cos(l), r * math.cos(p) * math.sin(l), r * math.sin(p))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def enu_basis(lat, lon):
|
|
74
|
+
p, l = rad(lat), rad(lon)
|
|
75
|
+
e = (-math.sin(l), math.cos(l), 0.0)
|
|
76
|
+
n = (-math.sin(p) * math.cos(l), -math.sin(p) * math.sin(l), math.cos(p))
|
|
77
|
+
u = (math.cos(p) * math.cos(l), math.cos(p) * math.sin(l), math.sin(p))
|
|
78
|
+
return e, n, u
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _dot(a, b):
|
|
82
|
+
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def look_rotation(cam_lat, cam_lon, cam_alt, tgt_lat, tgt_lon, tgt_alt) -> Tuple[float, float]:
|
|
86
|
+
"""Exact (heading, tilt) for a camera at cam_* to centre the target point.
|
|
87
|
+
|
|
88
|
+
heading: compass degrees (0 = north, clockwise). tilt: 0 = straight down, 90 = horizon.
|
|
89
|
+
"""
|
|
90
|
+
c = ecef(cam_lat, cam_lon, cam_alt)
|
|
91
|
+
t = ecef(tgt_lat, tgt_lon, tgt_alt)
|
|
92
|
+
v = (t[0] - c[0], t[1] - c[1], t[2] - c[2])
|
|
93
|
+
e, n, u = enu_basis(cam_lat, cam_lon)
|
|
94
|
+
ve, vn, vu = _dot(v, e), _dot(v, n), _dot(v, u)
|
|
95
|
+
horiz = math.hypot(ve, vn)
|
|
96
|
+
if horiz < 1e-4 * math.sqrt(ve * ve + vn * vn + vu * vu): # looking straight down
|
|
97
|
+
return None, deg(math.atan2(horiz, -vu))
|
|
98
|
+
heading = deg(math.atan2(ve, vn)) % 360.0
|
|
99
|
+
tilt = deg(math.atan2(horiz, -vu))
|
|
100
|
+
return heading, tilt
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def lookat_to_camera(lat, lon, elev, rng, tilt, heading):
|
|
104
|
+
"""LookAt (target + range/tilt/heading) -> camera lat, lon, alt (spherical earth).
|
|
105
|
+
|
|
106
|
+
Solves the triangle earth-centre / target / camera so it stays valid from
|
|
107
|
+
street level to orbit.
|
|
108
|
+
"""
|
|
109
|
+
rt = R_E + elev
|
|
110
|
+
tr = rad(tilt)
|
|
111
|
+
# camera = target + rng * (sin(tilt) * back-horizontal + cos(tilt) * up), in target ENU
|
|
112
|
+
back = rad(heading + 180.0)
|
|
113
|
+
e, n, u = enu_basis(lat, lon)
|
|
114
|
+
t = ecef(lat, lon, elev)
|
|
115
|
+
h = rng * math.sin(tr)
|
|
116
|
+
vu = rng * math.cos(tr)
|
|
117
|
+
ve, vn = h * math.sin(back), h * math.cos(back)
|
|
118
|
+
c = tuple(t[i] + ve * e[i] + vn * n[i] + vu * u[i] for i in range(3))
|
|
119
|
+
r = math.sqrt(_dot(c, c))
|
|
120
|
+
clat = deg(math.asin(c[2] / r))
|
|
121
|
+
clon = deg(math.atan2(c[1], c[0]))
|
|
122
|
+
return clat, clon, r - R_E
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def enu_offset(lat, lon, de, dn):
|
|
126
|
+
"""Small local offset (metres east/north) -> lat, lon."""
|
|
127
|
+
dist = math.hypot(de, dn)
|
|
128
|
+
if dist == 0:
|
|
129
|
+
return lat, lon
|
|
130
|
+
return destination(lat, lon, deg(math.atan2(de, dn)), dist)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Optional network helpers: place-name geocoding (Nominatim) and ground elevation
|
|
2
|
+
(Open-Meteo). Standard library only; results cached in ~/.cache/cinematicearth."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from typing import Optional, Tuple
|
|
11
|
+
|
|
12
|
+
_CACHE = os.path.join(os.path.expanduser("~"), ".cache", "cinematicearth", "geo.json")
|
|
13
|
+
_UA = "CinematicEarth (https://github.com/MayerT1/CinematicEarth)"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load():
|
|
17
|
+
try:
|
|
18
|
+
with open(_CACHE) as f:
|
|
19
|
+
return json.load(f)
|
|
20
|
+
except Exception:
|
|
21
|
+
return {}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _save(c):
|
|
25
|
+
try:
|
|
26
|
+
os.makedirs(os.path.dirname(_CACHE), exist_ok=True)
|
|
27
|
+
with open(_CACHE, "w") as f:
|
|
28
|
+
json.dump(c, f)
|
|
29
|
+
except Exception:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _get(url):
|
|
34
|
+
req = urllib.request.Request(url, headers={"User-Agent": _UA})
|
|
35
|
+
with urllib.request.urlopen(req, timeout=20) as r:
|
|
36
|
+
return json.loads(r.read().decode())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def geocode(name: str) -> Optional[Tuple[float, float]]:
|
|
40
|
+
c = _load()
|
|
41
|
+
key = "g:" + name.lower().strip()
|
|
42
|
+
if key in c:
|
|
43
|
+
return tuple(c[key]) if c[key] else None
|
|
44
|
+
q = urllib.parse.urlencode({"q": name, "format": "json", "limit": 1})
|
|
45
|
+
res = _get(f"https://nominatim.openstreetmap.org/search?{q}")
|
|
46
|
+
time.sleep(1.0) # Nominatim usage policy: max 1 request / second
|
|
47
|
+
val = (float(res[0]["lat"]), float(res[0]["lon"])) if res else None
|
|
48
|
+
c[key] = val
|
|
49
|
+
_save(c)
|
|
50
|
+
return val
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def elevation(lat: float, lon: float) -> Optional[float]:
|
|
54
|
+
c = _load()
|
|
55
|
+
key = f"e:{lat:.4f},{lon:.4f}"
|
|
56
|
+
if key in c:
|
|
57
|
+
return c[key]
|
|
58
|
+
try:
|
|
59
|
+
res = _get(f"https://api.open-meteo.com/v1/elevation?latitude={lat}&longitude={lon}")
|
|
60
|
+
val = float(res["elevation"][0])
|
|
61
|
+
except Exception:
|
|
62
|
+
return None
|
|
63
|
+
c[key] = val
|
|
64
|
+
_save(c)
|
|
65
|
+
return val
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve(stops, need_coords=True, need_elev=True, verbose=True):
|
|
69
|
+
"""Fill missing lat/lon (geocode) and elev (elevation API) in place."""
|
|
70
|
+
for s in stops:
|
|
71
|
+
if need_coords and (s.lat is None or s.lon is None):
|
|
72
|
+
r = geocode(s.name)
|
|
73
|
+
if r is None:
|
|
74
|
+
raise ValueError(f"could not geocode {s.name!r}; add lat/lon")
|
|
75
|
+
s.lat, s.lon = r
|
|
76
|
+
if verbose:
|
|
77
|
+
print(f"geocoded {s.name}: {s.lat:.4f}, {s.lon:.4f}")
|
|
78
|
+
if need_elev and s.elev is None:
|
|
79
|
+
s.elev = elevation(s.lat, s.lon)
|
|
80
|
+
if verbose and s.elev is not None:
|
|
81
|
+
print(f"elevation {s.name}: {s.elev:.0f} m")
|
|
82
|
+
return stops
|