Private
Public Access
Initiales Halloween-Projekt
This commit is contained in:
commit
e071fbad2a
36 files changed
+2220
No files matched your search
+21
@@ -0,0 +1,21 @@
|
||||
# Lokale Python-Umgebung und Laufzeitdaten
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
data/
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
|
||||
# Referenzclip und gerenderte Vorschauen bleiben lokal.
|
||||
references/
|
||||
preview/**/*.blend1
|
||||
preview/**/*.png
|
||||
preview/**/*.jpg
|
||||
preview/**/*.jpeg
|
||||
preview/**/*.mp4
|
||||
preview/**/*.webm
|
||||
preview/**/*.wav
|
||||
@@ -0,0 +1,93 @@
|
||||
# Halloween – Projektregeln
|
||||
|
||||
## Ziel und aktueller Auftrag
|
||||
|
||||
Interaktives atmendes Grab für Halloween im Vorgarten der Schwester bauen.
|
||||
Gewünscht sind ein kräftiger Schreckeffekt, Kamera, Weboberfläche und lokal
|
||||
gespeicherte Bilder/Videoclips. Der Nutzer hat nun eine erste Pi-Software mit
|
||||
Liveansicht und simuliertem Effektablauf beauftragt; das allgemeine
|
||||
Uploadskript und die Blender-Konzeptvorschau wurden ebenfalls freigegeben.
|
||||
Hardwarebau und Anschluss echter Aktoren folgen nach Klärung der Mechanik,
|
||||
Versorgung und Sicherheit im Plan.
|
||||
|
||||
## Arbeitsweise
|
||||
|
||||
- Auf Deutsch, klar und knapp kommunizieren.
|
||||
- `PLAN.md` als Arbeitsplan pflegen; erledigte Punkte, offene Entscheidungen
|
||||
und verifizierte Ergebnisse getrennt dokumentieren.
|
||||
- Vor Änderungen vorhandene Dateien und tatsächliche Hardware prüfen.
|
||||
- Vorschläge und Annahmen ausdrücklich kennzeichnen; keine Kameramodelle,
|
||||
Motorleistungen, Anschlüsse, Netzwerkadressen oder Preise erfinden.
|
||||
- Klein anfangen: einen vollständigen Effektablauf zuverlässig umsetzen,
|
||||
anschließend zusätzliche Effekte ergänzen.
|
||||
- Dokumentation übersichtlich halten; diese Datei unter 400 Zeilen halten.
|
||||
- Im lokalen Git Code, Planung und bearbeitbare Blender-Szenen versionieren;
|
||||
Renderdateien, Referenzclip, `.venv` und `data/` bleiben unversioniert.
|
||||
|
||||
## Hardware und Architektur
|
||||
|
||||
- Vorhanden laut Nutzer: Raspberry Pis 3–5; Pi 5 auf Nutzerfoto bestätigt,
|
||||
RAM vermutlich 8 GB laut Nutzer, noch nicht am Gerät geprüft;
|
||||
außerdem „2mini“ (genaue Geräte unbekannt), Camera Module 3 NoIR
|
||||
(Beschriftung auf Nutzerfoto bestätigt), eventuell Display,
|
||||
LEDs, Servos und Lötwerkzeug. Weitere Teile dürfen vorgeschlagen werden.
|
||||
- Vorgeschlagene Aufteilung: Pi 5 für Kamera/WebUI/Ereignisse und Pi 3/4
|
||||
für Effektsteuerung. Noch keine verbindliche Hardwareentscheidung.
|
||||
- Für die NoIR-Kamera IR-Beleuchtung bei Dunkelheit und ein passendes
|
||||
Standard-auf-Mini-Kamerakabel für Pi 5 einplanen; vorhandenes Kabel prüfen.
|
||||
- GPIOs steuern geeignete Treiber; Motoren, Servos und größere LED-Lasten
|
||||
erhalten eine passend dimensionierte Versorgung.
|
||||
- Mechanik erst nach Klärung von Abmessungen, Gewicht, Hub und Bewegungsraum
|
||||
auswählen. Atembewegung und kurzer Schreckimpuls getrennt bewerten.
|
||||
|
||||
## Betriebsregeln
|
||||
|
||||
- Bewegung bleibt im Grab und außerhalb des Besucherwegs; Quetschstellen
|
||||
abdecken. Leichte bewegte Bauteile und mechanische Anschläge vorsehen.
|
||||
- Elektronik für Außeneinsatz geschützt planen; Kleinspannung am Grab,
|
||||
Zugentlastung und geeignete Stromversorgung vorsehen.
|
||||
- Bei Start, Verbindungsverlust oder Fehler keine neue Schrecksequenz
|
||||
auslösen; definierte sichere Zustände und lokalen Stopp vorsehen.
|
||||
- Sperrzeit und belegten Auslösebereich berücksichtigen.
|
||||
- Kinder-/Sanftmodus anbieten. Kamerabasierte Größenhinweise sind unsicher
|
||||
und dürfen nicht als zuverlässige Altersbestimmung gelten.
|
||||
- Nutzerwunsch: „voller Schreck“ ist der anfängliche Standard und wird nach
|
||||
neuem gültigem Trigger automatisch ausgelöst, ohne Einzelfreigabe.
|
||||
Modi sind in der WebUI umstellbar; eine gewählte Einstellung soll einen
|
||||
normalen Neustart überdauern. Nach Start erst freien Auslösebereich
|
||||
abwarten; nach Stopp oder Fehler bewusst zurücksetzen. Kamerahinweise
|
||||
gelten nicht als verlässliche Altersentscheidung; automatische Reaktion
|
||||
auf Hinweise zu kleinen Besuchern vor Implementierung abstimmen.
|
||||
|
||||
## Kamera, Aufnahmen und Zugriff
|
||||
|
||||
- Personen als Ereignisse erkennen, ohne Identitäten zu bestimmen.
|
||||
- Keine Gesichtserkennung oder automatische Wiedererkennung konkreter
|
||||
Personen über getrennte Videos hinweg implementieren.
|
||||
- Clips mit Zeit, Vorschaubild und Ereignistyp zum manuellen Prüfen anbieten.
|
||||
- Aufnahmebereich auf den vorgesehenen privaten Bereich begrenzen;
|
||||
Maskierung, Aufbewahrung und Löschfrist vor Umsetzung festlegen.
|
||||
- Tonwiedergabe und Tonaufnahme unterscheiden; Tonaufnahme standardmäßig aus.
|
||||
- WebUI zunächst nur im lokalen Netz; keine öffentliche Freigabe ohne
|
||||
ausdrücklichen Auftrag. Zugangsschutz einplanen.
|
||||
|
||||
## Erste Softwareversion
|
||||
|
||||
- `run.py` startet die FastAPI-WebUI nur auf `127.0.0.1`; Laptop-Zugriff
|
||||
erfolgt per SSH-Tunnel. `--demo` kennzeichnet ein synthetisches Bild.
|
||||
- Picamera2 liefert das MJPEG-Livebild; auf dem Entwicklungsrechner wurde
|
||||
nur der Demo-Feed geprüft, nicht die echte Pi-Kamera.
|
||||
- Moduswahl und simulierte Sequenz sind implementiert. GPIO, automatische
|
||||
Personenauslösung und Clip-Speicherung sind noch offen.
|
||||
- Laufzeitdaten unter `data/` nicht mit dem Uploadskript überschreiben.
|
||||
- Optionaler systemd-Autostart der WebUI wird mit `--install-service` beim
|
||||
Upload eingerichtet; auf einem echten Pi noch nicht geprüft.
|
||||
|
||||
## Prüfung
|
||||
|
||||
- Softwareprüfungen, Hardwaretests und Außentests getrennt berichten.
|
||||
- Erst ohne angeschlossene Aktoren simulieren, dann einzelne Aktoren testen,
|
||||
anschließend Gesamtablauf und Fehlerfälle prüfen.
|
||||
- Kamera bei tatsächlicher Dunkelheit, Beleuchtung und Verkleidungen testen.
|
||||
- Sicheren Zustand bei Netzwerkausfall, Neustart und Stopp verifizieren.
|
||||
- Dokumentationsänderungen erfordern keine zusätzlichen Softwaretests.
|
||||
@@ -0,0 +1,395 @@
|
||||
# Halloween: interaktives atmendes Grab
|
||||
|
||||
Stand: 1. Oktober 2026. Status: Entwurf zur Abstimmung; erste Software mit
|
||||
Livebild und simulierter Steuerung vorhanden, reale Mechanik noch offen.
|
||||
|
||||
## Zielbild
|
||||
|
||||
Eine Grabattrappe im Vorgarten atmet langsam. Nähert sich ein Besucher,
|
||||
stoppt die Bewegung. Nach einer kurzen wechselnden Pause folgen Kratzen,
|
||||
ein kräftiger Geräuschimpuls, eine kurze Bewegung und Licht. Optional hebt
|
||||
sich eine Skelett-Hand innerhalb der Attrappe.
|
||||
|
||||
Die Weboberfläche zeigt das Livebild, Betriebszustand und Ereignisse.
|
||||
Bilder und kurze Videoclips lassen sich auf dem Laptop speichern und
|
||||
manuell prüfen. Die Erkennung meldet Personen oder Bewegungen in definierten
|
||||
Bereichen; sie identifiziert keine Personen.
|
||||
|
||||
## Bisherige Wünsche und Bestand
|
||||
|
||||
- Einsatz draußen im Vorgarten; möglichst starker Schreckeffekt.
|
||||
- Nutzerwunsch für die Graboberfläche: Folie, darüber grobes Gewebe und
|
||||
Blätter. Die Attrappe soll ungefähr die Proportionen eines Jugendlichen
|
||||
haben; „besenstiellang“ war die erste Größenbeschreibung. Exakte Länge,
|
||||
Breite und bewegter Bereich sind noch offen.
|
||||
- Die Atembewegung soll wie im Referenzvideo mehrere ungleichmäßige,
|
||||
örtliche Wölbungen erzeugen. Die Gestaltung darf detailreicher und
|
||||
realistischer werden als die Vorlage.
|
||||
- Am vorgesehenen Standort stehen nach grober Schätzung des Nutzers etwa
|
||||
2 m² zur Verfügung. Länge, Breite und Abstand zum Besucherweg sind noch
|
||||
nicht gemessen.
|
||||
- [Originalclip](references/atmendes-grab-original.mp4) als Referenz vom
|
||||
Nutzer bereitgestellt; [öffentliche Quelle](https://www.facebook.com/joshua.brawer/videos/1075933321689584/).
|
||||
- Raspberry Pis 3–5 vorhanden. Raspberry Pi 5 anhand der Platinenbeschriftung
|
||||
auf Nutzerfoto bestätigt; vermutlich 8 GB RAM laut Nutzer, noch zu prüfen.
|
||||
- Der Pi 5 unterstützt laut [Herstellerdaten](https://www.raspberrypi.com/products/raspberry-pi-5/)
|
||||
2,4- und 5-GHz-WLAN (Wi-Fi 5/802.11ac). Empfang am Aufstellort und die
|
||||
tatsächliche Verbindung sind noch nicht geprüft.
|
||||
- Aufkleber am Pi zeigt `raspi5WLAN.local` und darunter `_2`; tatsächlicher
|
||||
Hostname und Erreichbarkeit sind noch nicht geprüft.
|
||||
- „2mini“ vorhanden: genaue Bezeichnung noch klären.
|
||||
- Kamera: Raspberry Pi Camera Module 3 NoIR, anhand der Beschriftung auf dem
|
||||
Nutzerfoto identifiziert. Funktion noch nicht getestet; Display eventuell vorhanden.
|
||||
- LEDs, Servos und Lötwerkzeug vorhanden; weitere Teile können gekauft werden.
|
||||
- Kameraauswertung soll Hinweise auf kleine Kinder berücksichtigen.
|
||||
- Der volle Schreck soll nach Auslösung automatisch ablaufen, ohne
|
||||
Freigabe für jeden einzelnen Besucher.
|
||||
- „Voller Schreck“ ist der gewünschte Standardmodus; der Modus soll später
|
||||
in der WebUI auf Sanft, Atmosphäre oder Aus umstellbar sein.
|
||||
- Liveansicht sowie Speicherung von Fotos und Videoclips auf dem Laptop.
|
||||
- Wunsch nach Prüfung verdächtiger Rückkehr: Ereignisübersicht und manuelles
|
||||
Vergleichen der Clips; keine automatische personenübergreifende Identifizierung.
|
||||
|
||||
## Erkenntnisse aus dem Referenzvideo
|
||||
|
||||
Im etwa 17,5 Sekunden langen Clip ist ein rechteckiger Holzrahmen zu sehen.
|
||||
Eine längs durch den Rahmen geführte Stange trägt mehrere versetzt befestigte
|
||||
Kunststoffflaschen. Ihre Lage ändert sich im Video; sie wirken wie leichte
|
||||
Exzenter, die die Abdeckung an mehreren Stellen unterschiedlich anheben.
|
||||
Anschließend wird eine dunkle Folie über den Rahmen gelegt. Im fertigen
|
||||
Aufbau liegen darüber ein grobes helles Gewebe und Blätter; blaue Beleuchtung
|
||||
verstärkt den Eindruck. Die Abdeckung wölbt sich örtlich, statt als starre
|
||||
Fläche insgesamt hochzufahren.
|
||||
|
||||
Das Video belegt weder Antriebsmodell noch Drehmoment, genaue Abmessungen,
|
||||
Hub oder wetterfeste Ausführung. Die Flaschen sind eine Anschauung für das
|
||||
Bewegungsprinzip, noch keine Festlegung für unseren Bau. Die Kombination aus
|
||||
Folie, Gewebe und Blättern ist nun als gewünschter Aufbau festgehalten.
|
||||
|
||||
**Gestaltungsvorschlag für mehr Realismus:** Matte, unregelmäßig fallende
|
||||
Gewebeschicht statt sichtbarer glänzender Folie; unterschiedlich dichte
|
||||
Laubstellen und eine flache, nicht symmetrische Kontur. Rahmenkanten,
|
||||
Antrieb und Kabel werden aus Besucherperspektive verdeckt. Grabmarkierung,
|
||||
Licht und Geräusche sollen die Illusion unterstützen, ohne die bewegte
|
||||
Fläche unnötig zu beschweren. Materialoptik bei Tageslicht und bei der
|
||||
geplanten Nachtbeleuchtung prüfen; lose Blätter und nasses Gewebe gehören
|
||||
zum Belastungstest.
|
||||
|
||||
## Vorgeschlagene Architektur
|
||||
|
||||
| Komponente | Aufgabe |
|
||||
|---|---|
|
||||
| Pi 5 | Kamera, anonyme Personenerkennung, WebUI, Ereignisse und Clip-Puffer |
|
||||
| Pi 3 oder 4 (optional) | Effekte, Ablaufsteuerung und lokale Bedienelemente |
|
||||
| Laptop | WebUI aufrufen, Livebild ansehen, Clips herunterladen |
|
||||
| Treiber und separate Versorgung | Motoren, Servos und Beleuchtung versorgen |
|
||||
|
||||
Der gewählte Auslöser meldet Ereignisse an die Effektsteuerung. Diese
|
||||
begrenzt Sequenzdauer und Wiederholungen selbst. Bei Kommunikationsverlust
|
||||
führt sie keine neuen Schreckbefehle aus.
|
||||
|
||||
**Architekturentscheidung:** In Phase 3 den vollständigen Ablauf mit einem
|
||||
Pi und simulierten Ausgängen prüfen. Über einen zweiten Pi nach Integration
|
||||
von Kamera und WebUI entscheiden, wenn Leitungswege, Aufstellung und Last
|
||||
bekannt sind. Lokaler Stopp und sichere Abschaltung
|
||||
der Antriebe dürfen in keiner Variante von WebUI oder WLAN abhängen. Falls
|
||||
zwei Pis nötig sind, erhält jeder Schreckbefehl eine begrenzte Gültigkeit;
|
||||
nach Verbindungsverlust bleibt die Effektseite ohne neue Auslösung.
|
||||
|
||||
### Software-Stack
|
||||
|
||||
Der gesamte Code wird in Python umgesetzt. Vorgeschlagene Technologien:
|
||||
|
||||
| Bereich | Vorschlag |
|
||||
|---|---|
|
||||
| WebUI / API | FastAPI mit uvicorn |
|
||||
| Livebild | picamera2 oder gstreamer, je nach Test |
|
||||
| Personen-/Bewegungserkennung | OpenCV, optional Raspberry Pi AI Kit / Hailo |
|
||||
| GPIO / Effekte | gpiozero oder lgpio |
|
||||
| Konfiguration | YAML- oder JSON-Dateien |
|
||||
| Logging / Clips | Standardbibliothek `logging`, Video mit OpenCV/FFmpeg |
|
||||
|
||||
### Code-Struktur
|
||||
|
||||
Die Dateien bleiben, soweit sinnvoll, unter 400 Zeilen. Dabei bleibt
|
||||
Wartbarkeit wichtiger als sture Einhaltung der Grenze. Vorgesehene Module:
|
||||
|
||||
- `config.py` – zentrale Konfiguration und Validierung
|
||||
- `camera.py` – Kamerazugriff und Capture-Puffer
|
||||
- `detector.py` – anonyme Personen-/Bewegungserkennung
|
||||
- `events.py` – Ereignisverwaltung und Clip-Speicherung
|
||||
- `effects.py` – Effektablaufsteuerung, bei Bedarf auf dem Effekt-Pi
|
||||
- `web.py` – FastAPI-Endpunkte und MJPEG-Livebild in der ersten Version
|
||||
- `main_controller.py` – Start, Verbindung und Hauptloop auf Pi 5
|
||||
- `main_effects.py` – bei zwei Pis: Start und Überwachung auf dem Effekt-Pi
|
||||
- `shared/protocol.py` – bei zwei Pis: Nachrichtenformat zwischen Pis
|
||||
|
||||
Der Laptop muss für Liveansicht eingeschaltet sein. Vorgeschlagen ist ein
|
||||
begrenzter Clip-Puffer auf dem Pi, damit Ereignisse auch ohne Laptop später
|
||||
abrufbar bleiben. Speicherziel und Löschfrist sind noch offen. Für einen
|
||||
vollen Speicher ist ein sichtbarer Fehlerzustand vorzusehen; die
|
||||
Effektsteuerung darf dadurch nicht blockieren.
|
||||
|
||||
Für die erste nutzbare Version genügen ein Auslösebereich, ein kurzer
|
||||
Effektablauf, lokaler Stopp, Betriebsanzeige und ein abrufbarer Ereignisclip.
|
||||
Hand, Display und weitere Effekte folgen erst nach bestandenem Außentest
|
||||
dieses Ablaufs.
|
||||
|
||||
## Kamera und Nachtbetrieb
|
||||
|
||||
Das vorhandene Camera Module 3 NoIR besitzt Autofokus und keinen IR-Sperrfilter.
|
||||
Mit zusätzlicher Infrarotbeleuchtung ist Nachtbetrieb möglich; ohne Beleuchtung
|
||||
liefert es in völliger Dunkelheit kein brauchbares Bild. IR-Leuchte, Reichweite,
|
||||
Position und Reflexionen am Schutzfenster müssen am Aufstellort getestet werden.
|
||||
|
||||
Für Pi 5 wird ein Kamerakabel von Standard (15-polig, kameraseitig) auf Mini
|
||||
(22-polig, Pi-seitig) benötigt. Die Pi-seitige Ausführung des vorhandenen Kabels
|
||||
ist auf dem Foto nicht sichtbar und bleibt zu prüfen. Wetterschutz und Sichtfeld
|
||||
sind noch offen. Personenmodelle müssen mit den tatsächlichen Nachtbildern
|
||||
geprüft werden; NoIR allein garantiert keine zuverlässige Erkennung.
|
||||
|
||||
Vor dem Bau am Aufstellort Sichtfeld, Dunkelbild, Gegenlicht und Reflexionen
|
||||
prüfen. Dabei entscheiden, ob die Kamera zuverlässig auslösen kann oder ob
|
||||
ein zusätzlicher, noch auszuwählender Sensor den Auslöseimpuls liefert und
|
||||
die Kamera Bild und Clip aufzeichnet.
|
||||
|
||||
Quellen: [Camera Module 3](https://www.raspberrypi.com/products/camera-module-3/)
|
||||
und [Kamerakabel](https://www.raspberrypi.com/products/camera-cable/).
|
||||
|
||||
## Effektablauf und Modi
|
||||
|
||||
1. Ruhe: langsames Atmen, schwaches Licht, gelegentliche Geräusche.
|
||||
2. Annäherung: Atmen stoppt, kurze Stille.
|
||||
3. Auslösung: Kratzen, kurzer Tonimpuls, begrenzter Ruck und Licht.
|
||||
4. Optional: leichte Hand hebt sich am Grabrand.
|
||||
5. Rückkehr in sicheren Zustand, anschließend Sperrzeit.
|
||||
|
||||
Modi: Aus, Atmosphäre, Sanft, voller Schreck und Wartung/Test.
|
||||
Vorschlag: Ohne gespeicherte Auswahl ist „voller Schreck“ der Standard;
|
||||
eine Änderung in der WebUI bleibt nach normalem Neustart erhalten.
|
||||
Ein lokaler Stopp muss unabhängig vom Browser funktionieren.
|
||||
Im Modus „voller Schreck“ startet die Sequenz nach einem gültigen Trigger
|
||||
automatisch; Sperrzeit und Stopp bleiben wirksam. Nach dem Einschalten erst
|
||||
Betriebsbereitschaft und einen freien Auslösebereich abwarten. Nur ein neuer
|
||||
Trigger danach darf eine Sequenz starten; ein alter Zustand löst nichts aus.
|
||||
Nach Stopp oder Fehler ist ein bewusstes Rücksetzen nötig.
|
||||
|
||||
Vor der Umsetzung für jeden Aktor die sichere Ruhelage, mechanischen
|
||||
Anschlag und maximal erlaubte Laufzeit festlegen. Ein gut erreichbarer
|
||||
lokaler Schalter soll die Antriebsversorgung unabhängig vom Programm
|
||||
unterbrechen können; danach darf kein selbsttätiger Neustart erfolgen.
|
||||
Ein weiterer Trigger während der Sequenz löst keinen zweiten Effekt aus.
|
||||
Bei unklarem Steuersignal endet die Bewegung.
|
||||
|
||||
Körpergröße im Kamerabild ist nur ein unsicherer Hinweis. Abstand, Verdeckung,
|
||||
Kostüme und Kinderwagen können die Auswertung verfälschen. Eine zuverlässige
|
||||
automatische Altersentscheidung ist damit nicht möglich. Der Nutzer wünscht
|
||||
dennoch „voller Schreck“ als Standard mit automatischer Auslösung. Im
|
||||
vollen Modus können deshalb auch Kinder den vollen Effekt abbekommen.
|
||||
Die WebUI bietet Sanft für entsprechende Betriebssituationen an. Ob ein
|
||||
unsicherer Kamerahinweis zusätzlich automatisch auf Sanft zurückschaltet
|
||||
oder nur als Hinweis angezeigt wird, bleibt vor Umsetzung zu entscheiden.
|
||||
Keine Freigabe pro Besucher.
|
||||
|
||||
## Weboberfläche
|
||||
|
||||
- Livebild mit Auslösebereich und optional maskierten Bildbereichen.
|
||||
- Moduswahl, manuelle Effektknöpfe und klar sichtbarer Stopp.
|
||||
- Kamera- und Steuerungsstatus sowie letzte Auslösung.
|
||||
- Ereignisliste mit Zeit, Vorschaubild, Ereignistyp und Clip.
|
||||
- Bild-/Clip-Download auf den Laptop.
|
||||
- Einstellungen für Lautstärke, Sperrzeit, Bereiche und Aufbewahrung.
|
||||
- Lokaler Zugangsschutz; keine öffentliche Internetfreigabe vorgesehen.
|
||||
- Aufnahmebereich vor dem Einsatz begrenzen und nötige Bildbereiche
|
||||
maskieren; Tonaufnahme bleibt standardmäßig aus.
|
||||
|
||||
## Phasen mit Abnahmekriterien
|
||||
|
||||
### 1. Anforderungen und Hardware klären
|
||||
|
||||
- [ ] Hardware- und Sicherheitsplan vor Bau und Aktoranschluss abschließen.
|
||||
- [x] Kameramodell anhand des Fotos feststellen: Camera Module 3 NoIR.
|
||||
- [ ] Pi-5-Kabel, Kamerafunktion und Nachtbild mit IR-Beleuchtung prüfen.
|
||||
- [ ] Sichtfeld und mögliche Auslösung am echten Aufstellort prüfen;
|
||||
Kamera- oder Zusatzsensor als Trigger festlegen.
|
||||
- [ ] Pis, Display, Servos und vorhandene Netzteile konkret erfassen.
|
||||
- [x] Raspberry Pi 5 anhand des Nutzerfotos identifiziert.
|
||||
- [ ] RAM, tatsächlichen Hostnamen, Betriebssystem, Netzteil und Kühlung des Pi 5 prüfen.
|
||||
- [ ] Die grob geschätzten 2 m² am Aufstellort als Länge × Breite und mit
|
||||
Abstand zum Besucherweg nachmessen; daraus jugendlich wirkende
|
||||
Grabmaße festlegen. Gewicht von Folie/Gewebe/Blättern und Hub messen.
|
||||
- [ ] Aufstellort, Besucherweg, Wetterschutz, Strom und Netzverbindung klären.
|
||||
- [ ] Budget, Clip-Aufbewahrung und Reaktion auf unsichere Kamerahinweise
|
||||
bei Kindern abstimmen.
|
||||
- [x] Automatische Auslösung statt Freigabe pro Besucher festgelegt.
|
||||
- [x] „Voller Schreck“ als Standard, per WebUI umstellbar, festgelegt.
|
||||
- [ ] Sichere Ruhelage, Bewegungsgrenzen und Stoppkonzept je Aktor festlegen.
|
||||
- [ ] Akzeptable Auslöseverzögerung, Sperrzeit und Fehlalarmrate festlegen.
|
||||
|
||||
Abnahme: konkrete Bauteile, Abmessungen, Grenzen und ein am Einsatzort
|
||||
plausibler Auslöser sind dokumentiert.
|
||||
|
||||
### 2. Mechanischen Prototyp bauen
|
||||
|
||||
- [ ] Leichten Rahmen und bewegte Oberfläche konstruieren.
|
||||
- [ ] Lokale Wölbungen durch eine längs gelagerte Stange mit weichen,
|
||||
versetzten Hebepunkten im Tischversuch bewerten.
|
||||
- [ ] Folie, Befestigung und Blattauflage auch im nassen Zustand prüfen.
|
||||
- [ ] Abdeckung aus Besucherperspektive bei Tag und Nacht auf sichtbare
|
||||
Mechanik, glänzende Stellen und verrutschende Blätter prüfen.
|
||||
- [ ] Atembewegung mit geeignetem Antrieb auf dem Tisch testen.
|
||||
- [ ] Schreckimpuls separat auswählen und begrenzen.
|
||||
- [ ] Anschläge, Abdeckungen und lokalen Stopp einplanen.
|
||||
|
||||
Abnahme: wiederholbare Bewegung ohne zugängliche Quetschstellen; keine
|
||||
Bewegung in den Besucherweg. Dauerbetrieb und Stopp sind praktisch geprüft.
|
||||
|
||||
### 3. Effektsteuerung entwickeln
|
||||
|
||||
- [x] Einen vollständigen Ablauf zunächst mit simulierten Ausgängen umsetzen.
|
||||
- [ ] Licht, Ton und Bewegung einzeln anschließen und testen.
|
||||
- [ ] Modi, Sperrzeit, Sequenzbegrenzung und Fehlerzustände umsetzen.
|
||||
- [ ] Stopp, Neustart, hängenden Befehl und Verbindungsverlust testen.
|
||||
- [x] Prüfen, dass nach Start erst ein freier Bereich und danach ein neuer
|
||||
Trigger die automatische Auslösung ermöglichen.
|
||||
- [x] Moduswechsel per Web-API und gespeicherten Modus nach Neustart lokal prüfen.
|
||||
|
||||
Abnahme: vollständiger Ablauf funktioniert ohne Kamera und Laptop. Nach
|
||||
Stopp oder Fehler bleibt der Antrieb aus und startet nur nach bewusstem
|
||||
Rücksetzen neu.
|
||||
|
||||
### 4. Kamera und WebUI ergänzen
|
||||
|
||||
- [x] MJPEG-Livebild, Schnappschuss und WebUI im Demo-Modus lokal prüfen.
|
||||
- [ ] Livebild und geschützten lokalen Zugriff bereitstellen.
|
||||
- [ ] Person-/Bereichserkennung ohne Identitätsbestimmung einrichten.
|
||||
- [ ] Gewählten Auslöser mit dem Effektablauf verbinden und Fehlalarme messen.
|
||||
- [ ] Ereignisse mit begrenztem Vor- und Nachlauf aufzeichnen.
|
||||
- [ ] Übersicht, Download und automatische Löschung umsetzen.
|
||||
- [ ] Speichergrenze und sichtbaren Fehler bei vollem Speicher prüfen.
|
||||
- [ ] Unsichere Erkennung und Sanftmodus sichtbar darstellen.
|
||||
|
||||
Abnahme: ein Ereignis erscheint mit passendem Clip, lässt sich herunterladen
|
||||
und wird nach der festgelegten Frist gelöscht; ohne Laptop gehen vorgesehene
|
||||
Ereignisse innerhalb der Speichergrenzen nicht verloren.
|
||||
|
||||
### 5. Außentest und Erweiterungen
|
||||
|
||||
- [ ] Dunkelheit, Beleuchtung, Verkleidungen und Personengruppen testen.
|
||||
- [ ] Auslösebereich und Fehlalarme praktisch prüfen.
|
||||
- [ ] Wetterschutz, Kabel und Temperatur im geschützten Aufbau prüfen.
|
||||
- [ ] Gesamtablauf mehrfach testen und Lautstärke abstimmen.
|
||||
- [ ] Stopp und Strom-/Netzausfall am aufgebauten Grab erneut prüfen.
|
||||
- [ ] Optional Skelett-Hand, Display oder weitere Effekte ergänzen.
|
||||
- [ ] Kurze Anleitung für Aufstellen, Start, Stopp und Abbau erstellen.
|
||||
|
||||
Abnahme: zuverlässiger Betrieb am vorgesehenen Ort; Grenzen der Erkennung
|
||||
sind bekannt und der Betreiber kann jederzeit stoppen oder sanft schalten.
|
||||
|
||||
## Offene Entscheidungen für den nächsten Schritt
|
||||
|
||||
1. Ist ein Pi-5-kompatibles Kamerakabel und eine IR-Leuchte vorhanden?
|
||||
2. Wie verteilen sich die grob geschätzten 2 m² auf Länge und Breite, und
|
||||
welcher Abstand zum Besucherweg bleibt für das Grab?
|
||||
3. Welches Budget ist vorgesehen?
|
||||
4. WLAN oder Kabel am Aufstellort? Wo kommen Netzteile geschützt unter?
|
||||
5. Clips auf dem Pi vorhalten oder nur bei eingeschaltetem Laptop speichern?
|
||||
6. Wie lange sollen Aufnahmen bleiben und wer darf die WebUI bedienen?
|
||||
7. Soll ein unsicherer Kamerahinweis auf kleine Besucher automatisch auf
|
||||
Sanft zurückschalten oder in der WebUI nur warnen?
|
||||
8. Soll die Kamera auslösen, oder soll ein zusätzlicher Sensor auslösen und
|
||||
die Kamera nur dokumentieren?
|
||||
9. Reicht nach dem ersten Test ein Pi, oder begründen Aufbau und Last einen
|
||||
zweiten Pi?
|
||||
|
||||
## Fortschritt
|
||||
|
||||
- [x] Projektregeln und erster Plan angelegt.
|
||||
- [x] Allgemeines Uploadskript und Anleitung für Projektdateien vorbereitet.
|
||||
- [x] Erste Kamera-WebUI mit Demo-Feed und simulierter Steuerung erstellt;
|
||||
fünf lokale Softwaretests bestanden.
|
||||
- [x] Optionales Installationsskript für WebUI-Autostart beim Pi-Start und
|
||||
Integration in das Uploadskript erstellt; nur lokal geprüft.
|
||||
- [x] Lokale Git-Versionsverwaltung für Code, Planung und bearbeitbare
|
||||
Blender-Szenen vorbereitet; Renderdateien bleiben lokal.
|
||||
- [ ] Plan abgestimmt und freigegeben.
|
||||
- [x] Softwareumsetzung begonnen; echte Kamera und Aktoren nicht geprüft.
|
||||
|
||||
Die WebUI ist lokal per ASGI und Demo-Bild geprüft. Pi-Kamera, SSH-Tunnel,
|
||||
Netz, Verdrahtung, Aktoren und Außeneinsatz sind nicht praktisch verifiziert.
|
||||
Das Upload- und Autostartskript wurde nur lokal geprüft; eine Verbindung zum
|
||||
Pi, die Unit-Installation und ein Neustart wurden noch nicht getestet.
|
||||
Einkaufslisten mit konkreten Modellen folgen nach Phase 1.
|
||||
|
||||
|
||||
## Blender-Konzeptvorschau (separat freigegeben)
|
||||
|
||||
- [x] Vorgarten, Grabattrappe und kostümierte Besucher als Blender-Szene erstellt.
|
||||
- [x] 20-Sekunden-Ablauf mit sanfter Familiensequenz und Schreck für Erwachsene animiert.
|
||||
- [x] Selbst erzeugte Tonspur mit Atem-, Kratz- und Knurrgeräuschen erstellt.
|
||||
- [x] Drei Vorschaubilder geprüft und Nachtbeleuchtung/Bildausschnitt überarbeitet.
|
||||
- [x] Vollständiges Video gerendert: 20 Sekunden, 1280 × 720, 24 fps, H.264 + AAC.
|
||||
- [x] Zwölf Bilder aus der fertigen MP4-Datei dekodiert und visuell geprüft.
|
||||
|
||||
Dateien liegen unter `preview/`; Reproduktionsskript: `tools/render_preview.py`.
|
||||
Die Figuren sind vereinfachte 3D-Modelle in Kapuzenkostümen. Garten, Maße und
|
||||
Reaktionen sind inszeniert; keine echte Kindererkennung oder mechanische
|
||||
Funktionsprüfung. Die Freigabe dieser Vorschau umfasst keine Effektsoftware
|
||||
oder Hardwareinstallation.
|
||||
|
||||
|
||||
### Zweite Bildfassung nach Nutzerwunsch
|
||||
|
||||
- [x] Dichte Laubschicht ergänzt, synchron mit der Atembewegung.
|
||||
- [x] Dunklere Stimmung, rotes Grablicht, kahle Äste und Bodennebel ergänzt.
|
||||
- [x] Kinder bleiben in der fiktiven Schreckszene bei den Erwachsenen.
|
||||
- [x] Gelenkige Beinbewegungen und schwingende Ärmel angelegt.
|
||||
- [x] Drei finale Beispielbilder visuell geprüft; nur Bilder gerendert.
|
||||
- [x] Acht animierte Beine und synchronisierte Laub-/Grabbewegung geprüft.
|
||||
- [ ] Laufbewegung im vollständigen Video prüfen, falls später beauftragt.
|
||||
|
||||
Ergebnisse: `preview/v2/`. Das ursprüngliche Video und seine Blender-Datei
|
||||
bleiben erhalten. Die filmische Inszenierung mit Kindern ändert keine
|
||||
Sicherheits-/Freigaberegeln für den realen Betrieb.
|
||||
|
||||
|
||||
### Dritte Bildfassung: mittige Hand und Laubwirbel
|
||||
|
||||
- [x] Hand und verdeckte Austrittsöffnung in die Grabmitte versetzt.
|
||||
- [x] 110 rotierende Blätter steigen beim Handimpuls auf und fallen zurück.
|
||||
- [x] Drei Beispielbilder vor, während und nach dem Hochkommen gerendert
|
||||
und visuell geprüft. Ergebnisse: `preview/v3/`.
|
||||
- [ ] Neuer Videorender weiterhin nicht beauftragt.
|
||||
|
||||
|
||||
### Vierte Bildfassung: dunklere Nachtbeleuchtung
|
||||
|
||||
- [x] Dunkler Nachthimmel und schwächeres Mond-/Grablicht eingerichtet.
|
||||
- [x] Fensterlicht und sichtbare warme Hauslaterne als Lichtquellen ergänzt.
|
||||
- [x] Gerichteter Lichtimpuls, Kontaktschatten, dezente Reflexionen und
|
||||
Lichtstreuung im Bodennebel eingerichtet.
|
||||
- [x] Drei finale Standbilder visuell geprüft; Ergebnisse: `preview/v4/`.
|
||||
- [ ] Kein neuer Videorender beauftragt.
|
||||
|
||||
|
||||
### Videorender der dunklen Fassung (freigegeben)
|
||||
|
||||
- [x] Acht bewegte Beingelenke in der gespeicherten Szene überprüft.
|
||||
- [x] 20-Sekunden-Video in 720p/24 fps mit Ton fertig gerendert.
|
||||
- [x] MP4-Metadaten und zwölf dekodierte Bilder einschließlich Schrittstellungen geprüft.
|
||||
|
||||
Ziel: `preview/v4/halloween_dark.mp4`, bearbeitbare Szene
|
||||
`preview/v4/halloween_dark.blend`. Das Renderprofil verwendet acht
|
||||
EEVEE-Bildsamples und 16 Nebelsamples; Lichtquellen, Schatten und
|
||||
Reflexionen der Bildfassung bleiben enthalten.
|
||||
|
||||
Der vollständige Render erfolgte mit freigegebenem Grafikkartenzugriff.
|
||||
|
||||
|
||||
### Tonergänzung beim Herauskommen der Hand
|
||||
|
||||
- [x] Tiefes synthetisches Stöhnen mit kurzem Höhlennachhall ergänzt,
|
||||
Beginn bei 13,04 Sekunden, Ausklang bis etwa 16,6 Sekunden.
|
||||
- [x] Bestehende Videobilder mit neuer Tonspur exportiert; 20 Sekunden,
|
||||
720p/24 fps und AAC-Spur geprüft. Ergebnis: `preview/v4/halloween_dark_groan.mp4`.
|
||||
- [x] Separate bearbeitbare Blender-Datei mit gepackter Tonspur gespeichert.
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
# Erste Pi-Softwareversion
|
||||
|
||||
Diese Version implementiert ein Livebild des vorhandenen Camera Module 3 NoIR
|
||||
über Picamera2, eine lokale WebUI, Moduswahl mit Speicherung und eine
|
||||
**Simulation** des Effektablaufs. Sie steuert noch keine GPIOs, Motoren,
|
||||
Servos, Lampen oder Lautsprecher. Automatische Personenerkennung, Clips,
|
||||
Maskierung und Löschfristen sind noch nicht umgesetzt. Daher vorerst nur
|
||||
im privaten Testbereich verwenden; die Kamera nicht auf öffentliche Flächen
|
||||
richten.
|
||||
|
||||
## Auf dem Raspberry Pi 5 vorbereiten
|
||||
|
||||
Die Kamera mit passendem Kabel anschließen und mit `rpicam-hello --list-cameras`
|
||||
prüfen. Danach im Projektordner:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install python3-picamera2 python3-venv
|
||||
python3 -m venv --system-site-packages .venv
|
||||
.venv/bin/python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Picamera2 wird über die Raspberry-Pi-Pakete installiert; die virtuelle
|
||||
Umgebung kann dieses Systempaket durch `--system-site-packages` verwenden.
|
||||
Der Server bindet nur an `127.0.0.1` auf dem Pi. So ist das Livebild nicht
|
||||
direkt im WLAN sichtbar.
|
||||
|
||||
### WebUI beim Hochfahren starten
|
||||
|
||||
Nach der Vorbereitung kann `tools/install_pi_service.sh` auf dem Pi einen
|
||||
systemd-Dienst einrichten. Erst eine Vorschau ansehen, dann anwenden:
|
||||
|
||||
```bash
|
||||
bash tools/install_pi_service.sh
|
||||
bash tools/install_pi_service.sh --apply
|
||||
systemctl status halloween-grab.service
|
||||
```
|
||||
|
||||
Der Dienst läuft unter dem aktuellen Pi-Benutzer, startet `run.py` beim
|
||||
Hochfahren und wird bei einem Fehler neu gestartet. Das Skript benötigt für
|
||||
die Einrichtung `sudo` und überschreibt keine fremde Unit gleichen Namens.
|
||||
Nach Code-Updates kann derselbe `--apply`-Aufruf den Dienst neu starten.
|
||||
Zum Deaktivieren: `sudo systemctl disable --now halloween-grab.service`.
|
||||
Bei Startproblemen: `sudo journalctl -u halloween-grab.service -n 80 --no-pager`.
|
||||
Ein Browserfenster auf dem Laptop öffnet sich dadurch nicht automatisch.
|
||||
|
||||
## Vom Laptop aus ansehen
|
||||
|
||||
Mit dem geprüften Pi-Benutzer und Hostnamen ein SSH-Terminal offen lassen:
|
||||
|
||||
```bash
|
||||
ssh -L 8000:127.0.0.1:8000 BENUTZER@HOST
|
||||
```
|
||||
|
||||
Dann auf dem Laptop `http://127.0.0.1:8000` öffnen. Ein SSH-Passwort reicht;
|
||||
ein Schlüssel ist nicht nötig. Ist Port 8000 auf dem Laptop belegt, links
|
||||
vom ersten Doppelpunkt einen anderen freien Port wählen; rechts bleibt der
|
||||
Pi-Port 8000, solange `run.py` dort mit dem Standardport läuft.
|
||||
|
||||
## Ohne Kamera testen
|
||||
|
||||
Auf einem Rechner mit den Paketen aus `requirements.txt`:
|
||||
|
||||
```bash
|
||||
python3 run.py --demo
|
||||
```
|
||||
|
||||
Die Bildfläche trägt deutlich „DEMO – KEINE ECHTE KAMERA“. Über „Bereich
|
||||
frei (Test)“ und danach „Testauslösung“ lässt sich die Zustandsfolge ohne
|
||||
Aktoren durchspielen. „Stopp“ sperrt weitere Auslösungen bis „Zurücksetzen“
|
||||
und erneuter Freigabe des Bereichs. Der Modus „voller Schreck“ ist nur bei
|
||||
noch nicht vorhandenen Einstellungen der Standard. Ein WebUI-Wechsel wird
|
||||
unter `data/settings.json` gespeichert und bleibt bei normalem Neustart
|
||||
erhalten. Das Uploadskript überträgt diesen Laufzeitordner nicht.
|
||||
|
||||
## Lokal prüfen
|
||||
|
||||
Für die Webtests zusätzlich `httpx` installieren:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pip install -r requirements-dev.txt
|
||||
.venv/bin/python -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
Der Host-Test prüft Demo-Bild und Webendpunkte. Kamera, Nachtbild, WLAN,
|
||||
SSH-Tunnel und GPIOs müssen am echten Pi bzw. Aufstellort geprüft werden.
|
||||
|
||||
Technische Grundlagen: [Picamera2-Handbuch](https://datasheets.raspberrypi.com/camera/picamera2-manual.pdf),
|
||||
[FastAPI-StreamingResponse](https://fastapi.tiangolo.com/advanced/stream-data/).
|
||||
@@ -0,0 +1,66 @@
|
||||
# Projektdateien auf einen Raspberry Pi kopieren
|
||||
|
||||
Das Skript `tools/upload_to_pi.sh` kopiert den gewählten lokalen Ordner per
|
||||
SSH und `rsync` auf **einen** Pi. Ohne `--source` ist das Projektverzeichnis
|
||||
die Quelle. Es löscht keine Dateien auf dem Pi. Die Pi-Adresse, der Benutzer
|
||||
und der Zielpfad sind noch nicht am Gerät geprüft und werden beim Aufruf
|
||||
angegeben.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- Auf dem Laptop: `ssh` und `rsync`.
|
||||
- Auf dem Pi: aktivierter SSH-Zugang und `rsync`.
|
||||
- Der Benutzer muss im Zielpfad schreiben dürfen.
|
||||
- Für den optionalen Autostart: Pi-Software laut `SOFTWARE.md` vorbereiten,
|
||||
insbesondere `.venv` im Zielordner; `sudo` muss auf dem Pi nutzbar sein.
|
||||
- Vor dem ersten Upload den Pi per `ssh BENUTZER@HOST` erreichen und den
|
||||
angezeigten Hostschlüssel anhand des eigenen Geräts prüfen. Bei einem
|
||||
geänderten Hostschlüssel die Ursache klären, nicht die Prüfung abschalten.
|
||||
|
||||
## Ablauf
|
||||
|
||||
`BENUTZER`, `HOST` und `ZIEL` durch die geprüften Werte ersetzen. `ZIEL`
|
||||
muss ein absoluter Pfad ohne Leerzeichen sein.
|
||||
|
||||
```bash
|
||||
BENUTZER='BENUTZER'
|
||||
HOST='HOST'
|
||||
ZIEL='/home/BENUTZER/halloween'
|
||||
|
||||
bash tools/upload_to_pi.sh --user "$BENUTZER" --host "$HOST" --remote-dir "$ZIEL"
|
||||
bash tools/upload_to_pi.sh --user "$BENUTZER" --host "$HOST" --remote-dir "$ZIEL" --apply
|
||||
```
|
||||
|
||||
Der erste Aufruf zeigt nur die geplanten Befehle. Der zweite legt den
|
||||
Zielordner an und überträgt die Dateien. Ein anderer Quellordner kann mit
|
||||
`--source /absoluter/pfad` angegeben werden. Anschließend auf dem Pi prüfen:
|
||||
|
||||
```bash
|
||||
ssh "$BENUTZER@$HOST" "ls -la '$ZIEL'"
|
||||
```
|
||||
|
||||
Soll die WebUI auf dem Pi nach jedem Neustart automatisch laufen, beim
|
||||
Upload `--install-service` ergänzen:
|
||||
|
||||
```bash
|
||||
bash tools/upload_to_pi.sh --user "$BENUTZER" --host "$HOST" --remote-dir "$ZIEL" --install-service
|
||||
bash tools/upload_to_pi.sh --user "$BENUTZER" --host "$HOST" --remote-dir "$ZIEL" --install-service --apply
|
||||
```
|
||||
|
||||
Der erste Befehl ist wieder nur eine lokale Vorschau. Der zweite überträgt
|
||||
die Dateien, installiert/aktualisiert die systemd-Unit mit `sudo` auf dem Pi
|
||||
und startet den Dienst. Bei späteren Updates startet der Dienst so mit dem
|
||||
neuen Code neu. Er bindet weiterhin nur an `127.0.0.1` des Pi; vom Laptop
|
||||
aus ist der SSH-Tunnel aus `SOFTWARE.md` nötig. Es öffnet sich kein
|
||||
Browserfenster von selbst.
|
||||
|
||||
Das Skript lässt `.git`, `.venv`, `data`, `preview`, `references`,
|
||||
`__pycache__`, `.env` und übliche Schlüsseldateien aus. So bleiben
|
||||
Laufzeiteinstellungen auf dem Pi und die großen Konzeptdateien lokal.
|
||||
Es synchronisiert vorhandene Dateien, löscht aber
|
||||
keine alten Dateien auf dem Pi. Zugangsdaten und andere Geheimnisse gehören
|
||||
nicht in den Quellordner. Für zwei Pis den Upload mit den jeweils geprüften
|
||||
Zielwerten separat ausführen.
|
||||
|
||||
Es wurde bisher keine Verbindung zu einem Pi, keine Übertragung und keine
|
||||
Installation der systemd-Unit am Gerät geprüft.
|
||||
@@ -0,0 +1 @@
|
||||
"""Lokale Steueroberfläche für das Halloween-Grab."""
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Ein Kameraproduzent für mehrere Livebild-Betrachter."""
|
||||
|
||||
from io import BytesIO
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
|
||||
class CameraStream:
|
||||
def __init__(self, demo: bool = False):
|
||||
self.demo = demo
|
||||
self._condition = threading.Condition()
|
||||
self._thread = None
|
||||
self._stop = threading.Event()
|
||||
self._frame = None
|
||||
self._number = 0
|
||||
self._time = None
|
||||
self.error = None
|
||||
|
||||
def start(self):
|
||||
if self._thread is not None:
|
||||
return
|
||||
self._thread = threading.Thread(target=self._run, daemon=True, name="camera")
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
with self._condition:
|
||||
self._condition.notify_all()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=3)
|
||||
|
||||
@staticmethod
|
||||
def _jpeg(image: Image.Image) -> bytes:
|
||||
buffer = BytesIO()
|
||||
image.save(buffer, format="JPEG", quality=75)
|
||||
return buffer.getvalue()
|
||||
|
||||
def _publish(self, frame: bytes):
|
||||
with self._condition:
|
||||
self._frame = frame
|
||||
self._number += 1
|
||||
self._time = time.monotonic()
|
||||
self._condition.notify_all()
|
||||
|
||||
def _run(self):
|
||||
try:
|
||||
if self.demo:
|
||||
self._run_demo()
|
||||
else:
|
||||
self._run_pi()
|
||||
except Exception as exc:
|
||||
with self._condition:
|
||||
self.error = f"Kamera nicht verfügbar: {type(exc).__name__}: {exc}"
|
||||
self._condition.notify_all()
|
||||
|
||||
def _run_demo(self):
|
||||
while not self._stop.is_set():
|
||||
image = Image.new("RGB", (640, 480), (21, 28, 32))
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.rectangle((40, 105, 600, 400), outline=(144, 111, 65), width=8)
|
||||
draw.ellipse((165, 205, 475, 340), fill=(45, 52, 36), outline=(110, 91, 59), width=4)
|
||||
draw.text((42, 30), "DEMO - KEINE ECHTE KAMERA", fill=(255, 172, 72))
|
||||
draw.text((42, 440), datetime.now().strftime("%Y-%m-%d %H:%M:%S"), fill="white")
|
||||
self._publish(self._jpeg(image))
|
||||
self._stop.wait(0.2)
|
||||
|
||||
def _run_pi(self):
|
||||
from picamera2 import Picamera2
|
||||
|
||||
camera = Picamera2()
|
||||
started = False
|
||||
try:
|
||||
# BGR888 liefert laut Picamera2-Dokumentation RGB-Arrays für PIL.
|
||||
config = camera.create_video_configuration(
|
||||
main={"size": (640, 480), "format": "BGR888"}
|
||||
)
|
||||
camera.configure(config)
|
||||
camera.start()
|
||||
started = True
|
||||
while not self._stop.is_set():
|
||||
pixels = camera.capture_array("main")
|
||||
self._publish(self._jpeg(Image.fromarray(pixels, "RGB")))
|
||||
self._stop.wait(0.2)
|
||||
finally:
|
||||
try:
|
||||
if started:
|
||||
camera.stop()
|
||||
finally:
|
||||
camera.close()
|
||||
|
||||
def latest(self):
|
||||
with self._condition:
|
||||
return self._frame
|
||||
|
||||
def latest_numbered(self):
|
||||
with self._condition:
|
||||
return self._number, self._frame
|
||||
|
||||
def wait_next(self, number: int, timeout: float = 10):
|
||||
with self._condition:
|
||||
self._condition.wait_for(
|
||||
lambda: self._number > number or self.error or self._stop.is_set(),
|
||||
timeout=timeout,
|
||||
)
|
||||
return self._number, self._frame
|
||||
|
||||
def status(self):
|
||||
with self._condition:
|
||||
age = None if self._time is None else round(time.monotonic() - self._time, 1)
|
||||
return {
|
||||
"source": "Demo" if self.demo else "Pi-Kamera",
|
||||
"ready": self._frame is not None and age is not None and age < 3,
|
||||
"frame_age_seconds": age,
|
||||
"error": self.error,
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Sichere Zustandslogik für den simulierten Effektablauf."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
MODES = ("aus", "atmosphaere", "sanft", "voll")
|
||||
SEQUENCE_SECONDS = 3.0
|
||||
COOLDOWN_SECONDS = 10.0
|
||||
|
||||
|
||||
class Controller:
|
||||
def __init__(self, state_dir: Path, clock=time.monotonic):
|
||||
self._lock = threading.RLock()
|
||||
self._clock = clock
|
||||
self._path = state_dir / "settings.json"
|
||||
self._path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.mode, self.config_error = self._load_mode()
|
||||
self.stopped = False
|
||||
self.zone_clear_seen = False
|
||||
self.started_at = None
|
||||
self.cooldown_until = 0.0
|
||||
self.last_event = None
|
||||
|
||||
def _load_mode(self):
|
||||
if not self._path.exists():
|
||||
return "voll", None
|
||||
try:
|
||||
mode = json.loads(self._path.read_text(encoding="utf-8"))["mode"]
|
||||
if mode not in MODES:
|
||||
raise ValueError("ungültiger Modus")
|
||||
return mode, None
|
||||
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||
return "aus", f"Einstellungen fehlerhaft: {exc}"
|
||||
|
||||
def _save_mode(self, mode: str):
|
||||
temp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", encoding="utf-8", dir=self._path.parent,
|
||||
prefix="settings-", suffix=".tmp", delete=False,
|
||||
) as stream:
|
||||
temp_path = Path(stream.name)
|
||||
json.dump({"mode": mode}, stream)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temp_path, self._path)
|
||||
finally:
|
||||
if temp_path is not None:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
def set_mode(self, mode: str):
|
||||
if mode not in MODES:
|
||||
raise ValueError("unbekannter Modus")
|
||||
with self._lock:
|
||||
self._save_mode(mode)
|
||||
if mode != self.mode:
|
||||
self.started_at = None
|
||||
self.zone_clear_seen = False
|
||||
self.mode = mode
|
||||
self.config_error = None
|
||||
|
||||
def zone_clear(self):
|
||||
with self._lock:
|
||||
if not self.stopped:
|
||||
self.zone_clear_seen = True
|
||||
|
||||
def trigger(self):
|
||||
"""Nur neuer Trigger nach freiem Bereich; bislang ohne Aktorausgabe."""
|
||||
with self._lock:
|
||||
now = self._clock()
|
||||
if self.stopped:
|
||||
return False, "Stopp aktiv"
|
||||
if self.config_error:
|
||||
return False, "Einstellungen fehlerhaft"
|
||||
if self.mode not in ("sanft", "voll"):
|
||||
return False, "Modus löst keinen Schreck aus"
|
||||
if not self.zone_clear_seen:
|
||||
return False, "Auslösebereich zuerst freigeben"
|
||||
if self.started_at is not None and now - self.started_at < SEQUENCE_SECONDS:
|
||||
return False, "Sequenz läuft"
|
||||
if now < self.cooldown_until:
|
||||
return False, "Sperrzeit läuft"
|
||||
self.started_at = now
|
||||
self.cooldown_until = now + COOLDOWN_SECONDS
|
||||
self.zone_clear_seen = False
|
||||
self.last_event = {"mode": self.mode, "time": time.strftime("%Y-%m-%d %H:%M:%S")}
|
||||
return True, "Simulation gestartet"
|
||||
|
||||
def stop(self):
|
||||
with self._lock:
|
||||
self.stopped = True
|
||||
self.started_at = None
|
||||
self.zone_clear_seen = False
|
||||
|
||||
def reset(self):
|
||||
with self._lock:
|
||||
self.stopped = False
|
||||
self.started_at = None
|
||||
self.zone_clear_seen = False
|
||||
|
||||
def status(self):
|
||||
with self._lock:
|
||||
now = self._clock()
|
||||
elapsed = None if self.started_at is None else now - self.started_at
|
||||
if self.stopped:
|
||||
phase = "Stopp"
|
||||
elif elapsed is not None and elapsed < 0.8:
|
||||
phase = "Stille"
|
||||
elif elapsed is not None and elapsed < 1.7:
|
||||
phase = "Kratzen (simuliert)"
|
||||
elif elapsed is not None and elapsed < 2.4:
|
||||
phase = "Schreck (simuliert)" if self.mode == "voll" else "Sanft (simuliert)"
|
||||
elif elapsed is not None and elapsed < SEQUENCE_SECONDS:
|
||||
phase = "Rückkehr (simuliert)"
|
||||
elif now < self.cooldown_until:
|
||||
phase = "Sperrzeit"
|
||||
else:
|
||||
phase = "Bereit" if self.zone_clear_seen else "Bereich frei abwarten"
|
||||
return {
|
||||
"mode": self.mode,
|
||||
"phase": phase,
|
||||
"stopped": self.stopped,
|
||||
"zone_clear_seen": self.zone_clear_seen,
|
||||
"cooldown_seconds": round(max(0, self.cooldown_until - now), 1),
|
||||
"last_event": self.last_event,
|
||||
"config_error": self.config_error,
|
||||
"actuators": "nur Simulation",
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Halloween-Grab · Steuerung</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #111718; color: #eef0e9; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
header { padding: 1.1rem max(1rem, calc((100vw - 1120px)/2)); background: #182122; border-bottom: 1px solid #3a4644; }
|
||||
h1 { margin: 0; font-size: 1.5rem; }
|
||||
header p { margin: .35rem 0 0; color: #c1cbc2; }
|
||||
main { max-width: 1120px; margin: auto; padding: 1rem; display: grid; gap: 1rem; grid-template-columns: minmax(0, 2fr) minmax(290px, 1fr); }
|
||||
section { background: #1a2424; border: 1px solid #394846; border-radius: 12px; padding: 1rem; }
|
||||
h2 { margin: 0 0 .7rem; font-size: 1.1rem; }
|
||||
.camera { width: 100%; aspect-ratio: 4/3; object-fit: contain; background: #050808; border-radius: 8px; }
|
||||
.row { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
|
||||
button, select, a.download { border: 1px solid #5c6c67; border-radius: 7px; background: #293938; color: #f4f5ed; font: inherit; padding: .65rem .8rem; cursor: pointer; }
|
||||
button:hover, a.download:hover { background: #36504a; }
|
||||
button:focus-visible, select:focus-visible, a:focus-visible { outline: 3px solid #ffb44f; outline-offset: 2px; }
|
||||
button.danger { background: #782c26; border-color: #c76652; font-weight: 700; }
|
||||
button.danger:hover { background: #9c352c; }
|
||||
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||
a.download { display: inline-block; text-decoration: none; }
|
||||
.value { font-weight: 650; color: #ffca78; }
|
||||
.notice { border-left: 4px solid #ffb44f; padding: .55rem .8rem; background: #302b21; margin: .8rem 0; }
|
||||
.muted { color: #bfcbc6; }
|
||||
#message { min-height: 1.5rem; color: #ffca78; }
|
||||
dl { display: grid; grid-template-columns: auto 1fr; gap: .4rem .8rem; margin: .5rem 0 1rem; }
|
||||
dt { color: #adbfba; } dd { margin: 0; }
|
||||
@media (max-width: 760px) { main { grid-template-columns: 1fr; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>Halloween-Grab</h1>
|
||||
<p>Lokale Kameraansicht und Steuerung</p>
|
||||
</header>
|
||||
<main>
|
||||
<section>
|
||||
<h2>Livebild</h2>
|
||||
<img class="camera" src="/stream.mjpg" alt="Livebild der Kamera">
|
||||
<p class="muted" id="camera-status">Kamera wird gestartet …</p>
|
||||
<a class="download" href="/snapshot.jpg" download="halloween-snapshot.jpg">Aktuelles Bild herunterladen</a>
|
||||
<p class="notice">Aufnahmen und Personenerkennung sind noch nicht aktiviert. Der Aufnahmebereich muss vor dem Außeneinsatz festgelegt werden.</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Betrieb</h2>
|
||||
<dl>
|
||||
<dt>Modus</dt><dd class="value" id="mode-label">Lädt …</dd>
|
||||
<dt>Zustand</dt><dd id="phase">Lädt …</dd>
|
||||
<dt>Sperrzeit</dt><dd id="cooldown">–</dd>
|
||||
<dt>Letzter Test</dt><dd id="last-event">–</dd>
|
||||
</dl>
|
||||
<label for="mode">Modus wählen</label>
|
||||
<div class="row" style="margin: .5rem 0 1rem">
|
||||
<select id="mode">
|
||||
<option value="voll">Voller Schreck</option>
|
||||
<option value="sanft">Sanft</option>
|
||||
<option value="atmosphaere">Atmosphäre</option>
|
||||
<option value="aus">Aus</option>
|
||||
</select>
|
||||
<button id="save-mode">Übernehmen</button>
|
||||
</div>
|
||||
<p class="notice"><strong>Aktoren nur simuliert.</strong> Kein Motor, Licht oder Ton ist mit dieser Version verbunden. Es gibt noch keine automatische Personenauslösung.</p>
|
||||
<div class="row">
|
||||
<button id="zone-clear">Bereich frei (Test)</button>
|
||||
<button id="test-trigger">Testauslösung</button>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 1rem">
|
||||
<button class="danger" id="stop">Stopp</button>
|
||||
<button id="reset">Nach Stopp zurücksetzen</button>
|
||||
</div>
|
||||
<p id="message" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const names = { voll: 'Voller Schreck', sanft: 'Sanft', atmosphaere: 'Atmosphäre', aus: 'Aus' };
|
||||
async function api(path, body = {}) {
|
||||
const response = await fetch(path, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Halloween-UI': '1' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.detail || 'Anfrage fehlgeschlagen');
|
||||
return data;
|
||||
}
|
||||
async function refresh() {
|
||||
try {
|
||||
const response = await fetch('/api/status', { cache: 'no-store' });
|
||||
const data = await response.json();
|
||||
const c = data.controller, cam = data.camera;
|
||||
$('mode-label').textContent = names[c.mode] || c.mode;
|
||||
if (document.activeElement !== $('mode')) $('mode').value = c.mode;
|
||||
$('phase').textContent = c.phase;
|
||||
$('cooldown').textContent = c.cooldown_seconds ? c.cooldown_seconds + ' s' : '–';
|
||||
$('last-event').textContent = c.last_event ? c.last_event.time + ' · ' + names[c.last_event.mode] : '–';
|
||||
$('camera-status').textContent = cam.error || (cam.ready ? cam.source + ' · Bild aktiv' : cam.source + ' · wartet auf Bild');
|
||||
if (c.config_error) $('message').textContent = c.config_error;
|
||||
} catch (error) {
|
||||
$('camera-status').textContent = 'Server nicht erreichbar';
|
||||
$('message').textContent = error.message;
|
||||
}
|
||||
}
|
||||
async function action(path, body) {
|
||||
try {
|
||||
const data = await api(path, body);
|
||||
$('message').textContent = data.reason || 'Einstellung übernommen';
|
||||
await refresh();
|
||||
} catch (error) { $('message').textContent = error.message; }
|
||||
}
|
||||
$('save-mode').onclick = () => action('/api/mode', { mode: $('mode').value });
|
||||
$('zone-clear').onclick = () => action('/api/zone-clear');
|
||||
$('test-trigger').onclick = () => action('/api/test-trigger');
|
||||
$('stop').onclick = () => action('/api/stop');
|
||||
$('reset').onclick = () => action('/api/reset');
|
||||
refresh();
|
||||
setInterval(refresh, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Lokale WebUI und MJPEG-Livebild; alle Aktoren bleiben simuliert."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .camera import CameraStream
|
||||
from .controller import Controller, MODES
|
||||
|
||||
|
||||
PAGE = (Path(__file__).parent / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class ModeInput(BaseModel):
|
||||
mode: str
|
||||
|
||||
|
||||
async def require_ui(request: Request):
|
||||
if request.headers.get("X-Halloween-UI") != "1":
|
||||
raise HTTPException(status_code=403, detail="UI-Anfrage erforderlich")
|
||||
|
||||
|
||||
def create_app(demo: bool = False, state_dir: Path | None = None) -> FastAPI:
|
||||
directory = state_dir or Path(os.environ.get("HALLOWEEN_STATE_DIR", "data"))
|
||||
camera = CameraStream(demo=demo)
|
||||
controller = Controller(directory)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
camera.start()
|
||||
yield
|
||||
camera.stop()
|
||||
|
||||
app = FastAPI(title="Halloween-Grab", lifespan=lifespan)
|
||||
app.state.camera = camera
|
||||
app.state.controller = controller
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
return response
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def index():
|
||||
return PAGE
|
||||
|
||||
@app.get("/api/status")
|
||||
async def status():
|
||||
return {"controller": controller.status(), "camera": camera.status()}
|
||||
|
||||
@app.post("/api/mode", dependencies=[Depends(require_ui)])
|
||||
async def set_mode(payload: ModeInput):
|
||||
if payload.mode not in MODES:
|
||||
raise HTTPException(status_code=422, detail="Unbekannter Modus")
|
||||
try:
|
||||
controller.set_mode(payload.mode)
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Einstellung nicht gespeichert: {exc}") from exc
|
||||
return controller.status()
|
||||
|
||||
@app.post("/api/zone-clear", dependencies=[Depends(require_ui)])
|
||||
async def zone_clear():
|
||||
controller.zone_clear()
|
||||
return controller.status()
|
||||
|
||||
@app.post("/api/test-trigger", dependencies=[Depends(require_ui)])
|
||||
async def test_trigger():
|
||||
accepted, reason = controller.trigger()
|
||||
return {"accepted": accepted, "reason": reason, "controller": controller.status()}
|
||||
|
||||
@app.post("/api/stop", dependencies=[Depends(require_ui)])
|
||||
async def stop():
|
||||
controller.stop()
|
||||
return controller.status()
|
||||
|
||||
@app.post("/api/reset", dependencies=[Depends(require_ui)])
|
||||
async def reset():
|
||||
controller.reset()
|
||||
return controller.status()
|
||||
|
||||
@app.get("/snapshot.jpg")
|
||||
async def snapshot():
|
||||
frame = camera.latest()
|
||||
if frame is None:
|
||||
raise HTTPException(status_code=503, detail="Noch kein Kamerabild")
|
||||
return Response(
|
||||
frame,
|
||||
media_type="image/jpeg",
|
||||
headers={"Content-Disposition": 'attachment; filename="halloween-snapshot.jpg"'},
|
||||
)
|
||||
|
||||
@app.get("/stream.mjpg")
|
||||
async def stream():
|
||||
if camera.error:
|
||||
raise HTTPException(status_code=503, detail=camera.error)
|
||||
|
||||
async def frames():
|
||||
last_number = 0
|
||||
while True:
|
||||
number, frame = camera.latest_numbered()
|
||||
if camera.error:
|
||||
return
|
||||
if frame is None or number == last_number:
|
||||
await asyncio.sleep(0.1)
|
||||
continue
|
||||
last_number = number
|
||||
yield (
|
||||
b"--frame\r\nContent-Type: image/jpeg\r\nContent-Length: "
|
||||
+ str(len(frame)).encode("ascii")
|
||||
+ b"\r\n\r\n" + frame + b"\r\n"
|
||||
)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
return StreamingResponse(
|
||||
frames(), media_type="multipart/x-mixed-replace; boundary=frame"
|
||||
)
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,44 @@
|
||||
# Halloween – animierte Konzeptvorschau
|
||||
|
||||
Die Vorschau zeigt einen beispielhaften Vorgarten, das atmende Grab,
|
||||
einen sanften Familienbesuch und einen späteren Schreckeffekt für Erwachsene.
|
||||
Die Besucher sind vereinfachte, fiktive 3D-Figuren in Kapuzenkostümen;
|
||||
Materialien und Beleuchtung orientieren sich an einer realen Nachtszene.
|
||||
Es handelt sich nicht um fotorealistische Menschen oder eine Simulation
|
||||
zuverlässiger Kindererkennung. Alle Reaktionen sind voranimiert.
|
||||
|
||||
## Dateien
|
||||
|
||||
- `halloween_preview.mp4`: 20 Sekunden, 1280 × 720, 24 fps, mit Ton.
|
||||
- `halloween_preview.blend`: bearbeitbare Szene einschließlich Animation
|
||||
und gepackter, selbst erzeugter Tonspur.
|
||||
- `preview_080.png`, `preview_185.png`, `preview_320.png`: drei Vorschaubilder.
|
||||
- `effects.wav`: selbst erzeugte Atem-, Kratz- und Knurrgeräusche.
|
||||
|
||||
## Bearbeiten und neu rendern
|
||||
|
||||
Die Blender-Datei in Blender 4.0.2 oder einer kompatiblen Version öffnen.
|
||||
Die Zeitleiste umfasst Frames 1–480. Atembewegung liegt im Shape Key
|
||||
`Breath` der Grabfläche; Licht, Hand, Kamera und Besucher sind animiert.
|
||||
Die Tonspur liegt im Video Sequencer. Das Skript `tools/render_preview.py`
|
||||
rekonstruiert die Szene; Änderungen direkt in der Blender-Datei werden
|
||||
beim erneuten Ausführen dieses Skripts nicht übernommen.
|
||||
|
||||
Reproduzieren aus dem Projektverzeichnis:
|
||||
|
||||
```sh
|
||||
blender -b -t 12 --python tools/render_preview.py -- previews
|
||||
blender -b -t 12 --python tools/render_preview.py -- video
|
||||
```
|
||||
|
||||
Die Szene verwendet EEVEE. Maße, Garten und Bewegungen sind gestalterische
|
||||
Annahmen; die Datei ist kein mechanischer Bauplan.
|
||||
|
||||
## Abschlussprüfung
|
||||
|
||||
MP4-Metadaten bestätigen 480 Videobilder, exakt 20 Sekunden, 1280 × 720
|
||||
Pixel und 24 fps. H.264-Videospur und AAC-Tonspur sind enthalten. Blender
|
||||
hat das fertige Video erneut eingelesen; zwölf dekodierte Bilder wurden
|
||||
visuell geprüft (auch Schnitt, Lichtimpuls, Hand und Schlussansicht).
|
||||
`review_contact.png` zeigt die geprüften Stellen. Eine Prüfung des Tons
|
||||
über Lautsprecher wurde nicht durchgeführt.
|
||||
Binary file not shown.
@@ -0,0 +1,24 @@
|
||||
# Beispielbilder – zweite Fassung
|
||||
|
||||
Nur drei Standbilder gerendert; das bisherige Video ist unverändert.
|
||||
|
||||
- `example_140.png`: Familie kommt mit animierten Schritten näher.
|
||||
- `example_200.png`: Familie vor dem laubbedeckten Grab.
|
||||
- `example_320.png`: inszenierter Schreck mit Kindern, Licht und Hand.
|
||||
- `halloween_examples.blend`: bearbeitbare Szene mit Laufbewegungen.
|
||||
|
||||
Die Laubschicht besitzt eine zur Grabfläche synchronisierte Atemanimation.
|
||||
Die Figuren besitzen bewegte Oberschenkel, Knie, Schuhe und Ärmel sowie
|
||||
animiertes Zurückweichen. Dunklere Beleuchtung, rotes Grablicht, kahle
|
||||
Bäume und leichter Bodennebel ergänzen die Atmosphäre.
|
||||
|
||||
Die Figuren bleiben vereinfachte Kostümmodelle. Die Laufbewegung ist in der
|
||||
Szene angelegt; ihre Wirkung in einem vollständigen Video ist noch nicht
|
||||
geprüft. Die fiktive Schreckszene mit Kindern ist eine gestalterische
|
||||
Vorschau und ändert nicht die Betriebsregeln für die echte Installation.
|
||||
|
||||
Neu erzeugen aus dem Projektverzeichnis:
|
||||
|
||||
```sh
|
||||
blender -b -t 12 --python tools/render_examples_v2.py
|
||||
```
|
||||
Binary file not shown.
@@ -0,0 +1,15 @@
|
||||
# Mittige Hand und aufwirbelndes Laub
|
||||
|
||||
Nur Beispielbilder, kein neuer Videorender:
|
||||
|
||||
- `example_310.png`: unmittelbar vor der Auslösung.
|
||||
- `example_322.png`: mittige Hand kommt hoch, Blätter fliegen auf.
|
||||
- `example_334.png`: Blätter verteilen sich und sinken zurück.
|
||||
- `halloween_examples.blend`: bearbeitbare Szene mit Animation.
|
||||
|
||||
110 einzeln animierte Blätter verdecken zunächst die mittige Öffnung.
|
||||
Beim Hochkommen der Hand werden sie gestaffelt nach oben und außen bewegt,
|
||||
rotieren und fallen wieder herunter. Dies ist eine gestaltete Animation,
|
||||
keine physikalische Simulation einer echten Mechanik.
|
||||
|
||||
Reproduzieren: `blender -b -t 12 --python tools/render_examples_v3.py`.
|
||||
Binary file not shown.
@@ -0,0 +1,43 @@
|
||||
# Dunklere Beleuchtung – Beispielbilder
|
||||
|
||||
Drei Standbilder und ein vollständiges Video der dunklen Fassung.
|
||||
|
||||
- `example_310.png`: dunkle Ruhephase.
|
||||
- `example_322.png`: gezielter Lichtimpuls auf Hand und Laub.
|
||||
- `example_334.png`: abklingender Impuls, fallende Blätter.
|
||||
- `halloween_examples.blend`: bearbeitbare Szene mit bestehenden Bewegungen.
|
||||
|
||||
Beleuchtung: dunkler Nachthimmel, schwaches kühles Mondlicht, warme
|
||||
Lichtquellen an Fenstern und sichtbarer Hauslaterne, dezentes blaues und
|
||||
rotes Grablicht. Die Laterne flackert leicht. Ein gerichteter Strahler
|
||||
beleuchtet beim Schreck vor allem das Grab. Kontaktschatten, dezente
|
||||
Lichtstreuung im Nebel und Reflexionen auf dem Weg ergänzen die Wirkung.
|
||||
|
||||
Rendering in EEVEE, 1280 × 720. Die Figuren bleiben vereinfachte
|
||||
Kostümmodelle; dies ist keine fotorealistische Personenaufnahme.
|
||||
|
||||
Reproduzieren: `blender -b -t 12 --python tools/render_examples_v4.py`.
|
||||
|
||||
## Video
|
||||
|
||||
- `halloween_dark.mp4`: 20 Sekunden, 1280 × 720, 24 fps, H.264 und AAC-Ton.
|
||||
- `halloween_dark.blend`: bearbeitbare Szene mit Videoexport-Einstellungen.
|
||||
- `review_contact.png`: zwölf aus dem fertigen Video dekodierte Prüfbilder.
|
||||
|
||||
Alle vier Besucher besitzen animierte Oberschenkel, Knie und Ärmel.
|
||||
Die Bewegungen sind einfach gehalten und durch Kostüme und Dunkelheit
|
||||
teilweise verdeckt. Bewegte Gelenke wurden an zwei Zeitpunkten geprüft;
|
||||
Gehsequenz, Laubwirbel, Hand und Schlussbild anhand exportierter Bilder
|
||||
kontrolliert. Die Tonspur ist enthalten, wurde nicht über Lautsprecher geprüft.
|
||||
|
||||
Videorender: `blender -b -t 12 --python tools/render_video_v4.py`.
|
||||
Mit Grafikkartenzugriff rendern; ohne ihn ist diese Szene deutlich langsamer.
|
||||
|
||||
## Stöhnen beim Handimpuls
|
||||
|
||||
`halloween_dark_groan.mp4` ergänzt ein tiefes, selbst synthetisiertes
|
||||
Stöhnen ab 13,04 Sekunden mit kurzem Nachhall. Die Bilder wurden aus dem
|
||||
bestehenden Video übernommen und erneut codiert; kein neuer 3D-Render.
|
||||
`halloween_dark_groan.blend` enthält die neue gepackte Tonspur,
|
||||
`effects_with_groan.wav` die Mischung als separate Audiodatei.
|
||||
Die Mischung bleibt ohne digitale Übersteuerung.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
-r requirements.txt
|
||||
httpx>=0.27,<1
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi>=0.115,<1
|
||||
uvicorn>=0.30,<1
|
||||
Pillow>=10,<13
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Startet die lokale Halloween-WebUI auf dem Pi oder im Demo-Modus."""
|
||||
|
||||
import argparse
|
||||
|
||||
import uvicorn
|
||||
|
||||
from halloween.web import create_app
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--demo", action="store_true", help="synthetisches Bild statt Pi-Kamera")
|
||||
parser.add_argument("--port", type=int, default=8000)
|
||||
args = parser.parse_args()
|
||||
uvicorn.run(create_app(demo=args.demo), host="127.0.0.1", port=args.port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,58 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from halloween.controller import Controller
|
||||
|
||||
|
||||
class ControllerTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.folder.cleanup)
|
||||
self.now = 100.0
|
||||
self.path = Path(self.folder.name)
|
||||
self.controller = Controller(self.path, clock=lambda: self.now)
|
||||
|
||||
def test_start_needs_clear_zone_and_fresh_trigger(self):
|
||||
self.assertEqual(self.controller.status()["mode"], "voll")
|
||||
self.assertFalse(self.controller.trigger()[0])
|
||||
self.controller.zone_clear()
|
||||
self.assertTrue(self.controller.trigger()[0])
|
||||
self.assertFalse(self.controller.trigger()[0])
|
||||
self.now += 11
|
||||
self.assertFalse(self.controller.trigger()[0])
|
||||
self.controller.zone_clear()
|
||||
self.assertTrue(self.controller.trigger()[0])
|
||||
|
||||
def test_stop_latches_until_reset_and_new_clear_zone(self):
|
||||
self.controller.zone_clear()
|
||||
self.controller.stop()
|
||||
self.assertFalse(self.controller.trigger()[0])
|
||||
self.controller.reset()
|
||||
self.assertFalse(self.controller.trigger()[0])
|
||||
self.controller.zone_clear()
|
||||
self.assertTrue(self.controller.trigger()[0])
|
||||
|
||||
def test_mode_persists_and_corrupt_file_fails_closed(self):
|
||||
self.controller.set_mode("sanft")
|
||||
self.assertEqual(Controller(self.path).mode, "sanft")
|
||||
(self.path / "settings.json").write_text("{kaputt", encoding="utf-8")
|
||||
broken = Controller(self.path)
|
||||
self.assertEqual(broken.mode, "aus")
|
||||
self.assertIsNotNone(broken.config_error)
|
||||
broken.zone_clear()
|
||||
self.assertFalse(broken.trigger()[0])
|
||||
broken.set_mode("voll")
|
||||
self.assertEqual(json.loads((self.path / "settings.json").read_text())["mode"], "voll")
|
||||
|
||||
def test_mode_change_cancels_running_sequence(self):
|
||||
self.controller.zone_clear()
|
||||
self.assertTrue(self.controller.trigger()[0])
|
||||
self.controller.set_mode("aus")
|
||||
self.assertFalse(self.controller.trigger()[0])
|
||||
self.assertFalse(self.controller.status()["zone_clear_seen"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,54 @@
|
||||
import asyncio
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from halloween.web import create_app
|
||||
|
||||
|
||||
class WebTest(unittest.TestCase):
|
||||
def test_demo_live_image_and_controls(self):
|
||||
async def scenario():
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
app = create_app(demo=True, state_dir=Path(folder))
|
||||
app.state.camera.start()
|
||||
try:
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://local") as client:
|
||||
page = await client.get("/")
|
||||
self.assertEqual(page.status_code, 200)
|
||||
self.assertIn("/stream.mjpg", page.text)
|
||||
self.assertIn("Aktoren nur simuliert", page.text)
|
||||
for _ in range(30):
|
||||
if (await client.get("/api/status")).json()["camera"]["ready"]:
|
||||
break
|
||||
await asyncio.sleep(0.05)
|
||||
image = await client.get("/snapshot.jpg")
|
||||
self.assertEqual(image.status_code, 200)
|
||||
self.assertTrue(image.content.startswith(b"\xff\xd8"))
|
||||
self.assertEqual((await client.post("/api/stop")).status_code, 403)
|
||||
headers = {"X-Halloween-UI": "1"}
|
||||
self.assertEqual((await client.post("/api/stop", headers=headers)).status_code, 200)
|
||||
self.assertTrue((await client.get("/api/status")).json()["controller"]["stopped"])
|
||||
self.assertEqual((await client.post("/api/reset", headers=headers)).status_code, 200)
|
||||
result = await client.post(
|
||||
"/api/mode", headers=headers, json={"mode": "sanft"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 200)
|
||||
self.assertEqual((await client.get("/api/status")).json()["controller"]["mode"], "sanft")
|
||||
|
||||
route = next(route for route in app.routes if route.path == "/stream.mjpg")
|
||||
response = await route.endpoint()
|
||||
first = await response.body_iterator.__anext__()
|
||||
self.assertTrue(first.startswith(b"--frame\r\nContent-Type: image/jpeg"))
|
||||
await response.body_iterator.aclose()
|
||||
finally:
|
||||
app.state.camera.stop()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Add a low synthetic vocal moan at the hand emergence and export via VSE."""
|
||||
import os, math, wave, array, random
|
||||
ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUT=os.path.join(ROOT,'preview','v4');random.seed(49)
|
||||
with wave.open(os.path.join(ROOT,'preview','effects.wav'),'rb') as w:
|
||||
rate=w.getframerate();samples=array.array('h',w.readframes(w.getnframes()))
|
||||
moan=[0.0]*len(samples);phase=0;filtered=0;start=13.04;length=3.15
|
||||
for i in range(int(length*rate)):
|
||||
t=i/rate;pitch=92-25*(t/length)+2.2*math.sin(t*6.5)
|
||||
phase+=math.tau*pitch/rate
|
||||
# Harmonic vowel with resonances, descending pitch, and breath roughness.
|
||||
value=0
|
||||
for h in range(1,15):
|
||||
freq=h*pitch;weight=.35*math.exp(-((freq-330)/160)**2)+.16*math.exp(-((freq-720)/210)**2)
|
||||
value+=weight*math.sin(h*phase)/math.sqrt(h)
|
||||
value+=.17*math.sin(phase)+.06*math.sin(phase*.5)
|
||||
filtered=.92*filtered+.08*random.uniform(-1,1)
|
||||
envelope=min(1,t/.16)*min(1,(length-t)/.65)*(.7+.3*math.sin(math.pi*t/length))
|
||||
idx=int(start*rate)+i
|
||||
if idx<len(moan):moan[idx]=envelope*(value*.72+filtered*.22)*(1+.08*math.sin(t*31))
|
||||
# Short, subdued echoes evoke a hollow cavity under the grave.
|
||||
output=[]
|
||||
for i,old in enumerate(samples):
|
||||
value=moan[i]
|
||||
for delay,gain in [(.19,.22),(.37,.12)]:
|
||||
index=i-int(delay*rate)
|
||||
if index>=0:value+=gain*moan[index]
|
||||
mixed=old/32768+value
|
||||
output.append(int(max(-.98,min(.98,mixed))*32767))
|
||||
assert max(abs(x) for x in output)<32767
|
||||
path=os.path.join(OUT,'effects_with_groan.wav')
|
||||
with wave.open(path,'wb') as w:w.setparams((1,2,rate,len(output),'NONE','not compressed'));w.writeframes(array.array('h',output).tobytes())
|
||||
print('GROAN: 13.04 to 16.19 seconds; peak',max(abs(x) for x in output)/32768)
|
||||
# When invoked with Blender, update a separate editable copy and export video.
|
||||
try:import bpy
|
||||
except ImportError:raise SystemExit(0)
|
||||
bpy.ops.wm.open_mainfile(filepath=os.path.join(OUT,'halloween_dark.blend'))
|
||||
s=bpy.context.scene
|
||||
for strip in list(s.sequence_editor.sequences):
|
||||
if strip.type=='SOUND':s.sequence_editor.sequences.remove(strip)
|
||||
s.sequence_editor.sequences.new_sound('Dark moan and ambience',path,channel=1,frame_start=1)
|
||||
bpy.ops.file.pack_all();bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT,'halloween_dark_groan.blend'))
|
||||
# Reuse the already rendered movie; do not render the 3D scene again.
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True);s=bpy.context.scene
|
||||
s.render.resolution_x=1280;s.render.resolution_y=720;s.render.resolution_percentage=100;s.render.fps=24;s.frame_start=1;s.frame_end=480
|
||||
s.view_settings.view_transform='Standard';s.view_settings.look='Medium High Contrast' if False else 'None'
|
||||
s.sequence_editor_create();s.sequence_editor.sequences.new_movie('Existing rendered picture',os.path.join(OUT,'halloween_dark.mp4'),channel=1,frame_start=1)
|
||||
s.sequence_editor.sequences.new_sound('New mixed sound',path,channel=2,frame_start=1)
|
||||
s.render.image_settings.file_format='FFMPEG';s.render.ffmpeg.format='MPEG4';s.render.ffmpeg.codec='H264';s.render.ffmpeg.constant_rate_factor='HIGH';s.render.ffmpeg.audio_codec='AAC';s.render.ffmpeg.audio_bitrate=192
|
||||
s.render.filepath=os.path.join(OUT,'halloween_dark_groan.mp4');bpy.ops.render.render(animation=True)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Inspect MP4 track metadata without external tools."""
|
||||
import json, struct, sys
|
||||
from pathlib import Path
|
||||
|
||||
def boxes(data):
|
||||
offset=0
|
||||
while offset+8<=len(data):
|
||||
size,kind=struct.unpack_from('>I4s',data,offset);header=8
|
||||
if size==1:size=struct.unpack_from('>Q',data,offset+8)[0];header=16
|
||||
if size==0:size=len(data)-offset
|
||||
if size<header or offset+size>len(data):raise ValueError('Invalid MP4 box')
|
||||
yield kind.decode('ascii'), data[offset+header:offset+size]
|
||||
offset+=size
|
||||
|
||||
def child(data,name):return next(body for kind,body in boxes(data) if kind==name)
|
||||
|
||||
def inspect(path):
|
||||
moov=child(Path(path).read_bytes(),'moov');result=[]
|
||||
for kind,trak in boxes(moov):
|
||||
if kind!='trak':continue
|
||||
mdia=child(trak,'mdia');hdlr=child(mdia,'hdlr');kind=hdlr[8:12].decode()
|
||||
mdhd=child(mdia,'mdhd')
|
||||
if mdhd[0]==0:scale,duration=struct.unpack_from('>II',mdhd,12)
|
||||
else:scale=struct.unpack_from('>I',mdhd,20)[0];duration=struct.unpack_from('>Q',mdhd,24)[0]
|
||||
stbl=child(child(mdia,'minf'),'stbl');stsz=child(stbl,'stsz');count=struct.unpack_from('>I',stsz,8)[0]
|
||||
desc=child(stbl,'stsd');entry=next(boxes(desc[8:]));seconds=duration/scale
|
||||
item={'type':kind,'codec':entry[0],'duration_seconds':seconds,'samples':count}
|
||||
if kind=='vide':
|
||||
width,height=struct.unpack_from('>HH',entry[1],24);item.update(width=width,height=height,fps=count/seconds)
|
||||
assert (width,height,count)==(1280,720,480),item
|
||||
assert abs(seconds-20)<.01,item
|
||||
result.append(item)
|
||||
assert any(t['type']=='soun' for t in result),'Missing audio track'
|
||||
return result
|
||||
if __name__=='__main__':print(json.dumps(inspect(sys.argv[1]),indent=2))
|
||||
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
|
||||
service_name=halloween-grab.service
|
||||
unit_path="/etc/systemd/system/$service_name"
|
||||
apply=false
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Richtet die Halloween-WebUI als systemd-Dienst auf dem Raspberry Pi ein.
|
||||
|
||||
Aufruf: bash tools/install_pi_service.sh [--apply]
|
||||
|
||||
Ohne --apply wird nur die geplante Unit gezeigt. --apply installiert sie mit
|
||||
sudo, aktiviert den Autostart und startet die WebUI sofort neu.
|
||||
EOF
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'Fehler: %s\n' "$*" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while (($#)); do
|
||||
case $1 in
|
||||
--apply) apply=true; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) die "Unbekanntes Argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
service_user=$(id -un)
|
||||
[[ $service_user =~ ^[a-zA-Z_][a-zA-Z0-9_-]*$ ]] || die 'Ungeeigneter Benutzername für systemd'
|
||||
[[ $service_user != root ]] || die 'Skript als normaler Pi-Benutzer ausführen, nicht als root'
|
||||
[[ $project_dir =~ ^/[a-zA-Z0-9_./-]+$ ]] || die 'Projektpfad enthält für die Unit ungeeignete Zeichen'
|
||||
[[ /$project_dir/ != *'/../'* && /$project_dir/ != *'/./'* ]] || die 'Projektpfad enthält . oder .. Segmente'
|
||||
|
||||
unit_contents=$(cat <<EOF
|
||||
# Managed by Halloween project: tools/install_pi_service.sh
|
||||
[Unit]
|
||||
Description=Halloween-Grab WebUI und Kamera
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=$service_user
|
||||
WorkingDirectory=$project_dir
|
||||
ExecStart=$project_dir/.venv/bin/python $project_dir/run.py
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
)
|
||||
|
||||
printf 'Dienst: %s\nBenutzer: %s\nProjekt: %s\n' "$service_name" "$service_user" "$project_dir"
|
||||
if ! $apply; then
|
||||
printf '\nVorschau für %s:\n%s\n' "$unit_path" "$unit_contents"
|
||||
printf '\nMit --apply installieren, aktivieren und starten.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
[[ -f "$project_dir/run.py" ]] || die 'run.py fehlt im Projektordner'
|
||||
[[ -x "$project_dir/.venv/bin/python" ]] || die '.venv fehlt; zuerst SOFTWARE.md: Pi vorbereiten ausführen'
|
||||
"$project_dir/.venv/bin/python" -c 'import fastapi, uvicorn, PIL' \
|
||||
|| die 'Python-Abhängigkeiten fehlen; zuerst requirements.txt installieren'
|
||||
command -v sudo >/dev/null || die 'sudo fehlt'
|
||||
command -v systemctl >/dev/null || die 'systemctl fehlt'
|
||||
|
||||
if sudo test -e "$unit_path" &&
|
||||
! sudo grep -qF '# Managed by Halloween project: tools/install_pi_service.sh' "$unit_path"; then
|
||||
die "Vorhandene fremde Unit wird nicht überschrieben: $unit_path"
|
||||
fi
|
||||
|
||||
temp_unit=$(mktemp)
|
||||
trap 'rm -f -- "$temp_unit"' EXIT
|
||||
printf '%s\n' "$unit_contents" > "$temp_unit"
|
||||
sudo install -m 0644 -- "$temp_unit" "$unit_path"
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable "$service_name"
|
||||
sudo systemctl restart "$service_name"
|
||||
if ! systemctl is-active --quiet "$service_name"; then
|
||||
die "Dienst läuft nicht; Diagnose: sudo journalctl -u $service_name -n 80 --no-pager"
|
||||
fi
|
||||
printf 'Autostart aktiviert und Dienst gestartet. Status: systemctl status %s\n' "$service_name"
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Improve the existing concept scene; render still examples only."""
|
||||
import bpy, math, random, os
|
||||
from mathutils import Vector
|
||||
ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUT=os.path.join(ROOT,'preview','v2');os.makedirs(OUT,exist_ok=True)
|
||||
bpy.ops.wm.open_mainfile(filepath=os.path.join(ROOT,'preview','halloween_preview.blend'))
|
||||
s=bpy.context.scene;s.eevee.taa_render_samples=32
|
||||
random.seed(104)
|
||||
def material(name,c,rough=.8):
|
||||
m=bpy.data.materials.new(name);m.use_nodes=True
|
||||
p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=(*c,1);p.inputs['Roughness'].default_value=rough
|
||||
return m
|
||||
leafm=[material('Dry oak leaves '+str(i),c) for i,c in enumerate([(.20,.055,.012),(.32,.14,.032),(.10,.042,.013),(.40,.23,.075),(.16,.08,.025)])]
|
||||
shroud=bpy.data.objects['Breathing burial shroud'];base=shroud.data.shape_keys.key_blocks['Rest'];breath=shroud.data.shape_keys.key_blocks['Breath']
|
||||
# A real opening at the hand's emergence point, rather than intersecting cloth.
|
||||
import bmesh
|
||||
bm=bmesh.new();bm.from_mesh(shroud.data)
|
||||
for face in list(bm.faces):
|
||||
c=face.calc_center_median()
|
||||
if .48<c.x<.80 and -1.35<c.y<-.96:bm.faces.remove(face)
|
||||
bm.to_mesh(shroud.data);bm.free()
|
||||
# Leaf litter follows the animated cloth with its own synchronized shape key.
|
||||
verts=[];faces=[];indices=[];lifts=[]
|
||||
def surface(x,y):
|
||||
u=(x+.8)/1.6*28;v=(y+1.35)/2.7*44;i=min(27,max(0,int(u)));j=min(43,max(0,int(v)));a=u-i;b=v-j
|
||||
zs=[base.data[k].co.z for k in [j*29+i,j*29+i+1,(j+1)*29+i,(j+1)*29+i+1]]
|
||||
return zs[0]*(1-a)*(1-b)+zs[1]*a*(1-b)+zs[2]*(1-a)*b+zs[3]*a*b
|
||||
for n in range(950):
|
||||
x=random.uniform(-.77,.77);y=random.uniform(-1.32,1.32)
|
||||
if x>.43 and y<-.91:continue
|
||||
angle=random.random()*math.tau;size=random.uniform(.045,.105);start=len(verts)
|
||||
z=surface(x,y)+random.uniform(.012,.045)
|
||||
coords=[(0,0,.010)]
|
||||
for k in range(14):
|
||||
a=k*math.tau/14;r=1 if k%2==0 else .65
|
||||
coords.append((math.cos(a)*size*r,math.sin(a)*size*.55*r,random.uniform(-.004,.014)))
|
||||
lift=.23*math.exp(-((x/.50)**2+(y/.85)**2))
|
||||
for xx,yy,zz in coords:
|
||||
verts.append((x+xx*math.cos(angle)-yy*math.sin(angle),y+xx*math.sin(angle)+yy*math.cos(angle),z+zz));lifts.append(lift)
|
||||
idx=random.randrange(len(leafm))
|
||||
for k in range(14):faces.append((start,start+1+k,start+1+(k+1)%14));indices.append(idx)
|
||||
me=bpy.data.meshes.new('Curled oak leaf litter');me.from_pydata(verts,[],faces);me.update()
|
||||
leaves=bpy.data.objects.new('Leaves following breathing surface',me);s.collection.objects.link(leaves)
|
||||
for m in leafm:me.materials.append(m)
|
||||
for p,idx in zip(me.polygons,indices):p.material_index=idx
|
||||
leaves.shape_key_add(name='Rest');lk=leaves.shape_key_add(name='Breath')
|
||||
for v,lift in zip(lk.data,lifts):v.co.z+=lift
|
||||
for frame in range(1,481):
|
||||
s.frame_set(frame);lk.value=breath.value;lk.keyframe_insert('value',frame=frame)
|
||||
# Dimmer porch and blue moonlight, red glow beneath the grave.
|
||||
bpy.data.lights['Moon'].energy=220;bpy.data.lights['Porch'].energy=95;bpy.data.lights['Grave blue'].energy=95
|
||||
red=bpy.data.lights.new('Hidden red glow','POINT');red.energy=32;red.color=(1,.018,.004);red.shadow_soft_size=.28
|
||||
ob=bpy.data.objects.new('Hidden red glow',red);s.collection.objects.link(ob);ob.location=(0,-.3,.6)
|
||||
# Bare branches create recognisable long, eerie shadows.
|
||||
bark=material('Dark tree bark',(.025,.018,.014))
|
||||
def rod(name,a,b,r,m,parent=None):
|
||||
d=Vector(b)-Vector(a);bpy.ops.mesh.primitive_cylinder_add(vertices=10,radius=r,depth=d.length,location=(Vector(a)+Vector(b))/2);o=bpy.context.object;o.name=name;o.rotation_euler=d.to_track_quat('Z','Y').to_euler();o.data.materials.append(m)
|
||||
if parent:o.parent=parent
|
||||
return o
|
||||
for x,y in [(-3.3,2.5),(-4,-1.5)]:
|
||||
rod('Bare tree trunk',(x,y,0),(x+.1,y,3.8),.11,bark)
|
||||
for i in range(8):
|
||||
h=random.uniform(1.7,3.4);tip=(x+random.uniform(-1.5,1.5),y+random.uniform(-.8,.8),h+random.uniform(.3,1))
|
||||
rod('Bare branch',(x,y,h),tip,.035,bark)
|
||||
rod('Branch twig',tip,(tip[0]+.4,tip[1]-.2,tip[2]+.4),.012,bark)
|
||||
# Thin ground mist; faces remain readable.
|
||||
fog=bpy.data.materials.new('Thin ground mist');fog.use_nodes=True;nodes=fog.node_tree.nodes;nodes.clear();out=nodes.new('ShaderNodeOutputMaterial');vol=nodes.new('ShaderNodeVolumePrincipled');vol.inputs['Density'].default_value=.025;vol.inputs['Color'].default_value=(.45,.55,.65,1);fog.node_tree.links.new(vol.outputs['Volume'],out.inputs['Volume'])
|
||||
bpy.ops.mesh.primitive_cube_add(size=1,location=(0,0,.22));ob=bpy.context.object;ob.name='Low garden mist';ob.scale=(10,10,.40);ob.data.materials.append(fog)
|
||||
black=bpy.data.materials['Dark fabric']
|
||||
# Replace floating boots with jointed, stepping legs and swinging sleeves.
|
||||
roots=[bpy.data.objects[n] for n in ['Adult A','Adult B','Child witch','Child vampire']]
|
||||
for root,h in zip(roots,[1.78,1.68,1.05,1.2]):
|
||||
for ob in list(root.children):
|
||||
if ob.name.startswith('Boot'):bpy.data.objects.remove(ob,do_unlink=True)
|
||||
gate='((frame>=95 and frame<165) or (frame>=311 and frame<=344))'
|
||||
for side in [-1,1]:
|
||||
phase=0 if side<0 else math.pi
|
||||
hip=bpy.data.objects.new(root.name+' leg pivot '+str(side),None);s.collection.objects.link(hip);hip.parent=root;hip.location=(side*h*.075,0,h*.40)
|
||||
upper=rod('Trouser thigh',(0,0,0),(0,0,-h*.17),h*.041,black,hip)
|
||||
knee=bpy.data.objects.new(root.name+' knee '+str(side),None);s.collection.objects.link(knee);knee.parent=hip;knee.location=(0,0,-h*.17)
|
||||
rod('Trouser shin',(0,0,0),(0,0,-h*.16),h*.036,black,knee)
|
||||
bpy.ops.mesh.primitive_uv_sphere_add(segments=16,ring_count=8,location=(0,.025,-h*.19));boot=bpy.context.object;boot.name='Animated boot';boot.parent=knee;boot.scale=(h*.055,h*.10,h*.045);boot.data.materials.append(black)
|
||||
hip.driver_add('rotation_euler',0).driver.expression=f'0.40*sin(frame*0.34+{phase}) if {gate} else 0'
|
||||
knee.driver_add('rotation_euler',0).driver.expression=f'-0.40*max(0,-sin(frame*0.34+{phase})) if {gate} else 0'
|
||||
for idx,ob in enumerate([o for o in root.children if o.name.startswith('Sleeve')]):
|
||||
ob.driver_add('rotation_euler',0).driver.expression=f'0.20*sin(frame*0.34+{idx*math.pi}) if {gate} else 0.1'
|
||||
# Keep the family at the grave for the fictional fright scene.
|
||||
root.animation_data_clear()
|
||||
child=root.name.startswith('Child');idx=roots.index(root)
|
||||
x=[3.25,3.9,2.10,2.82][idx];y=[.05,.8,-.55,-1.0][idx]
|
||||
positions=[(1,(x,-9-idx*.35,0)),(95,(x,-5-idx*.25,0)),(165,(x,y,0)),(310,(x,y,0)),(325,(x+.20,y-.30,0)),(345,(x+.22,y-.40,0)),(480,(x+.22,y-.4,0))]
|
||||
for f,pos in positions:root.location=(pos[0],pos[1],pos[2]+.065);root.keyframe_insert('location',frame=f)
|
||||
for f,ang in [(1,0),(165,-.15),(310,-.15),(321,.18 if child else .07),(345,0),(480,0)]:root.rotation_euler.z=ang;root.keyframe_insert('rotation_euler',frame=f)
|
||||
root.driver_add('rotation_euler',0).driver.expression='-0.14 if 316<=frame<=328 else 0'
|
||||
# Camera remains with the family; no later jump to an adults-only sequence.
|
||||
cam=s.camera;cam.animation_data_clear();cam.data.lens=40
|
||||
for f,loc,target in [(1,(7.5,-9,4),(.7,0,.6)),(165,(6.8,-7.6,3.3),(.7,0,.65)),(310,(6.8,-7.6,3.3),(.7,0,.65)),(320,(6.6,-7.5,3.15),(.7,0,.65)),(480,(7.5,-9,4),(.7,0,.6))]:
|
||||
cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();cam.keyframe_insert('location',frame=f);cam.keyframe_insert('rotation_euler',frame=f)
|
||||
# Warm reveal is brief and darker than the previous white flash.
|
||||
flash=bpy.data.lights['Brief reveal'];flash.animation_data_clear();flash.color=(1,.24,.07)
|
||||
for f,power in [(1,0),(310,0),(316,350),(322,65),(332,140),(340,0),(480,0)]:flash.energy=power;flash.keyframe_insert('energy',frame=f)
|
||||
s.frame_set(200);s.render.image_settings.file_format='PNG';s.render.filepath=os.path.join(OUT,'example_')
|
||||
bpy.ops.file.pack_all();bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT,'halloween_examples.blend'))
|
||||
for frame in [140,200,320]:
|
||||
s.frame_set(frame);s.render.filepath=os.path.join(OUT,'example_%03d.png'%frame);bpy.ops.render.render(write_still=True)
|
||||
print('V2 EXAMPLES COMPLETE — no video rendered')
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Improve the existing concept scene; render still examples only."""
|
||||
import bpy, math, random, os
|
||||
from mathutils import Vector
|
||||
ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUT=os.path.join(ROOT,'preview','v3');os.makedirs(OUT,exist_ok=True)
|
||||
bpy.ops.wm.open_mainfile(filepath=os.path.join(ROOT,'preview','halloween_preview.blend'))
|
||||
s=bpy.context.scene;s.eevee.taa_render_samples=32
|
||||
random.seed(104)
|
||||
def material(name,c,rough=.8):
|
||||
m=bpy.data.materials.new(name);m.use_nodes=True
|
||||
p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=(*c,1);p.inputs['Roughness'].default_value=rough
|
||||
return m
|
||||
leafm=[material('Dry oak leaves '+str(i),c) for i,c in enumerate([(.20,.055,.012),(.32,.14,.032),(.10,.042,.013),(.40,.23,.075),(.16,.08,.025)])]
|
||||
shroud=bpy.data.objects['Breathing burial shroud'];base=shroud.data.shape_keys.key_blocks['Rest'];breath=shroud.data.shape_keys.key_blocks['Breath']
|
||||
# A real opening at the hand's emergence point, rather than intersecting cloth.
|
||||
import bmesh
|
||||
bm=bmesh.new();bm.from_mesh(shroud.data)
|
||||
for face in list(bm.faces):
|
||||
c=face.calc_center_median()
|
||||
if abs(c.x)<.16 and abs(c.y)<.18:bm.faces.remove(face)
|
||||
bm.to_mesh(shroud.data);bm.free()
|
||||
# Leaf litter follows the animated cloth with its own synchronized shape key.
|
||||
verts=[];faces=[];indices=[];lifts=[]
|
||||
def surface(x,y):
|
||||
u=(x+.8)/1.6*28;v=(y+1.35)/2.7*44;i=min(27,max(0,int(u)));j=min(43,max(0,int(v)));a=u-i;b=v-j
|
||||
zs=[base.data[k].co.z for k in [j*29+i,j*29+i+1,(j+1)*29+i,(j+1)*29+i+1]]
|
||||
return zs[0]*(1-a)*(1-b)+zs[1]*a*(1-b)+zs[2]*(1-a)*b+zs[3]*a*b
|
||||
for n in range(950):
|
||||
x=random.uniform(-.77,.77);y=random.uniform(-1.32,1.32)
|
||||
if x*x+y*y<.24**2:continue
|
||||
angle=random.random()*math.tau;size=random.uniform(.045,.105);start=len(verts)
|
||||
z=surface(x,y)+random.uniform(.012,.045)
|
||||
coords=[(0,0,.010)]
|
||||
for k in range(14):
|
||||
a=k*math.tau/14;r=1 if k%2==0 else .65
|
||||
coords.append((math.cos(a)*size*r,math.sin(a)*size*.55*r,random.uniform(-.004,.014)))
|
||||
lift=.23*math.exp(-((x/.50)**2+(y/.85)**2))
|
||||
for xx,yy,zz in coords:
|
||||
verts.append((x+xx*math.cos(angle)-yy*math.sin(angle),y+xx*math.sin(angle)+yy*math.cos(angle),z+zz));lifts.append(lift)
|
||||
idx=random.randrange(len(leafm))
|
||||
for k in range(14):faces.append((start,start+1+k,start+1+(k+1)%14));indices.append(idx)
|
||||
me=bpy.data.meshes.new('Curled oak leaf litter');me.from_pydata(verts,[],faces);me.update()
|
||||
leaves=bpy.data.objects.new('Leaves following breathing surface',me);s.collection.objects.link(leaves)
|
||||
for m in leafm:me.materials.append(m)
|
||||
for p,idx in zip(me.polygons,indices):p.material_index=idx
|
||||
leaves.shape_key_add(name='Rest');lk=leaves.shape_key_add(name='Breath')
|
||||
for v,lift in zip(lk.data,lifts):v.co.z+=lift
|
||||
for frame in range(1,481):
|
||||
s.frame_set(frame);lk.value=breath.value;lk.keyframe_insert('value',frame=frame)
|
||||
# A layer covering the middle opening bursts upward with the emerging hand.
|
||||
for n in range(110):
|
||||
angle=random.uniform(0,math.tau);radius=.23*math.sqrt(random.random())
|
||||
x=radius*math.cos(angle);y=radius*math.sin(angle);z0=surface(x,y)+.035
|
||||
size=random.uniform(.055,.10);vs=[(0,0,.009)]
|
||||
for k in range(14):
|
||||
theta=k*math.tau/14;r=1 if k%2==0 else .65
|
||||
vs.append((size*r*math.cos(theta),size*.55*r*math.sin(theta),random.uniform(-.004,.012)))
|
||||
me=bpy.data.meshes.new('Flying curled leaf');me.from_pydata(vs,[],[(0,k+1,(k+1)%14+1) for k in range(14)]);me.update()
|
||||
obj=bpy.data.objects.new('Burst leaf %03d'%n,me);s.collection.objects.link(obj);obj.data.materials.append(random.choice(leafm))
|
||||
delay=random.uniform(0,.11);speed=random.uniform(.35,.85);up=random.uniform(2.0,3.0);spin=random.uniform(-7,7);initial=random.uniform(0,math.tau)
|
||||
for frame in [1,200,310]+list(range(311,371))+[480]:
|
||||
s.frame_set(frame);t=max(0,(frame-313)/24-delay)
|
||||
flight=min(t,2*up/5.4);direction=angle+.8*flight
|
||||
xx=x+speed*flight*math.cos(direction);yy=y+speed*flight*math.sin(direction)
|
||||
zz=z0+up*flight-2.7*flight*flight
|
||||
if t==0:zz+=.23*math.exp(-((x/.50)**2+(y/.85)**2))*breath.value
|
||||
else:zz=max(.025,zz)
|
||||
obj.location=(xx,yy,zz);obj.rotation_euler=(spin*flight,.7*spin*flight,initial+spin*flight)
|
||||
obj.keyframe_insert('location',frame=frame);obj.keyframe_insert('rotation_euler',frame=frame)
|
||||
for fc in obj.animation_data.action.fcurves:
|
||||
for key in fc.keyframe_points:key.interpolation='LINEAR'
|
||||
hand=bpy.data.objects['Emerging hand'];hand.animation_data_clear();hand.location=(0,0,-.65)
|
||||
for frame,z in [(1,-.65),(310,-.65),(314,-.35),(322,.15),(340,.15),(365,-.65),(480,-.65)]:
|
||||
hand.location.z=z;hand.keyframe_insert('location',frame=frame)
|
||||
|
||||
# Dimmer porch and blue moonlight, red glow beneath the grave.
|
||||
bpy.data.lights['Moon'].energy=220;bpy.data.lights['Porch'].energy=95;bpy.data.lights['Grave blue'].energy=95
|
||||
red=bpy.data.lights.new('Hidden red glow','POINT');red.energy=32;red.color=(1,.018,.004);red.shadow_soft_size=.28
|
||||
ob=bpy.data.objects.new('Hidden red glow',red);s.collection.objects.link(ob);ob.location=(0,-.3,.6)
|
||||
# Bare branches create recognisable long, eerie shadows.
|
||||
bark=material('Dark tree bark',(.025,.018,.014))
|
||||
def rod(name,a,b,r,m,parent=None):
|
||||
d=Vector(b)-Vector(a);bpy.ops.mesh.primitive_cylinder_add(vertices=10,radius=r,depth=d.length,location=(Vector(a)+Vector(b))/2);o=bpy.context.object;o.name=name;o.rotation_euler=d.to_track_quat('Z','Y').to_euler();o.data.materials.append(m)
|
||||
if parent:o.parent=parent
|
||||
return o
|
||||
for x,y in [(-3.3,2.5),(-4,-1.5)]:
|
||||
rod('Bare tree trunk',(x,y,0),(x+.1,y,3.8),.11,bark)
|
||||
for i in range(8):
|
||||
h=random.uniform(1.7,3.4);tip=(x+random.uniform(-1.5,1.5),y+random.uniform(-.8,.8),h+random.uniform(.3,1))
|
||||
rod('Bare branch',(x,y,h),tip,.035,bark)
|
||||
rod('Branch twig',tip,(tip[0]+.4,tip[1]-.2,tip[2]+.4),.012,bark)
|
||||
# Thin ground mist; faces remain readable.
|
||||
fog=bpy.data.materials.new('Thin ground mist');fog.use_nodes=True;nodes=fog.node_tree.nodes;nodes.clear();out=nodes.new('ShaderNodeOutputMaterial');vol=nodes.new('ShaderNodeVolumePrincipled');vol.inputs['Density'].default_value=.025;vol.inputs['Color'].default_value=(.45,.55,.65,1);fog.node_tree.links.new(vol.outputs['Volume'],out.inputs['Volume'])
|
||||
bpy.ops.mesh.primitive_cube_add(size=1,location=(0,0,.22));ob=bpy.context.object;ob.name='Low garden mist';ob.scale=(10,10,.40);ob.data.materials.append(fog)
|
||||
black=bpy.data.materials['Dark fabric']
|
||||
# Replace floating boots with jointed, stepping legs and swinging sleeves.
|
||||
roots=[bpy.data.objects[n] for n in ['Adult A','Adult B','Child witch','Child vampire']]
|
||||
for root,h in zip(roots,[1.78,1.68,1.05,1.2]):
|
||||
for ob in list(root.children):
|
||||
if ob.name.startswith('Boot'):bpy.data.objects.remove(ob,do_unlink=True)
|
||||
gate='((frame>=95 and frame<165) or (frame>=311 and frame<=344))'
|
||||
for side in [-1,1]:
|
||||
phase=0 if side<0 else math.pi
|
||||
hip=bpy.data.objects.new(root.name+' leg pivot '+str(side),None);s.collection.objects.link(hip);hip.parent=root;hip.location=(side*h*.075,0,h*.40)
|
||||
upper=rod('Trouser thigh',(0,0,0),(0,0,-h*.17),h*.041,black,hip)
|
||||
knee=bpy.data.objects.new(root.name+' knee '+str(side),None);s.collection.objects.link(knee);knee.parent=hip;knee.location=(0,0,-h*.17)
|
||||
rod('Trouser shin',(0,0,0),(0,0,-h*.16),h*.036,black,knee)
|
||||
bpy.ops.mesh.primitive_uv_sphere_add(segments=16,ring_count=8,location=(0,.025,-h*.19));boot=bpy.context.object;boot.name='Animated boot';boot.parent=knee;boot.scale=(h*.055,h*.10,h*.045);boot.data.materials.append(black)
|
||||
hip.driver_add('rotation_euler',0).driver.expression=f'0.40*sin(frame*0.34+{phase}) if {gate} else 0'
|
||||
knee.driver_add('rotation_euler',0).driver.expression=f'-0.40*max(0,-sin(frame*0.34+{phase})) if {gate} else 0'
|
||||
for idx,ob in enumerate([o for o in root.children if o.name.startswith('Sleeve')]):
|
||||
ob.driver_add('rotation_euler',0).driver.expression=f'0.20*sin(frame*0.34+{idx*math.pi}) if {gate} else 0.1'
|
||||
# Keep the family at the grave for the fictional fright scene.
|
||||
root.animation_data_clear()
|
||||
child=root.name.startswith('Child');idx=roots.index(root)
|
||||
x=[3.25,3.9,2.10,2.82][idx];y=[.05,.8,-.55,-1.0][idx]
|
||||
positions=[(1,(x,-9-idx*.35,0)),(95,(x,-5-idx*.25,0)),(165,(x,y,0)),(310,(x,y,0)),(325,(x+.20,y-.30,0)),(345,(x+.22,y-.40,0)),(480,(x+.22,y-.4,0))]
|
||||
for f,pos in positions:root.location=(pos[0],pos[1],pos[2]+.065);root.keyframe_insert('location',frame=f)
|
||||
for f,ang in [(1,0),(165,-.15),(310,-.15),(321,.18 if child else .07),(345,0),(480,0)]:root.rotation_euler.z=ang;root.keyframe_insert('rotation_euler',frame=f)
|
||||
root.driver_add('rotation_euler',0).driver.expression='-0.14 if 316<=frame<=328 else 0'
|
||||
# Camera remains with the family; no later jump to an adults-only sequence.
|
||||
cam=s.camera;cam.animation_data_clear();cam.data.lens=40
|
||||
for f,loc,target in [(1,(7.5,-9,4),(.7,0,.6)),(165,(6.8,-7.6,3.3),(.7,0,.65)),(310,(6.8,-7.6,3.3),(.7,0,.65)),(320,(6.6,-7.5,3.15),(.7,0,.65)),(480,(7.5,-9,4),(.7,0,.6))]:
|
||||
cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();cam.keyframe_insert('location',frame=f);cam.keyframe_insert('rotation_euler',frame=f)
|
||||
# Warm reveal is brief and darker than the previous white flash.
|
||||
flash=bpy.data.lights['Brief reveal'];flash.animation_data_clear();flash.color=(1,.24,.07)
|
||||
for f,power in [(1,0),(310,0),(316,350),(322,65),(332,140),(340,0),(480,0)]:flash.energy=power;flash.keyframe_insert('energy',frame=f)
|
||||
s.frame_set(200);s.render.image_settings.file_format='PNG';s.render.filepath=os.path.join(OUT,'example_')
|
||||
bpy.ops.file.pack_all();bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT,'halloween_examples.blend'))
|
||||
for frame in [310,322,334]:
|
||||
s.frame_set(frame);s.render.filepath=os.path.join(OUT,'example_%03d.png'%frame);bpy.ops.render.render(write_still=True)
|
||||
print('V3 EXAMPLES COMPLETE — no video rendered')
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Darker night lighting with visible practical sources; still renders only."""
|
||||
import bpy, os, math
|
||||
from mathutils import Vector
|
||||
ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUT=os.path.join(ROOT,'preview','v4');os.makedirs(OUT,exist_ok=True)
|
||||
bpy.ops.wm.open_mainfile(filepath=os.path.join(ROOT,'preview','v3','halloween_examples.blend'))
|
||||
s=bpy.context.scene;s.render.engine='BLENDER_EEVEE';s.eevee.taa_render_samples=64
|
||||
s.eevee.use_gtao=True;s.eevee.gtao_distance=2;s.eevee.use_ssr=True;s.eevee.use_ssr_refraction=True
|
||||
s.eevee.use_soft_shadows=True;s.eevee.shadow_cube_size='2048';s.eevee.use_bloom=True;s.eevee.bloom_intensity=.045;s.eevee.bloom_threshold=1.2
|
||||
s.view_settings.exposure=-.6
|
||||
s.world.use_nodes=True;bg=s.world.node_tree.nodes.get('Background');bg.inputs['Color'].default_value=(.002,.004,.008,1);bg.inputs['Strength'].default_value=.15
|
||||
for l in bpy.data.lights:
|
||||
l.use_shadow=True;l.use_contact_shadow=True;l.contact_shadow_distance=.4;l.contact_shadow_bias=.015
|
||||
moon=bpy.data.lights['Moon'];moon.energy=95;moon.color=(.55,.65,1);moon.size=5
|
||||
bpy.data.lights['Porch'].energy=0
|
||||
blue=bpy.data.lights['Grave blue'];blue.energy=30;blue.color=(.12,.24,1);blue.size=.7
|
||||
red=bpy.data.lights['Hidden red glow'];red.energy=5;red.color=(1,.055,.008);red.shadow_soft_size=.12
|
||||
bpy.data.objects['Hidden red glow'].location=(-.72,.7,.31)
|
||||
# Light actually emanates from the lit windows, casting warm patches on the path.
|
||||
for x in [-3,0,3]:
|
||||
l=bpy.data.lights.new('Window spill '+str(x),'AREA');l.energy=24;l.color=(1,.59,.28);l.shape='RECTANGLE';l.size=1.3;l.size_y=1.5
|
||||
l.use_contact_shadow=True
|
||||
o=bpy.data.objects.new(l.name,l);s.collection.objects.link(o);o.location=(x,5.55,2.2);o.rotation_euler=(Vector((x,1,.2))-o.location).to_track_quat('-Z','Y').to_euler()
|
||||
m=bpy.data.materials['Warm window '+str(x)];p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Emission Color'].default_value=(1,.52,.19,1);p.inputs['Emission Strength'].default_value=1.5
|
||||
# Small visible porch lantern with warm light and subtle irregular flicker.
|
||||
em=bpy.data.materials.new('Lantern glowing glass');em.use_nodes=True;p=em.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=(.6,.18,.02,1);p.inputs['Emission Color'].default_value=(1,.45,.12,1);p.inputs['Emission Strength'].default_value=4
|
||||
bpy.ops.mesh.primitive_uv_sphere_add(segments=16,ring_count=8,location=(2,5.35,2.15));o=bpy.context.object;o.name='Porch lantern glass';o.scale=(.065,.065,.12);o.data.materials.append(em)
|
||||
for z in [2.02,2.28]:
|
||||
bpy.ops.mesh.primitive_cube_add(size=1,location=(2,5.35,z));o=bpy.context.object;o.name='Lantern metal cap';o.scale=(.19,.16,.035);o.data.materials.append(bpy.data.materials['Dark fabric'])
|
||||
l=bpy.data.lights.new('Porch lantern light','POINT');l.color=(1,.56,.23);l.shadow_soft_size=.09;l.use_contact_shadow=True
|
||||
ob=bpy.data.objects.new(l.name,l);s.collection.objects.link(ob);ob.location=(2,5.20,2.15)
|
||||
for frame in range(1,481,3):l.energy=17+1.8*math.sin(frame*.17)+.8*math.sin(frame*.71);l.keyframe_insert('energy',frame=frame)
|
||||
# Damp pavers catch localized highlights; leave soil and cloth matte.
|
||||
p=bpy.data.materials['Weathered limestone'].node_tree.nodes.get('Principled BSDF');p.inputs['Roughness'].default_value=.48
|
||||
# Directed flash briefly reveals hand and airborne leaves, rather than lighting the whole garden.
|
||||
ob=bpy.data.objects['Brief reveal'];old=ob.data;flash=bpy.data.lights.new('Directed grave reveal','SPOT');ob.data=flash;flash.use_contact_shadow=True;flash.spot_size=math.radians(65);flash.spot_blend=.65;flash.shadow_soft_size=.13;flash.color=(1,.67,.36)
|
||||
for frame,power in [(1,0),(310,0),(316,700),(320,450),(324,180),(332,380),(340,0),(480,0)]:flash.energy=power;flash.keyframe_insert('energy',frame=frame)
|
||||
# Hint of light scattering in the low mist, without a luminous fog blanket.
|
||||
vol=bpy.data.materials['Thin ground mist'].node_tree.nodes.get('Principled Volume');vol.inputs['Density'].default_value=.035;vol.inputs['Anisotropy'].default_value=.25
|
||||
s.frame_set(310);s.render.image_settings.file_format='PNG';s.render.filepath=os.path.join(OUT,'example_')
|
||||
bpy.ops.file.pack_all();bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT,'halloween_examples.blend'))
|
||||
for frame in [310,322,334]:
|
||||
s.frame_set(frame);s.render.filepath=os.path.join(OUT,'example_%03d.png'%frame);bpy.ops.render.render(write_still=True)
|
||||
print('DARKER EXAMPLES COMPLETE — still images only')
|
||||
@@ -0,0 +1,175 @@
|
||||
import bpy, math, random, os, sys, wave, struct
|
||||
from mathutils import Vector
|
||||
random.seed(18)
|
||||
ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUT=os.path.join(ROOT,'preview')
|
||||
bpy.ops.object.select_all(action='SELECT'); bpy.ops.object.delete(use_global=False)
|
||||
s=bpy.context.scene
|
||||
s.render.engine='BLENDER_EEVEE'; s.eevee.taa_render_samples=8; s.eevee.use_gtao=True; s.eevee.gtao_distance=3; s.eevee.use_soft_shadows=True
|
||||
s.render.resolution_x=1280;s.render.resolution_y=720;s.render.resolution_percentage=100
|
||||
s.render.fps=24;s.frame_start=1;s.frame_end=480
|
||||
s.world.color=(.003,.003,.003)
|
||||
s.view_settings.view_transform='AgX'
|
||||
def mat(name,color,rough=.7,noise=False):
|
||||
m=bpy.data.materials.new(name);m.diffuse_color=(*color,1);m.use_nodes=True
|
||||
p=m.node_tree.nodes.get('Principled BSDF');p.inputs['Base Color'].default_value=(*color,1);p.inputs['Roughness'].default_value=rough
|
||||
if noise:
|
||||
n=m.node_tree.nodes.new('ShaderNodeTexNoise');n.inputs['Scale'].default_value=24
|
||||
b=m.node_tree.nodes.new('ShaderNodeBump');b.inputs['Strength'].default_value=.3;b.inputs['Distance'].default_value=.08
|
||||
m.node_tree.links.new(n.outputs['Fac'],b.inputs['Height']);m.node_tree.links.new(b.outputs['Normal'],p.inputs['Normal'])
|
||||
return m
|
||||
soil=mat('Damp garden soil',(.07,.045,.025),noise=True);stone=mat('Weathered limestone',(.28,.29,.27),noise=True)
|
||||
cloth=mat('Burial cloth',(.15,.12,.09),noise=True);brick=mat('Rendered house wall',(.24,.19,.14),noise=True)
|
||||
bone=mat('Old ivory',(.55,.48,.32));black=mat('Dark fabric',(.015,.02,.025),noise=True)
|
||||
leafm=[mat('Autumn leaf '+str(i),c) for i,c in enumerate([(.21,.065,.014),(.30,.15,.03),(.12,.055,.018)])]
|
||||
def cube(name,loc,scale,m,bevel=0):
|
||||
bpy.ops.mesh.primitive_cube_add(size=1,location=loc);o=bpy.context.object;o.name=name;o.scale=scale
|
||||
bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
|
||||
if m:o.data.materials.append(m)
|
||||
if bevel:
|
||||
mod=o.modifiers.new('Soft edges','BEVEL');mod.width=bevel;mod.segments=3;o.data.use_auto_smooth=True;o.modifiers.new('Normals','WEIGHTED_NORMAL')
|
||||
return o
|
||||
def uv(name,loc,scale,m):
|
||||
bpy.ops.mesh.primitive_uv_sphere_add(segments=16,ring_count=10,location=loc);o=bpy.context.object;o.name=name;o.scale=scale;o.data.materials.append(m)
|
||||
for p in o.data.polygons:p.use_smooth=True
|
||||
return o
|
||||
def rod(name,a,b,r,m,parent=None):
|
||||
d=Vector(b)-Vector(a);bpy.ops.mesh.primitive_cylinder_add(vertices=10,radius=r,depth=d.length,location=(Vector(a)+Vector(b))/2)
|
||||
o=bpy.context.object;o.name=name;o.rotation_euler=d.to_track_quat('Z','Y').to_euler();o.data.materials.append(m)
|
||||
if parent:o.parent=parent
|
||||
return o
|
||||
cube('Ground',(0,0,-.12),(16,16,.2),soil)
|
||||
cube('Walkway',(2.6,0,.01),(1.5,12,.09),stone,.03)
|
||||
for y in range(-6,7):cube('Paving joint',(2.6,y,.065),(1.5,.017,.005),black)
|
||||
cube('House',(0,6,2),(12,.3,4),brick)
|
||||
for x in [-3,0,3]:
|
||||
cube('Window frame',(x,5.80,2.2),(1.6,.15,1.8),black,.04)
|
||||
glass=mat('Warm window '+str(x),(.5,.22,.045));p=glass.node_tree.nodes.get('Principled BSDF');p.inputs['Emission Color'].default_value=(1,.35,.08,1);p.inputs['Emission Strength'].default_value=.8
|
||||
cube('Lit window',(x,5.70,2.2),(1.4,.04,1.6),glass)
|
||||
cube('Window divider',(x,5.65,2.2),(.05,.06,1.7),black)
|
||||
for x in [-5,5]:
|
||||
for y in range(-4,6):cube('Fence post',(x,y,.65),(.09,.09,1.3),stone,.015)
|
||||
cube('Fence rail',(x,.5,.9),(.07,10,.08),stone)
|
||||
# Grave border and headstone
|
||||
for x in [-.9,.9]:cube('Grave side',(x,0,.14),(.15,2.9,.28),stone,.04)
|
||||
for y in [-1.45,1.45]:cube('Grave end',(0,y,.14),(1.95,.15,.28),stone,.04)
|
||||
cube('Headstone base',(0,1.65,.25),(1.1,.45,.3),stone,.06)
|
||||
cube('Headstone',(0,1.65,.85),(.85,.22,1.1),stone,.10)
|
||||
uv('Rounded headstone',(0,1.65,1.35),(.425,.11,.3),stone)
|
||||
def text(body,loc,size):
|
||||
bpy.ops.object.text_add(location=loc,rotation=(math.pi/2,0,0));o=bpy.context.object;o.data.body=body;o.data.align_x='CENTER';o.data.size=size;o.data.extrude=.002;o.data.materials.append(black)
|
||||
text('R I P',(0,1.525,1.05),.19);text('STILL BREATHING',(0,1.525,.72),.065)
|
||||
# Fabric mesh with animated breathing shape
|
||||
verts=[];faces=[];nx=28;ny=44
|
||||
for j in range(ny+1):
|
||||
y=-1.35+2.7*j/ny
|
||||
for i in range(nx+1):
|
||||
x=-.80+1.6*i/nx;z=.19+.035*math.sin(i*1.3+j*.7)*math.sin(math.pi*i/nx)*math.sin(math.pi*j/ny);verts.append((x,y,z))
|
||||
for j in range(ny):
|
||||
for i in range(nx):
|
||||
a=j*(nx+1)+i;faces.append((a,a+1,a+nx+2,a+nx+1))
|
||||
mesh=bpy.data.meshes.new('Cloth grid');mesh.from_pydata(verts,[],faces);mesh.update()
|
||||
o=bpy.data.objects.new('Breathing burial shroud',mesh);s.collection.objects.link(o);o.data.materials.append(cloth)
|
||||
for p in mesh.polygons:p.use_smooth=True
|
||||
o.shape_key_add(name='Rest');breath=o.shape_key_add(name='Breath')
|
||||
for v in breath.data:v.co.z+=.23*math.exp(-((v.co.x/.50)**2+(v.co.y/.85)**2))
|
||||
for f,val in [(1,.1),(30,1),(65,0),(100,.85),(120,.15),(150,0),(175,.45),(210,0),(240,.1),(241,0),(310,0),(316,1.8),(322,.1),(332,1.2),(345,0),(380,.1),(415,.8),(450,0),(480,.35)]:breath.value=val;breath.keyframe_insert('value',frame=f)
|
||||
for i in range(220):
|
||||
x=random.uniform(-4,4);y=random.uniform(-4,4)
|
||||
if abs(x)<1 and abs(y)<1.5:continue
|
||||
me=bpy.data.meshes.new('Leaf');me.from_pydata([(-.10,0,0),(-.045,-.04,0),(0,-.065,.01),(.07,-.025,0),(.11,0,0),(.04,.045,0),(-.02,.055,.008)],[],[(0,1,2,3,4,5,6)]);me.update()
|
||||
l=bpy.data.objects.new('Fallen leaf',me);s.collection.objects.link(l);l.location=(x,y,.025);l.data.materials.append(random.choice(leafm));l.rotation_euler.z=random.random()*6.28
|
||||
# Grass tufts, shrubs, and Halloween pumpkins
|
||||
grassmat=mat('Dry grass',(.055,.065,.024))
|
||||
for i in range(180):
|
||||
x=random.uniform(-4.5,1.6);y=random.uniform(-4,4)
|
||||
if abs(x)<1.1 and abs(y)<1.8:continue
|
||||
rod('Grass blade',(x,y,0),(x+.025,y+.015,random.uniform(.07,.17)),.003,grassmat)
|
||||
for x,y in [(-2,1),(-3,3),(4,4)]:
|
||||
for j in range(8):uv('Garden shrub',(x+random.uniform(-.3,.3),y+random.uniform(-.3,.3),random.uniform(.2,.5)),(.3,.3,.3),grassmat)
|
||||
for x,y in [(-1.5,-1.7),(-1.8,-1.4),(.8,2.1)]:
|
||||
pumpkin=uv('Ribbed pumpkin',(x,y,.18),(.23,.23,.18),leafm[0])
|
||||
for v in pumpkin.data.vertices:
|
||||
ang=math.atan2(v.co.y,v.co.x);fac=1+.055*math.cos(ang*10);v.co.x*=fac;v.co.y*=fac
|
||||
rod('Pumpkin stem',(x,y,.32),(x+.02,y,.42),.024,soil)
|
||||
# Cloaked costumed visitors, seen from behind/side
|
||||
purple=mat('Purple wool costume',(.11,.025,.13),noise=True);red=mat('Burgundy costume',(.18,.018,.022),noise=True)
|
||||
def person(name,h,color,start,end,child=False):
|
||||
root=bpy.data.objects.new(name,None);s.collection.objects.link(root)
|
||||
# Folded tapered cloak
|
||||
vs=[];fs=[];rings=14;seg=32
|
||||
for j in range(rings):
|
||||
z=h*(.18+.60*j/(rings-1));r=h*(.24-.12*j/(rings-1))
|
||||
for k in range(seg):
|
||||
ang=k*2*math.pi/seg;rr=r*(1+.085*math.sin(ang*9+j*.25));vs.append((rr*math.cos(ang),rr*.65*math.sin(ang),z))
|
||||
for j in range(rings-1):
|
||||
for k in range(seg):a=j*seg+k;b=j*seg+(k+1)%seg;fs.append((a,b,b+seg,a+seg))
|
||||
me=bpy.data.meshes.new(name+' costume');me.from_pydata(vs,[],fs);me.update();ob=bpy.data.objects.new(name+' cloak',me);s.collection.objects.link(ob);ob.parent=root;ob.data.materials.append(color)
|
||||
for p in me.polygons:p.use_smooth=True
|
||||
for x in [-h*.08,h*.08]:
|
||||
ob=uv('Boot',(x,-.04,h*.08),(h*.065,h*.10,h*.09),black);ob.parent=root
|
||||
drv=ob.driver_add('location',2).driver;drv.expression=f'{h*.08} + (max(0,sin(frame*0.32+{0 if x<0 else math.pi}))*{h*.035} if 120<frame<165 else 0)'
|
||||
ob=uv('Hood',(0,0,h*.86),(h*.12,h*.12,h*.15),color);ob.parent=root
|
||||
# dark face opening directed toward grave (-X)
|
||||
ob=uv('Hood shadow',(-h*.10,0,h*.85),(h*.025,h*.075,h*.09),black);ob.parent=root
|
||||
for sign in [-1,1]:
|
||||
ob=uv('Sleeve',(0,sign*h*.14,h*.56),(h*.07,h*.07,h*.24),color);ob.rotation_euler.x=sign*.12;ob.parent=root
|
||||
if child:
|
||||
ob=uv('Pumpkin candy bucket',(-h*.12,-h*.27,h*.35),(h*.09,h*.09,h*.08),leafm[1]);ob.parent=root
|
||||
for f,pos in start:root.location=pos;root.keyframe_insert('location',frame=f)
|
||||
for f,angle in end:root.rotation_euler.z=angle;root.keyframe_insert('rotation_euler',frame=f)
|
||||
return root
|
||||
person('Adult A',1.78,black,[(1,(3.2,-10,0)),(120,(3.2,-3,0)),(165,(2.7,.0,0)),(235,(2.7,1.5,0)),(240,(3,7,0)),(241,(2.7,-.35,0)),(310,(2.7,-.35,0)),(325,(3.05,-.75,0)),(390,(3.05,-.75,0)),(480,(3.2,.2,0))],[(1,0),(310,0),(325,-.15),(380,0)])
|
||||
person('Adult B',1.68,red,[(1,(3.6,-10.8,0)),(120,(3.6,-3.6,0)),(165,(3.35,-.5,0)),(235,(3.35,1.0,0)),(240,(3,7,0)),(241,(3.45,.5,0)),(310,(3.45,.5,0)),(325,(3.75,.9,0)),(480,(3.65,1.3,0))],[(1,0),(310,0),(325,.15),(380,0)])
|
||||
for name,h,x,y,c in [('Child witch',1.05,2.35,-.8,purple),('Child vampire',1.2,3.1,-1.35,black)]:
|
||||
person(name,h,c,[(1,(x,-11,0)),(120,(x,-3.8,0)),(165,(x,y,0)),(220,(x,y+.8,0)),(239,(x,3.5,0)),(240,(x,8,0)),(480,(x,8,0))],[(1,0)],True)
|
||||
# Emerging skeletal hand
|
||||
hand=bpy.data.objects.new('Emerging hand',None);s.collection.objects.link(hand);hand.location=(.65,-1.15,-.4)
|
||||
ob=uv('Palm',(0,0,.4),(.09,.045,.12),bone);ob.parent=hand
|
||||
rod('Forearm',(0,0,0),(0,0,.32),.045,bone,hand)
|
||||
for i in range(4):
|
||||
x=(i-1.5)*.045;top=.64+(.025 if i in [1,2] else 0)
|
||||
rod('Finger',(x,0,.48),(x,-.025,top),.015,bone,hand);rod('Finger tip',(x,-.025,top),(x,-.065,top+.035),.012,bone,hand)
|
||||
rod('Thumb',(-.075,0,.4),(-.14,-.04,.48),.02,bone,hand)
|
||||
for f,z in [(1,-.65),(310,-.65),(320,.03),(345,.05),(365,-.65),(480,-.65)]:hand.location.z=z;hand.keyframe_insert('location',frame=f)
|
||||
def light(name,loc,color,power,size=3):
|
||||
data=bpy.data.lights.new(name,'AREA');data.energy=power;data.color=color;data.shape='DISK';data.size=size
|
||||
ob=bpy.data.objects.new(name,data);s.collection.objects.link(ob);ob.location=loc;ob.rotation_euler=(Vector((0,0,.3))-ob.location).to_track_quat('-Z','Y').to_euler();return data
|
||||
light('Moon',(0,-2,7),(.27,.46,1),450,7)
|
||||
light('Porch',(1,4,3),(1,.46,.16),240,4)
|
||||
light('Grave blue',(-1,1,1.9),(.025,.12,1),110,2)
|
||||
flash=light('Brief reveal',(1,-2,2),(1,.72,.38),0,1)
|
||||
for f,e in [(1,0),(312,0),(316,600),(321,40),(330,260),(337,0),(480,0)]:flash.energy=e;flash.keyframe_insert('energy',frame=f)
|
||||
# Cinematic camera
|
||||
bpy.ops.object.camera_add();cam=bpy.context.object;s.camera=cam;cam.data.lens=38
|
||||
for f,loc,target in [(1,(7,-9,4),(0,0,.55)),(120,(4.8,-6.3,3.1),(0,0,.55)),(240,(4.8,-6.3,3.1),(.7,0,.65)),(241,(6.5,-7,3.3),(.6,0,.65)),(380,(6.5,-7,3.3),(.6,0,.65)),(480,(7,-8.5,4.5),(0,.5,.6))]:
|
||||
cam.location=loc;cam.rotation_euler=(Vector(target)-cam.location).to_track_quat('-Z','Y').to_euler();cam.keyframe_insert('location',frame=f);cam.keyframe_insert('rotation_euler',frame=f)
|
||||
# Make cuts and visitor reset instantaneous rather than interpolated
|
||||
for ob in [cam]+[o for o in s.objects if o.type=='EMPTY' and o.name.startswith(('Adult','Child'))]:
|
||||
if ob.animation_data:
|
||||
for fc in ob.animation_data.action.fcurves:
|
||||
for k in fc.keyframe_points:
|
||||
if int(k.co.x) in [240,239]:k.interpolation='CONSTANT'
|
||||
# Sound: filtered noise breath, scratching and synthetic growl
|
||||
rate=24000;random.seed(72);samples=[];lp=0
|
||||
for i in range(rate*20):
|
||||
t=i/rate;lp=.90*lp+.10*random.uniform(-1,1)
|
||||
env=(.5+.5*math.sin(t*2.1))**3
|
||||
a=.07*lp*env if t<10 or t>17 else .006*lp
|
||||
for begin,end in [(7.1,8.3),(13,13.45)]:
|
||||
if begin<t<end:a+=.13*lp*(.5+.5*math.sin(t*90))
|
||||
if 13.15<t<16.1:
|
||||
e=math.exp(-(t-13.15)*1.25);a+=e*(.27*math.sin(2*math.pi*62*t+3*math.sin(t*17))+.25*lp)
|
||||
samples.append(int(max(-.9,min(.9,a))*32767))
|
||||
audio=os.path.join(OUT,'effects.wav')
|
||||
with wave.open(audio,'wb') as w:w.setparams((1,2,rate,len(samples),'NONE','not compressed'));w.writeframes(struct.pack('<%dh'%len(samples),*samples))
|
||||
s.sequence_editor_create();s.sequence_editor.sequences.new_sound('Generated ambience and effects',audio,channel=1,frame_start=1)
|
||||
s.render.image_settings.file_format='PNG';s.render.filepath=os.path.join(OUT,'frames','frame_')
|
||||
bpy.ops.file.pack_all()
|
||||
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT,'halloween_preview.blend'))
|
||||
mode=sys.argv[-1]
|
||||
if mode=='previews':
|
||||
for f in [80,185,320]:
|
||||
s.frame_set(f);s.render.filepath=os.path.join(OUT,'preview_%03d.png'%f);bpy.ops.render.render(write_still=True)
|
||||
elif mode=='video':
|
||||
s.render.image_settings.file_format='FFMPEG';s.render.ffmpeg.format='MPEG4';s.render.ffmpeg.codec='H264';s.render.ffmpeg.constant_rate_factor='MEDIUM';s.render.ffmpeg.audio_codec='AAC';s.render.ffmpeg.audio_bitrate=192;s.render.filepath=os.path.join(OUT,'halloween_preview.mp4');bpy.ops.render.render(animation=True)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Render the approved dark scene as a 20-second video."""
|
||||
import bpy, os
|
||||
ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUT=os.path.join(ROOT,'preview','v4')
|
||||
bpy.ops.wm.open_mainfile(filepath=os.path.join(OUT,'halloween_examples.blend'))
|
||||
s=bpy.context.scene
|
||||
# Animation quality: retain lights, fog and reflections; use fewer temporal samples.
|
||||
s.eevee.taa_render_samples=8;s.eevee.volumetric_samples=16
|
||||
s.render.resolution_x=1280;s.render.resolution_y=720;s.render.resolution_percentage=100
|
||||
s.render.fps=24;s.frame_start=1;s.frame_end=480
|
||||
s.render.image_settings.file_format='FFMPEG';s.render.ffmpeg.format='MPEG4'
|
||||
s.render.ffmpeg.codec='H264';s.render.ffmpeg.constant_rate_factor='MEDIUM'
|
||||
s.render.ffmpeg.audio_codec='AAC';s.render.ffmpeg.audio_bitrate=192
|
||||
s.render.filepath=os.path.join(OUT,'halloween_dark.mp4')
|
||||
# Confirm that all four visitors have animated leg joints in the saved scene.
|
||||
legs=[o for o in s.objects if 'leg pivot' in o.name]
|
||||
assert len(legs)==8
|
||||
s.frame_set(140);a=[o.evaluated_get(bpy.context.evaluated_depsgraph_get()).rotation_euler.x for o in legs]
|
||||
s.frame_set(148);b=[o.evaluated_get(bpy.context.evaluated_depsgraph_get()).rotation_euler.x for o in legs]
|
||||
assert all(abs(x-y)>.01 for x,y in zip(a,b))
|
||||
print('VERIFIED: eight moving leg joints')
|
||||
s.frame_set(1);bpy.ops.file.pack_all()
|
||||
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT,'halloween_dark.blend'))
|
||||
bpy.ops.render.render(animation=True)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Decode selected video frames and save the editable scene with MP4 settings."""
|
||||
import bpy, os
|
||||
ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUT=os.path.join(ROOT,'preview')
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
s=bpy.context.scene;s.render.resolution_x=1280;s.render.resolution_y=720;s.render.resolution_percentage=100;s.render.fps=24
|
||||
s.sequence_editor_create();strip=s.sequence_editor.sequences.new_movie('Finished video',os.path.join(OUT,'halloween_preview.mp4'),channel=1,frame_start=1)
|
||||
print('DECODED MOVIE',strip.frame_duration,strip.elements[0].orig_width,strip.elements[0].orig_height)
|
||||
s.render.image_settings.file_format='PNG'
|
||||
for frame in [1,80,120,185,239,241,310,316,320,360,440,480]:
|
||||
s.frame_set(frame);s.render.filepath=os.path.join(OUT,'frames','review_%03d.png'%frame);bpy.ops.render.render(write_still=True)
|
||||
bpy.ops.wm.open_mainfile(filepath=os.path.join(OUT,'halloween_preview.blend'))
|
||||
s=bpy.context.scene;s.frame_set(1)
|
||||
s.render.image_settings.file_format='FFMPEG';s.render.ffmpeg.format='MPEG4';s.render.ffmpeg.codec='H264';s.render.ffmpeg.constant_rate_factor='MEDIUM';s.render.ffmpeg.audio_codec='AAC';s.render.ffmpeg.audio_bitrate=192;s.render.filepath='//halloween_preview.mp4'
|
||||
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT,'halloween_preview.blend'))
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Decode selected video frames and save the editable scene with MP4 settings."""
|
||||
import bpy, os
|
||||
ROOT=os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
OUT=os.path.join(ROOT,'preview','v4')
|
||||
os.makedirs(os.path.join(OUT,'frames'),exist_ok=True)
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
s=bpy.context.scene;s.render.resolution_x=1280;s.render.resolution_y=720;s.render.resolution_percentage=100;s.render.fps=24
|
||||
s.sequence_editor_create();strip=s.sequence_editor.sequences.new_movie('Finished video',os.path.join(OUT,'halloween_dark.mp4'),channel=1,frame_start=1)
|
||||
print('DECODED MOVIE',strip.frame_duration,strip.elements[0].orig_width,strip.elements[0].orig_height)
|
||||
s.render.image_settings.file_format='PNG'
|
||||
for frame in [1,120,128,136,144,152,185,310,322,334,440,480]:
|
||||
s.frame_set(frame);s.render.filepath=os.path.join(OUT,'frames','review_%03d.png'%frame);bpy.ops.render.render(write_still=True)
|
||||
bpy.ops.wm.open_mainfile(filepath=os.path.join(OUT,'halloween_dark.blend'))
|
||||
s=bpy.context.scene;s.frame_set(1)
|
||||
s.render.image_settings.file_format='FFMPEG';s.render.ffmpeg.format='MPEG4';s.render.ffmpeg.codec='H264';s.render.ffmpeg.constant_rate_factor='MEDIUM';s.render.ffmpeg.audio_codec='AAC';s.render.ffmpeg.audio_bitrate=192;s.render.filepath='//halloween_dark.mp4'
|
||||
bpy.ops.wm.save_as_mainfile(filepath=os.path.join(OUT,'halloween_dark.blend'))
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
project_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)
|
||||
source_dir=$project_dir
|
||||
pi_user=
|
||||
pi_host=
|
||||
remote_dir=
|
||||
apply=false
|
||||
install_service=false
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Projektdateien per SSH/rsync auf einen Raspberry Pi kopieren.
|
||||
|
||||
Aufruf:
|
||||
bash tools/upload_to_pi.sh --user BENUTZER --host HOST \
|
||||
--remote-dir /home/BENUTZER/halloween [--source ORDNER] [--install-service] [--apply]
|
||||
|
||||
Ohne --apply wird nur der geplante Aufruf angezeigt; es gibt keine Verbindung
|
||||
zum Pi. --apply legt den Zielordner an und kopiert die Dateien. Alte Dateien
|
||||
auf dem Pi werden nicht gelöscht.
|
||||
--install-service richtet nach dem Upload einen systemd-Autostart ein. Dazu
|
||||
muss die Python-Umgebung auf dem Pi bereits vorbereitet sein; sudo wird dort
|
||||
benötigt. Ohne --apply wird auch der Dienst nicht installiert.
|
||||
EOF
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'Fehler: %s\n' "$*" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
while (($#)); do
|
||||
case $1 in
|
||||
--user|--host|--remote-dir|--source)
|
||||
(($# >= 2)) || die "Wert für $1 fehlt"
|
||||
case $1 in
|
||||
--user) pi_user=$2 ;;
|
||||
--host) pi_host=$2 ;;
|
||||
--remote-dir) remote_dir=$2 ;;
|
||||
--source) source_dir=$2 ;;
|
||||
esac
|
||||
shift 2
|
||||
;;
|
||||
--apply) apply=true; shift ;;
|
||||
--install-service) install_service=true; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) die "Unbekanntes Argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ $pi_user =~ ^[a-zA-Z_][a-zA-Z0-9_-]*$ ]] || die 'Gültigen --user angeben'
|
||||
[[ $pi_host =~ ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ ]] || die 'Gültigen --host angeben (Name oder IPv4-Adresse)'
|
||||
[[ $remote_dir =~ ^/[a-zA-Z0-9_./-]+$ && $remote_dir != / ]] || die 'Sicheren absoluten --remote-dir angeben'
|
||||
[[ /$remote_dir/ != *'/../'* && /$remote_dir/ != *'/./'* ]] || die 'Zielpfad darf keine . oder .. Segmente enthalten'
|
||||
[[ -d $source_dir ]] || die "Quellordner fehlt: $source_dir"
|
||||
source_dir=$(cd -- "$source_dir" && pwd -P)
|
||||
if $install_service; then
|
||||
[[ -f "$source_dir/tools/install_pi_service.sh" ]] || die 'tools/install_pi_service.sh fehlt in der Quelle'
|
||||
fi
|
||||
|
||||
command -v ssh >/dev/null || die 'ssh fehlt lokal'
|
||||
command -v rsync >/dev/null || die 'rsync fehlt lokal'
|
||||
|
||||
target="$pi_user@$pi_host"
|
||||
excludes=(
|
||||
--exclude=.git/
|
||||
--exclude=.venv/
|
||||
--exclude=__pycache__/
|
||||
--exclude=data/
|
||||
--exclude=preview/
|
||||
--exclude=references/
|
||||
--exclude=.env
|
||||
--exclude='*.pem'
|
||||
--exclude='*.key'
|
||||
--exclude='*.p12'
|
||||
--exclude='*.pfx'
|
||||
)
|
||||
ssh_cmd=(ssh -o StrictHostKeyChecking=ask "$target" "mkdir -p -- '$remote_dir'")
|
||||
rsync_cmd=(
|
||||
rsync -a --delay-updates --human-readable --info=stats2
|
||||
"${excludes[@]}"
|
||||
-e 'ssh -o StrictHostKeyChecking=ask'
|
||||
-- "$source_dir/" "$target:$remote_dir/"
|
||||
)
|
||||
service_cmd=(ssh -tt -o StrictHostKeyChecking=ask "$target" "cd -- '$remote_dir' && bash tools/install_pi_service.sh --apply")
|
||||
|
||||
printf 'Quelle: %s\nZiel: %s:%s/\n' "$source_dir" "$target" "$remote_dir"
|
||||
printf 'Ausgelassen: .git, .venv, data, preview, references, __pycache__, .env, Schlüsseldateien\n'
|
||||
if ! $apply; then
|
||||
printf '\nVorschau, noch keine Verbindung und keine Übertragung:\n'
|
||||
printf ' %q' "${ssh_cmd[@]}"; printf '\n'
|
||||
printf ' %q' "${rsync_cmd[@]}"; printf '\n'
|
||||
if $install_service; then
|
||||
printf ' %q' "${service_cmd[@]}"; printf '\n'
|
||||
fi
|
||||
printf '\nMit --apply ausführen.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
"${ssh_cmd[@]}"
|
||||
"${rsync_cmd[@]}"
|
||||
printf 'Übertragung abgeschlossen.\n'
|
||||
if $install_service; then
|
||||
"${service_cmd[@]}"
|
||||
fi
|
||||
Reference in new issue
Block a user