Repository navigation
Expand file tree
/
Copy pathcamera_translate.py
More file actions
1552 lines (1398 loc) · 72.8 KB
/
Copy pathcamera_translate.py
File metadata and controls
1552 lines (1398 loc) · 72.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Camera Studio, layer 3: the TRANSLATOR. Geometry in, model language out.
Numbers never go to the model raw. A camera state - where the camera is,
where it looks, its lens - and a scene of subjects become a paragraph in the
form Krea 2 actually obeys (KNOWLEDGE.md, "Krea 2 camera-angle prompting"):
the camera as a PHYSICAL OBJECT, position -> tilt in degrees -> the subject's
relation to it -> which surfaces it sees -> what fills the background -> a
lens. Each of those is a constraint the encoder cannot quietly reinterpret
as an eye-level shot; a bare "high angle" it can.
Coordinates: metres, right-handed. x right, y UP, z toward the viewer (a
subject facing the default camera faces +z). Ground is y = 0. A subject
stands at (x, 0, z) with its head at y = height. The camera is at (x, y, z)
and aims at a target point (a subject's face by default).
Pure functions, no ComfyUI imports: the node, the Prompt Frame's Camera
height dial and the lab all call the same code, so there is one source of
truth for what a "high angle" says.
"""
import math
# ---------------------------------------------------------------- lens table
# focal length -> horizontal FOV on a full-frame 36mm sensor, and the words
# the research says each range should carry. Never below ~14mm in wording:
# past that Krea reads "crazy wide image" instead of "camera position".
LENS_WORDS = [
(0, 20, "ultra-wide-angle lens"),
(20, 30, "wide-angle lens"),
(30, 42, "lens"),
(42, 70, "lens"),
(70, 400, "telephoto lens"),
]
def fov_deg(focal_mm, sensor_mm=36.0):
"""Horizontal field of view for a focal length, degrees."""
f = max(4.0, float(focal_mm))
return math.degrees(2.0 * math.atan(sensor_mm / (2.0 * f)))
def focal_for_fov(fov, sensor_mm=36.0):
return sensor_mm / (2.0 * math.tan(math.radians(max(1.0, min(170.0, fov)) / 2.0)))
def lens_phrase(focal_mm):
f = int(round(float(focal_mm)))
for lo, hi, word in LENS_WORDS:
if lo <= f < hi:
return "Shot with a %dmm %s" % (f, word)
return "Shot with a %dmm lens" % f
# ---------------------------------------------------------------- geometry
def _v(a, b):
return (b[0] - a[0], b[1] - a[1], b[2] - a[2])
def _len(v):
return math.sqrt(v[0] ** 2 + v[1] ** 2 + v[2] ** 2)
def _norm(v):
n = _len(v) or 1.0
return (v[0] / n, v[1] / n, v[2] / n)
def camera_geometry(cam_pos, target_pos):
"""The numbers the words are made from: distance, pitch, height delta,
horizontal (ground) distance, and the yaw of the look direction."""
d = _v(cam_pos, target_pos)
dist = _len(d)
ground = math.sqrt(d[0] ** 2 + d[2] ** 2)
pitch = math.degrees(math.atan2(d[1], ground)) if (ground or d[1]) else 0.0
yaw = math.degrees(math.atan2(d[0], -d[2])) if ground else 0.0 # 0 = looking -z
return {"distance": dist, "ground": ground, "pitch": pitch, # pitch < 0: down
"dy": cam_pos[1] - target_pos[1], "yaw": yaw}
# ---------------------------------------------------------------- vertical stops
# The pitch bands and what each says. Wordings are your researched
# blocks (KNOWLEDGE.md), parameterised by the real numbers.
def _height_words(cam_y, subj_top, dy_face):
"""How far the camera sits above/below the face, in plain metres."""
m = abs(dy_face)
if m < 0.15:
return "at the subject's eye level"
if m < 0.6:
return "roughly %d cm %s the subject's eye level" % (
int(round(m * 100 / 10.0) * 10), "above" if dy_face > 0 else "below")
if dy_face > 0:
above_head = max(0.0, cam_y - subj_top)
if above_head >= 0.5:
return "about %s above the subject's head" % _metres(above_head)
return "about %s above the subject's eye level" % _metres(m)
if cam_y <= 0.25:
return "on the ground"
return "about %s below the subject's eye level" % _metres(m)
def _metres(m):
m = float(m)
if m < 0.95:
return "%d cm" % int(round(m * 100 / 10.0) * 10)
if abs(m - round(m)) < 0.15:
return "%d meter%s" % (int(round(m)), "" if int(round(m)) == 1 else "s")
return "%.1f meters" % m
def vertical_block(geo, cam_y, subject_height, subject_word="the subject",
pronoun_obj="them", possessive="their", pronoun_subj="they"):
"""The physical-camera paragraph for the camera's height and pitch."""
pitch = geo["pitch"]
dy = geo["dy"]
down = pitch < 0
steep = abs(pitch)
where = _height_words(cam_y, subject_height, dy)
S, sp, po, ps = subject_word, pronoun_subj, pronoun_obj, possessive
if steep < 8:
return ("Eye-level photograph. The camera is %s and level, pointing straight "
"at %s; %s %s the lens directly, natural proportions."
% (where, S, sp, "meet" if sp == "they" else "meets"))
if down:
if steep >= 80:
return ("Direct overhead photograph. The camera is mounted vertically above "
"%s, %s, and points straight down at 90 degrees, its optical axis "
"perpendicular to the floor. %s is directly beneath the lens; the top "
"of %s head and shoulders are seen from above and the floor fills the "
"entire background. True top-down perspective, not an oblique "
"high-angle view." % (S, where, _cap(S), ps))
if steep >= 55:
return ("Extreme high-angle perspective. The camera is positioned %s and "
"pitched steeply downward at around %d degrees toward %s. %s stands "
"beneath the camera looking upward into the elevated lens; %s head "
"appears nearest the lens while the torso and legs recede strongly "
"toward the floor, and a large area of floor is visible around %s. "
"Pronounced perspective depth." % (where, int(round(steep)), S,
_cap(S), ps, po))
if steep >= 30:
return ("Strong high-angle photograph. The camera is positioned %s and tilted "
"downward at %d degrees. %s stands beneath the camera looking upward "
"into the lens; the top planes of %s head and shoulders are clearly "
"visible and the floor surrounds %s body. Strong but realistic "
"perspective." % (where, int(round(steep)), _cap(S), ps, ps))
return ("High-angle photograph. The camera is %s and tilted downward about %d "
"degrees toward %s face. %s %s %s eyes toward the camera; the top of "
"the hair and shoulders slightly visible, subtle elevated perspective, "
"natural proportions." % (where, int(round(steep)), ps, _cap(sp),
"raise" if sp == "they" else "raises", ps))
# looking up
if steep >= 55:
return ("Extreme low-angle photograph, a worm's-eye view. The camera is %s and "
"points steeply upward at around %d degrees. %s towers above the camera "
"and looks down toward the lens; the underside of the chin and the soles "
"of the shoes are nearest the camera, the head and shoulders recede "
"upward, and the sky or ceiling fills the background. Pronounced upward "
"foreshortening." % (where, int(round(steep)), _cap(S)))
# LOW SIDE, rewritten from the 2026-08-17 lab sweep: the first wording said
# the subject "looks down toward the lens" and named "the underside of the
# jaw", and Krea 2 read those as a downward-looking scene and flipped the
# shot HIGH. The worm's-eye block that worked leads with what is NEAREST the
# camera and what FILLS THE BACKGROUND (sky), never with a downward verb, so
# both low stops now follow that template. Nothing here says "down".
if steep >= 25:
return ("Low-angle photograph, shot from below. The camera is %s, near the "
"ground, pointing upward at around %d degrees toward %s. %s stands "
"tall above the camera; %s legs and torso are nearest the lens and "
"loom large, the head is furthest away and smaller, and the sky or "
"ceiling fills the space above and behind %s. Clear upward "
"perspective." % (where, int(round(steep)), S, _cap(S), ps, po))
return ("Slightly low-angle photograph, the camera %s and pointing gently upward "
"toward %s. %s chin and jawline sit slightly nearer the lens than %s eyes, "
"and open sky or ceiling shows above and behind the head, subtle heroic "
"perspective, natural proportions." % (where, S, _cap(ps), ps))
def _height_only_block(geo, cam_y, subject_top, sname):
"""The camera's height and general pitch relative to the subject, with no
claim of aiming at them - for the unlocked (off-centre) composition."""
p = geo["pitch"]
steep = abs(p)
where = _height_words(cam_y, subject_top, geo["dy"])
if steep < 8:
return ("Eye-level photograph. The camera is %s and held level."
% where)
if p < 0:
kind = ("Direct overhead" if steep >= 80 else "Extreme high-angle"
if steep >= 55 else "High-angle")
return ("%s photograph. The camera is %s and pitched downward about %d "
"degrees; the ground fills much of the frame and %s is seen from "
"above." % (kind, where, int(round(steep)), sname))
kind = ("Extreme low-angle" if steep >= 55 else "Low-angle")
return ("%s photograph, shot from below. The camera is %s and pointing upward "
"about %d degrees; the sky or ceiling fills much of the frame and %s "
"is seen from below." % (kind, where, int(round(steep)), sname))
def _aim_words(aim_geo, look_at, cpos):
"""Where the lens points when unlocked, in plain words."""
d = aim_geo["distance"]
p = aim_geo["pitch"]
tilt = ("level" if abs(p) < 8 else
"tilted %s about %d degrees" % ("down" if p < 0 else "up", int(round(abs(p)))))
return "%s, toward a point %s away" % (tilt, _metres(d))
def _cap(t):
return t[:1].upper() + t[1:] if t else t
# ---------------------------------------------------------------- horizontal
def bearing_from(subject_pos, cam_pos):
"""The direction from a subject to the camera, in FACING degrees
(0 = +z, 90 = +x, the same convention as facing_deg)."""
return math.degrees(math.atan2(float(cam_pos[0]) - float(subject_pos[0]),
float(cam_pos[2]) - float(subject_pos[2])))
def rel_bearing(subject_pos, cam_pos, facing_deg):
"""Where the camera sits relative to the way the subject faces, -180..180:
0 in front, +90 on the subject's LEFT, -90 on their RIGHT, +-180 behind.
(Fixed 2026-08-17: the old form used the look yaw with a sign that only
held for facing 0; a subject facing +x got its sides swapped.)"""
return (bearing_from(subject_pos, cam_pos) - float(facing_deg) + 180) % 360 - 180
def _facing_relation(rel):
"""Front / three-quarter / profile / rear three-quarter / rear, from a
rel_bearing() value; the side named is the one nearer the camera."""
a = abs(rel)
side = "left" if rel > 0 else "right"
if a < 20:
return "square on to the camera, facing it directly"
if a < 65:
return "turned in a three-quarter view, %s side nearer the camera" % side
if a < 115:
return "in %s profile to the camera" % side
if a < 160:
return "in a rear three-quarter view, the camera behind and to the %s" % side
return "seen from behind, back to the camera"
def _distance_words(dist, focal_mm):
"""A shot-size cue from distance and lens, in the framing vocabulary."""
# rough: horizontal FOV at distance -> width of frame; compare with a body
width_m = 2.0 * dist * math.tan(math.radians(fov_deg(focal_mm) / 2.0))
if width_m < 0.9:
return "a tight close-up of the face"
if width_m < 1.6:
return "a head-and-shoulders portrait"
if width_m < 2.6:
return "a three-quarter figure"
if width_m < 4.5:
return "the whole figure with room around them"
return "a wide view with the figure small in the frame"
# ---------------------------------------------------------------- the paragraph
def describe(camera, subjects, output="krea2"):
"""camera: {pos:[x,y,z], target: index|None, target_height: m|None,
focal_mm, roll_deg}
subjects: [{name, pos:[x,y,z], height, facing_deg, primary?}]
Returns the model-facing text (str). output: "krea2" (the physical-camera
paragraph, the tuned default), "short" (the same facts in a few plain
words, for encoders that choke on meters and degrees), "tags" (booru-style
tags for Pony / Illustrious / tag-trained XL). short and tags are UNTUNED
on XL as of 2026-08-17: built from the same geometry, wording unproven.
"""
if output in ("tags", "short"):
return _describe_compact(camera, subjects, output)
if not subjects:
subjects = [{"name": "the subject", "pos": [0, 0, 0], "height": 1.7,
"facing_deg": 0}]
ti = camera.get("target")
if not isinstance(ti, int) or ti < 0 or ti >= len(subjects):
ti = 0
prime = subjects[ti]
sname = str(prime.get("name") or "the subject")
sh = float(prime.get("height", 1.7))
spos = [float(x) for x in prime.get("pos", [0, 0, 0])]
face_y = spos[1] + sh * 0.92
th = camera.get("target_height")
cpos = [float(x) for x in camera.get("pos", [0, face_y, 3.0])]
focal = float(camera.get("focal_mm", 35))
# LOCK ON SUBJECT (default): the camera aims at the target's face and the
# subject sits centre frame. UNLOCKED: the camera aims at a free point
# (aim: [x, y, z]), so the subject can sit off-centre or at the edge -
# for less centred compositions. The vertical block is
# still measured to the subject (that is what "high angle" means); the
# composition sentence says where in the frame the subject lands.
locked = camera.get("lock", True) is not False
aim = camera.get("aim")
if locked or not (isinstance(aim, (list, tuple)) and len(aim) == 3):
look_at = [spos[0], float(th) if th is not None else face_y, spos[2]]
else:
look_at = [float(aim[0]), float(aim[1]), float(aim[2])]
geo = camera_geometry(cpos, [spos[0], face_y, spos[2]]) # angle TO the subject
aim_geo = camera_geometry(cpos, look_at) # where the lens points
if locked:
parts = [vertical_block(geo, cpos[1], spos[1] + sh, subject_word=sname)]
else:
# unlocked: the height relation still matters (it is what makes the
# shot high or low) but the block must not claim the lens points at
# the subject - the aim sentence says where it really points
parts = [_height_only_block(geo, cpos[1], spos[1] + sh, sname)]
# horizontal relation and distance
rel = _facing_relation(rel_bearing(spos, cpos, prime.get("facing_deg", 0)))
# STRAIGHT DOWN OR UP the horizontal bearing means nothing: "rear
# three-quarter view, the camera behind" under "direct overhead" pulled the
# model to a three-quarter shot (your boxing report). Past 75 degrees
# of pitch the relation is simply the top (or underside) of the subject.
if abs(geo["pitch"]) >= 75:
rel = ("seen from directly above" if geo["pitch"] < 0
else "seen from directly below")
if locked:
parts.append(_cap("%s is %s, %s from the camera, centered in the frame, "
"framed as %s."
% (sname, rel, _metres(geo["distance"]),
_distance_words(geo["distance"], focal))))
else:
# where does the subject fall in the frame? Angle between the lens
# axis and the subject direction, signed left/right, as a fraction of
# the half-FOV
d_yaw = ((geo["yaw"] - aim_geo["yaw"] + 180) % 360) - 180
half = fov_deg(focal) / 2.0
frac = max(-1.4, min(1.4, d_yaw / max(1e-6, half)))
if abs(frac) < 0.15:
where = "near the center of the frame"
elif abs(frac) < 0.5:
where = "a little %s of center" % ("right" if frac > 0 else "left")
elif abs(frac) < 0.9:
where = "well %s of center, in the %s third of the frame" % (
("right", "right") if frac > 0 else ("left", "left"))
elif abs(frac) <= 1.0:
where = "at the very %s edge of the frame" % ("right" if frac > 0 else "left")
else:
where = "just outside the %s edge of the frame, mostly cut off" % (
"right" if frac > 0 else "left")
parts.append(_cap("The camera is not aimed at %s: it points %s, at open %s, "
"and %s falls %s, %s, %s from the camera. The composition "
"gives space to the scene rather than centering the figure."
% (sname, _aim_words(aim_geo, look_at, cpos),
"ground" if look_at[1] < 0.6 else "space",
sname, where, rel, _metres(geo["distance"]))))
# RELATIONSHIPS: "the girl sits on the bed" is a relation,
# not two positions. An entry may declare rel = {kind, to}; the pair's
# relation is stated in words FIRST (the model reads "on the bed" far
# more reliably than "0.4 m above and level with"), and the geometry
# blocking below then covers everything else.
REL_WORDS = {
"on": "%s is on %s", "in": "%s is in %s", "beside": "%s is right beside %s",
"behind": "%s is directly behind %s", "holding": "%s is holding %s",
"leaning on": "%s is leaning on %s", "under": "%s is under %s",
"at": "%s is at %s", "sitting on": "%s is sitting on %s",
"lying on": "%s is lying on %s", "standing on": "%s is standing on %s",
"looking at": "%s is looking at %s", "next to": "%s is next to %s",
}
related_pairs = set()
for k, s_ in enumerate(subjects):
rel = s_.get("rel")
if not isinstance(rel, dict):
continue
kind = str(rel.get("kind") or "").strip().lower()
to = rel.get("to")
if kind not in REL_WORDS or not isinstance(to, int) or to < 0 or to >= len(subjects) or to == k:
continue
a = str(s_.get("name") or "the subject")
b = str(subjects[to].get("name") or "the object")
parts.append(_cap(REL_WORDS[kind] % (a, b)) + ".")
related_pairs.add((k, to))
# the other subjects: blocking relative to the primary and the camera. A
# related pair's member skips its geometry line against the primary when
# the relation already said where it is (on the bed says enough).
others = [s for k, s in enumerate(subjects) if k != ti]
# OVER THE SHOULDER (two-person scenes,): a person standing
# close in front of the lens, on the look line, nearer than the target, is
# the foreground shoulder. Say it as the shot it is - the model knows the
# grammar of an OTS far better than "1 m from the camera, in line with" -
# and skip that person's ordinary blocking line.
ots = _ots_person(cpos, look_at, geo, subjects, ti)
if ots is not None:
oname = str(subjects[ots].get("name") or "the other person")
opos_o = [float(x) for x in subjects[ots]["pos"]]
which, lateral = _ots_side(cpos, look_at, opos_o)
gaze = ("looking toward %s" % oname if _faces_each_other(prime, subjects[ots])
else "looking past them")
if lateral < 0.28:
# dead ahead of the lens: not a shoulder at an edge but a back in
# the middle of the frame (a "from behind" foreground). Said so, or
# the model draws the person twice - once as the shoulder, once as
# the person the text mentions.
parts.append(_cap("%s stands between the camera and %s with %s back to the lens, "
"filling the lower middle of the near foreground, softly out of "
"focus; %s is seen past %s, sharp, %s."
% (oname, sname, "their", sname, "them", gaze)))
else:
# they face away from us, so the shoulder at the frame's left edge is
# their RIGHT shoulder
parts.append(_cap("An over-the-shoulder shot: the back of %s's head and %s shoulder "
"are in the near foreground at the %s edge of the frame, close to "
"the lens and softly out of focus, and %s is seen past them, "
"sharp, %s."
% (oname, "right" if which == "left" else "left", which, sname, gaze)))
# WHAT IS IN THE FRAME (room sets,): only things the lens
# can see get a line - inside the horizontal field of view plus a margin,
# not behind the camera, not beyond 14 m - nearest first, at most eight.
# Walls never get their own line: a room's walls are the room, and the
# Subject / Surroundings text says what the room looks like.
look_dir = _norm((look_at[0] - cpos[0], 0.0, look_at[2] - cpos[2]))
half_fov = fov_deg(focal) / 2.0 + 12.0
walls = _wall_segments(subjects)
visible = []
for o in others:
ok = subjects.index(o)
if str(o.get("kind") or "person") == "wall":
continue
opos = [float(x) for x in o.get("pos", [0, 0, 0])]
d_flat = math.hypot(opos[0] - cpos[0], opos[2] - cpos[2])
if d_flat > 14.0:
continue
# behind a wall from where the camera stands: not in the picture (a
# doorway is a gap between two wall parts, so it stays open)
if _occluded((cpos[0], cpos[2]), (opos[0], opos[2]), walls):
continue
if d_flat > 0.05:
v = _norm((opos[0] - cpos[0], 0.0, opos[2] - cpos[2]))
ang = math.degrees(math.acos(max(-1.0, min(1.0, v[0] * look_dir[0] + v[2] * look_dir[2]))))
if ang > half_fov and ok != ots:
continue
visible.append((d_flat, ok, o))
visible.sort(key=lambda t: t[0])
others = [o for _, _, o in visible[:8]]
for o in others:
ok = subjects.index(o)
if (ti, ok) in related_pairs or (ok, ti) in related_pairs:
continue
if ok == ots:
continue
opos = [float(x) for x in o.get("pos", [0, 0, 0])]
d_cam = _len(_v(cpos, opos))
d_prime = _len(_v(spos, opos))
# in front of / behind, from the camera's point of view
along = geo["distance"] - _len(_v(cpos, [opos[0], look_at[1], opos[2]]))
depth = ("in the foreground, nearer the camera than %s" % sname if along > 0.6
else "in the background, %s behind %s" % (_metres(d_prime), sname)
if along < -0.6 else "level with %s" % sname)
# left / right of the primary as the camera sees it
cx = _norm((look_at[0] - cpos[0], 0.0, look_at[2] - cpos[2]))
right = (-cx[2], 0.0, cx[0]) # perpendicular, camera-right
side_dot = (opos[0] - spos[0]) * right[0] + (opos[2] - spos[2]) * right[2]
side = ("to the right of" if side_dot > 0.35 else "to the left of"
if side_dot < -0.35 else "in line with")
# OBJECTS share the stage with people (a bed, a lamp,
# a window placed and named, and the words say where they are - the
# Subject and Surroundings text still say what they LOOK like). A
# person "stands"; an object "sits" (or "is" for a wall/window).
kind = str(o.get("kind") or "person")
oname = str(o.get("name") or ("a second person" if kind == "person"
else "an object"))
verb = ("stands" if kind == "person" else "is"
if kind in ("wall", "window", "door") else "sits")
# FACING:
# a person's or an object's front, relative to the camera; a door is
# said as a doorway; walls have no front
facing_words = ""
if kind in ("person", "object", "door"):
o_rel = rel_bearing(opos, cpos, o.get("facing_deg", 0))
facing_words = _facing_words(kind, o_rel, prime, o, oname, sname)
parts.append(_cap("%s %s %s, %s %s, %s from the camera%s."
% (oname, verb, depth, side, sname, _metres(d_cam),
(", " + facing_words) if facing_words else "")))
roll = float(camera.get("roll_deg", 0) or 0)
if abs(roll) >= 5:
parts.append("The camera is rolled %d degrees to the %s, a Dutch tilt."
% (int(round(abs(roll))), "left" if roll < 0 else "right"))
parts.append(lens_phrase(focal) + ".")
# DEPTH OF FIELD ("bokeh"): with an aperture set, the
# physics of lens, f-number and distance says what is sharp and what
# dissolves - and the stage knows how far every other thing is, so the
# words name who is soft. Off (no aperture) says nothing, as before.
dof = dof_words(camera, subjects, ti, cpos, geo, focal)
if dof:
parts.append(dof)
return " ".join(p if p.endswith(".") else p + "." for p in parts)
def _wall_segments(subjects):
"""[(x1, z1, x2, z2)] for every wall entry: it runs along its facing
(facing 0 = along x, 90 = along z), size[0] long."""
segs = []
for s_ in subjects:
if str(s_.get("kind") or "") != "wall":
continue
p = [float(x) for x in s_.get("pos", [0, 0, 0])]
length = float((s_.get("size") or [1.0, 0.2])[0])
f = math.radians(float(s_.get("facing_deg", 0)))
dx, dz = math.cos(f) * length / 2.0, math.sin(f) * length / 2.0
segs.append((p[0] - dx, p[2] - dz, p[0] + dx, p[2] + dz))
return segs
def _occluded(a, b, walls):
"""True when the segment a->b (camera to thing, ground plan) crosses a
wall segment strictly between them (the thing itself may sit ON a wall:
a window or a door, hence the 0.97 cut)."""
ax, az = a
bx, bz = b
for (x1, z1, x2, z2) in walls:
d = (bx - ax) * (z2 - z1) - (bz - az) * (x2 - x1)
if abs(d) < 1e-9:
continue
t = ((x1 - ax) * (z2 - z1) - (z1 - az) * (x2 - x1)) / d # along a->b
u = ((x1 - ax) * (bz - az) - (z1 - az) * (bx - ax)) / d # along the wall
if 0.02 < t < 0.97 and 0.0 <= u <= 1.0:
return True
return False
def _ots_person(cpos, look_at, geo, subjects, ti):
"""Index of a person who is the foreground shoulder of an OTS, or None:
a person (not the target) within 1.6 m of the camera, nearer than the
target, and within 35 degrees of the look direction."""
best, best_d = None, 9e9
look = _norm((look_at[0] - cpos[0], 0.0, look_at[2] - cpos[2]))
for k, s_ in enumerate(subjects):
if k == ti or str(s_.get("kind") or "person") != "person":
continue
opos = [float(x) for x in s_.get("pos", [0, 0, 0])]
d = math.hypot(opos[0] - cpos[0], opos[2] - cpos[2])
if d > 1.6 or d >= geo["ground"] - 0.3 or d < 0.15:
continue
v = _norm((opos[0] - cpos[0], 0.0, opos[2] - cpos[2]))
cosang = look[0] * v[0] + look[2] * v[2]
if cosang < math.cos(math.radians(35)):
continue
if d < best_d:
best, best_d = k, d
return best
def _ots_side(cpos, look_at, opos):
"""(left|right, lateral metres): which edge of the frame the foreground
person sits at, and how far off the look line they are."""
look = _norm((look_at[0] - cpos[0], 0.0, look_at[2] - cpos[2]))
right = (-look[2], 0.0, look[0])
dot = (opos[0] - cpos[0]) * right[0] + (opos[2] - cpos[2]) * right[2]
return ("right" if dot >= 0 else "left"), abs(dot)
def _faces_each_other(a, b):
"""True when a's facing points roughly at b (within 60 degrees)."""
apos = [float(x) for x in a.get("pos", [0, 0, 0])]
bpos = [float(x) for x in b.get("pos", [0, 0, 0])]
to_b = math.degrees(math.atan2(bpos[0] - apos[0], bpos[2] - apos[2])) # 0 = +z
d = (to_b - float(a.get("facing_deg", 0)) + 180) % 360 - 180
return abs(d) < 60
def _facing_words(kind, o_rel, prime, o, oname, sname):
"""Which way a person / object / door faces, as the camera sees it."""
a = abs(o_rel)
if kind == "person":
if _faces_each_other(o, prime):
return "facing %s" % sname
if a < 35:
return "facing the camera"
if a < 110:
return "turned side-on to the camera"
return "with their back to the camera"
if kind == "door":
return "an open doorway"
# an object: its front
if a < 45:
return "its front toward the camera"
if a < 135:
return "seen from its side"
return "its back to the camera"
# ---------------------------------------------------------------- lights
# THE LIGHT RIG ( "add a light source like Unreal -
# diameter, soft or hard, directional or orbital, colour tone - through
# prompting"). Same contract as the camera translator and the depth-of-field
# sentence: geometry and physics in, prose out, no renderer involved.
#
# WHY THIS CAN WORK AT ALL, and the rule every sentence here obeys: the corpus
# study behind prompt_lists.LIGHTING found that lighting is followed when it
# names A SOURCE, A DIRECTION and WHAT IT LANDS ON, and ignored when it is a
# bare label ("cinematic lighting" measured as a large change with no control).
# A light standing on the stage knows all three, and knows them in the units a
# photographer would use, so it writes the long form every time.
#
# The physics that is real rather than decorative:
# - HARDNESS is angular size. A source's apparent width from the subject,
# 2*atan(d/2L), decides the shadow edge: the sun is 0.5 deg and cuts hard,
# a 1 m softbox at 1 m is 53 deg and wraps. This is the same kind of
# computation dof_words() makes from the blur circle, and it means "big
# light close" and "small light far" stop being guesses.
# - RATIO is inverse square. Key and fill intensities at their own distances
# give a lighting ratio in stops, which is what low-key and high-key
# actually mean, so the words follow the rig instead of a mood label.
# - DIRECTION is the same bearing maths the camera uses, read twice: against
# the VIEWER (screen left/right, which is what a viewer sees) and against
# the subject's own facing (short vs broad lighting, rim, backlight).
LIGHT_KINDS = ("softbox", "bulb", "sun", "window", "practical", "ambient")
# apparent-size bands -> shadow character. Degrees of angular width.
def hardness_words(angular_deg):
if angular_deg >= 45:
return ("very soft", "shadows so soft they barely have an edge")
if angular_deg >= 20:
return ("soft", "soft-edged shadows that wrap around the form")
if angular_deg >= 8:
return ("fairly soft", "gently graded shadow edges")
if angular_deg >= 2:
return ("crisp", "clearly drawn shadow edges")
return ("hard", "hard-edged shadows with a sharp line between light and dark")
def angular_size_deg(diameter_m, distance_m):
"""How wide the source looks from where it is standing, in degrees."""
d = max(0.001, float(diameter_m))
L = max(0.05, float(distance_m))
return math.degrees(2.0 * math.atan((d / 2.0) / L))
def colour_words(kelvin):
"""Colour of the light. 0 = don't mention it.
NAME THE COLOUR, NEVER THE THING THAT MAKES IT:
the first version said "candle-warm" under 2200 K and the model drew
candles. A colour word is a description; a light-source noun is an object,
and an object named in a prompt gets rendered. Same reason the source
wording below stopped saying softbox and bulb.
"""
try:
k = float(kelvin or 0)
except (TypeError, ValueError):
k = 0.0
if k <= 0:
return ""
if k < 2200:
return "deep amber"
if k < 3000:
return "warm amber"
if k < 4000:
return "warm white"
if k < 5200:
return "neutral white"
if k < 6500:
return "cool white"
if k < 9000:
return "cold blue-white"
return "icy blue"
# HOW MUCH LIGHT LANDS, which is what makes a picture dark - NOT the colour
#.
# 1900 K is the colour of a candle flame, not its level: a warm scene can be
# blazing and a blue one can be nearly black. Level is power over distance
# squared, and the nominal is power 1.0 at 2 m, so a light dragged back really
# does dim the scene, exactly as it would in a room.
LEVEL_NOMINAL = 0.25 # illuminance of power 1.0 at 2 m
LEVEL_BANDS = (0.12, 0.4, 2.5, 8.0)
def illuminance(intensity, distance_m):
try:
i = max(0.0, float(intensity))
except (TypeError, ValueError):
i = 1.0
return i / max(0.05, float(distance_m)) ** 2
def level_words(rel, sname="the subject"):
"""(sentence, step) for how lit the scene is, relative to the nominal.
step: -2 very dark .. +2 blown, 0 = ordinary, which says nothing.
DARKNESS IS A THING, NOT AN EXPOSURE ( "I am still not
getting the dark effect I want"). The corpus-validated entries in
prompt_lists.LIGHTING never say "the frame is dark"; they name the lit POOL
and then what lies BEYOND it - "deep shadow beyond a tight pool of orange
light", "everything beyond the pool falling away into dark", "leaving the
room behind dark". A model can draw an unlit background; it cannot draw an
f-stop. So the dark bands describe the room, not the exposure.
"""
lo, dim, hi, blown = LEVEL_BANDS
if rel < lo:
return ("a tight pool of light on %s and nothing else, the background "
"unlit and velvety black, the walls and floor beyond swallowed "
"by it" % sname, -2)
if rel < dim:
return ("the light falling away quickly past %s, the background dark and "
"unlit, only the nearest shapes catching any of it" % sname, -1)
if rel < hi:
return ("", 0)
if rel < blown:
return ("everything around %s lit as well, shadows open and full of "
"detail" % sname, 1)
return ("the whole scene flooded with light, highlights close to blowing "
"out", 2)
def rig_level(camera, subjects, lights):
"""The level step a placed rig implies, or 0 when there is none. The frame's
own Brightness dial can disagree with this, and prompt_frame says so."""
live = [l for l in (lights or []) if l.get("on", True)]
if not live:
return 0
ti = camera.get("target")
if not isinstance(ti, int) or ti < 0 or ti >= len(subjects):
ti = 0
prime = subjects[ti]
spos = [float(x) for x in prime.get("pos", [0, 0, 0])]
face = [spos[0], spos[1] + float(prime.get("height", 1.7)) * 0.92, spos[2]]
total = 0.0
for l in live:
lpos = [float(x) for x in l.get("pos", [0, 2.0, 0])]
total += illuminance(l.get("intensity", 1.0), _len(_v(lpos, face)))
return level_words(total / LEVEL_NOMINAL)[1]
def _light_geometry(light, subject_face, subject_pos, facing_deg, cam_pos):
"""Where a light stands, in the two frames a sentence needs: against the
VIEWER (screen left/right, front/back) and against the SUBJECT's facing."""
lpos = [float(x) for x in light.get("pos", [0, 2.0, 0])]
geo = camera_geometry(lpos, subject_face) # light -> face
dist = geo["distance"]
# elevation of the light as seen from the face: + is above
elev = math.degrees(math.atan2(lpos[1] - subject_face[1],
max(0.01, geo["ground"])))
# bearing of the light around the subject, measured against the CAMERA's
# bearing: 0 = from behind the camera, 180 = straight into the lens
b_light = bearing_from(subject_pos, lpos)
b_cam = bearing_from(subject_pos, cam_pos)
rel_cam = (b_light - b_cam + 180) % 360 - 180 # + = viewer's right
rel_face = rel_bearing(subject_pos, lpos, facing_deg)
return {"pos": lpos, "distance": dist, "elev": elev,
"rel_cam": rel_cam, "rel_face": rel_face}
def _direction_words(g):
"""Where the light comes from, told the way a viewer sees it."""
a = abs(g["rel_cam"])
side = "right" if g["rel_cam"] > 0 else "left"
if a < 18:
where = "from behind the camera, straight onto the subject"
elif a < 65:
where = "from the %s of frame" % side
elif a < 115:
where = "from directly %s of the subject, edge on" % side
elif a < 160:
where = "from behind the subject on the %s, raking forward" % side
else:
where = "from directly behind the subject, straight back into the lens"
e = g["elev"]
if e >= 55:
height = "almost overhead"
elif e >= 25:
height = "high and angled down"
elif e >= 8:
height = "a little above the face"
elif e >= -8:
height = "level with the face"
elif e >= -30:
height = "from below the face"
else:
height = "from low down, throwing shadows upward"
return where, height
# The classic setups, recognised from the geometry. A named setup is obeyed far
# better than a described one (the same reason the camera translator names its
# bands), so when a rig lands on one, the sentence says its name as well.
def named_setup(g, hard, filled=False):
e, a, rel_face = g["elev"], abs(g["rel_cam"]), g["rel_face"]
if a >= 160:
# a silhouette needs an EMPTY shadow side; with a fill in the rig the
# same geometry is a rim/backlight, and calling it a silhouette while
# asking for shadow detail is a contradiction the model has to resolve
if filled:
return "a strong backlight drawing a bright rim around the edges"
return "a backlit silhouette" if hard else "a soft backlight"
if a >= 115:
return "rim lighting"
if 22 <= e <= 62 and 28 <= a <= 68:
return "Rembrandt lighting, a small triangle of light on the shadow cheek"
if e >= 35 and a < 25:
return "butterfly lighting, a small shadow straight under the nose"
if 85 <= a <= 115 and abs(e) < 25:
return "split lighting, half the face lit and half in shadow"
if e <= -25 and a < 65:
return "uplighting from below"
if a < 20 and abs(e) < 20:
return "flat frontal lighting"
if abs(rel_face) > 100 and a < 90:
return "short lighting, the lit side turned away from the camera"
return ""
def _ratio_words(key, others):
"""Key-to-fill in stops, which is what low-key and high-key really mean."""
def pull(l):
try:
i = float(l.get("intensity", 1.0))
except (TypeError, ValueError):
i = 1.0
return max(0.0, i)
kp = pull(key["light"]) / max(0.05, key["geo"]["distance"]) ** 2
fills = [pull(o["light"]) / max(0.05, o["geo"]["distance"]) ** 2 for o in others]
if kp <= 0 or not fills or max(fills) <= 0:
return "the shadow side falling away with no fill" if not fills else ""
stops = math.log(kp / max(fills), 2)
if stops >= 4:
return "the fill far below it, so the shadows stay deep and low-key"
if stops >= 2:
return "a fill about %d stops down, keeping detail in the shadows" % round(stops)
if stops >= 0.7:
return "a fill close behind it, an even, high-key balance"
return ("the two nearly equal, flat and shadowless" if len(fills) == 1
else "the others nearly as strong, flat and shadowless")
def light_words(camera, subjects, lights, join=", "):
"""The lighting paragraph for a stage's lights, or "" when there are none.
Source, direction and what it lands on, every time, plus the shadow
character its real angular size implies, its colour, and the ratio to any
other light. Lights beyond the first two are summarised rather than
described: a paragraph naming four exact sources averages into mush, which
is the same obedience ceiling the reference stack has.
"""
live = [l for l in (lights or []) if l.get("on", True)]
if not live:
return ""
geo0, _rel = _prime_geo(camera, subjects)
ti = camera.get("target")
if not isinstance(ti, int) or ti < 0 or ti >= len(subjects):
ti = 0
prime = subjects[ti]
sname = str(prime.get("name") or "the subject")
spos = [float(x) for x in prime.get("pos", [0, 0, 0])]
face = [spos[0], spos[1] + float(prime.get("height", 1.7)) * 0.92, spos[2]]
cpos = [float(x) for x in camera.get("pos", [0, face[1], 3.0])]
rigs = []
for l in live:
g = _light_geometry(l, face, spos, prime.get("facing_deg", 0), cpos)
rigs.append({"light": l, "geo": g,
"power": (float(l.get("intensity", 1.0) or 0)
/ max(0.05, g["distance"]) ** 2)})
rigs.sort(key=lambda r: -r["power"])
key, others = rigs[0], rigs[1:]
kl, kg = key["light"], key["geo"]
kind = str(kl.get("kind") or "softbox")
if kind not in LIGHT_KINDS:
kind = "softbox"
# the sun is a special case: 150 million km away, so its angular size is
# fixed at half a degree no matter what diameter the stage gives it
ang = 0.53 if kind == "sun" else angular_size_deg(kl.get("diameter", 1.0), kg["distance"])
hard_word, shadow_words = hardness_words(ang)
where, height = _direction_words(kg)
colour = colour_words(kl.get("kelvin", 0))
tint = (colour.replace(" ", "-") + " ") if colour else ""
# HARDWARE IS AN OBJECT. "softbox" and "bulb" put studio gear in the frame
# the same way "candle-warm" put candles in it, so the sentence describes
# the LIGHT and only names a thing where the thing is wanted in shot: a
# practical lamp, and a window, which is a feature of the room anyway.
# A panel and a bare bulb differ by ONE thing a picture can show: how hard
# their shadows are, which the size and distance already decided. So neither
# names itself; "soft light" and "hard light" is the whole honest difference,
# and the direction clause that follows reads without a second "from".
source = {"softbox": "%s %slight" % (hard_word, tint),
"bulb": "%s %slight" % (hard_word, tint),
"sun": "hard direct %ssunlight" % tint,
"window": "%s %sdaylight through a window" % (hard_word, tint),
"practical": "%s %slight cast by a lamp in the scene" % (hard_word, tint),
"ambient": "%s %sambient light" % (hard_word, tint)}[kind]
lead = "%s lit by %s %s, %s" % (sname, source, where, height)
bits = [lead, shadow_words]
# the LEVEL of the whole rig, so turning the power down darkens the picture
# instead of doing nothing (with one light it used to do exactly nothing:
# intensity only ever fed the ratio between two of them)
total_e = sum(illuminance(r["light"].get("intensity", 1.0), r["geo"]["distance"])
for r in rigs)
lvl, _step = level_words(total_e / LEVEL_NOMINAL, sname)
if lvl:
bits.append(lvl)
setup = named_setup(kg, ang < 8, filled=bool(others))
if setup:
bits.append(setup)
ratio = _ratio_words(key, others)
if ratio:
bits.append(ratio)
if len(others) == 1:
og = others[0]["geo"]
ow, _oh = _direction_words(og)
oc = colour_words(others[0]["light"].get("kelvin", 0))
bits.append("a second, %sweaker light %s"
% (oc.replace(" ", "-") + " " if oc else "", ow))
elif len(others) > 1:
bits.append("%d further sources filling the scene" % len(others))
return _cap(join.join(b for b in bits if b)) + "."
# ------------------------------------------------------- the lighting LoRAs
# Two third-party sliders the light rig can drive, the way the camera drives
# its own. Every number below was MEASURED on the sandbox (2026-08-19, 54
# images, mean luminance and a warm/cool balance per frame); the tables are in
# the hub's CAMERA_STUDIO.md. Nothing here is assumed, because the two authors
# of "light sliders" disagree about which way is brighter.
LIGHT_LORA_KEYS = ("brightness", "colour")
LIGHT_LORA_RANGE = {"brightness": (-10.0, 10.0), "colour": (-4.0, 4.0)}
# the part of each dial that is actually usable, for the panel to say so
LIGHT_LORA_CLEAN = {"brightness": (-10.0, 10.0), "colour": (-4.0, 4.0)}
# BRIGHTNESS: negative darkens, positive brightens, and how well the plus side
# works depends entirely on HEADROOM (measured twice, 2026-08-19 - the first
# sweep only used well-exposed scenes and wrongly read the plus side as broken).
# on an already-lit scene: +1.5 and +3 do almost nothing, +6 lifts about 9%
# but blows 10-37% of the frame to white - it has nowhere to go;
# on a DARK scene, which is when anyone asks for it: 0.151 -> 0.219 at +1.5,
# 0.343 at +3, 0.466 at +6, 0.495 at +10, with under 5% clipped and the
# crushed blacks recovered from 16.7% to 0.7%. A real, monotonic lift.
# So auto works both ways, gently on the plus side because that is where the
# clipping lives when a scene is already bright.
# THE USER'S NUMBERS, and they overrule the strip (2026-08-19): "almost black
# is around -9 not -4, and around 10 is good for high brightness" - then "in
# fact you can use -10, its cleaner". The sandbox
# sweep was a bare Turbo at 8 steps with no other LoRAs on the model; their
# real stack carries the identity LoRA, the bypasses and a style, and a slider
# lands very differently underneath that lot. They judge by the generation, so
# the dial is theirs: the ends of the file's own range, and the halfway points
# between.
LIGHT_AUTO_BY_STEP = {-2: -10.0, -1: -5.0, 0: 0.0, 1: 5.0, 2: 10.0}
# COLOUR: positive is warm. Linear in MIREDS from a neutral, with a different
# factor each way because the LoRA is not symmetric: the warm side is gentle
# and near-linear to +4, the cool side turns violent past -2.5.
COLOUR_NEUTRAL_K = 5200.0
COLOUR_WARM_PER_MIRED = 0.011
COLOUR_COOL_PER_MIRED = 0.0435
# The DIAL runs the file's full -4..+4, but AUTO stops at the author's own
# recommendation - Loraholic's listing says "Recommended range: -3.0 to 3.0"
# (civitai 2760910). Automatic behaviour should not go past what the person who
# trained it suggests; Manual still reaches the ends for anyone who wants them.
COLOUR_MAX_WARM = 3.0
COLOUR_MAX_COOL = -3.0
def auto_light_strength(camera, subjects, lights):
"""Brightness-slider strength from the rig's own level: minus when the rig
is dim, plus when it is bright, 0 for an ordinary one."""
return LIGHT_AUTO_BY_STEP.get(rig_level(camera, subjects, lights), 0.0)
def key_light_kelvin(camera, subjects, lights):
"""The colour temperature of the light doing the work, or 0 when none of
them states one. The key is the strongest AT THE SUBJECT, the same rule
light_words() uses."""
live = [l for l in (lights or []) if l.get("on", True)]
if not live:
return 0.0
ti = camera.get("target")
if not isinstance(ti, int) or ti < 0 or ti >= len(subjects):
ti = 0
prime = subjects[ti]
spos = [float(x) for x in prime.get("pos", [0, 0, 0])]
face = [spos[0], spos[1] + float(prime.get("height", 1.7)) * 0.92, spos[2]]
best, best_power = None, -1.0
for l in live:
lpos = [float(x) for x in l.get("pos", [0, 2.0, 0])]
power = illuminance(l.get("intensity", 1.0), _len(_v(lpos, face)))
if power > best_power:
best, best_power = l, power
try:
return float((best or {}).get("kelvin", 0) or 0)
except (TypeError, ValueError):
return 0.0
def auto_colour_strength(camera, subjects, lights):
"""Colour-slider strength from the key light's kelvin. 0 when the rig does
not state a colour - saying nothing beats inventing a cast."""
k = key_light_kelvin(camera, subjects, lights)
if k <= 0:
return 0.0
mired = 1e6 / max(1000.0, k)
delta = mired - (1e6 / COLOUR_NEUTRAL_K) # + = warmer than neutral
if delta >= 0:
return round(min(COLOUR_MAX_WARM, delta * COLOUR_WARM_PER_MIRED), 2)
return round(max(COLOUR_MAX_COOL, delta * COLOUR_COOL_PER_MIRED), 2)
LIGHT_AUTO_FN = {"brightness": auto_light_strength, "colour": auto_colour_strength}
# ---------------------------------------------------------------- presets
# The Prompt Frame's Camera height stops as camera states, so the dial and
# the studio share one translator. Subject 1.7m at origin facing +z; camera