Initiales Halloween-Projekt

This commit is contained in:
marcinator_2 committed 2026-10-01 03:19:17 +02:00
commit e071fbad2a
36 files changed
+2220

No files matched your search

+21
View File
@@ -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
+93
View File
@@ -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.
+395
View File
@@ -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
View File
@@ -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/).
+66
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
"""Lokale Steueroberfläche für das Halloween-Grab."""
+118
View File
@@ -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,
}
+133
View File
@@ -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",
}
+123
View File
@@ -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>
+127
View File
@@ -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
+44
View File
@@ -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.
+24
View File
@@ -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.
+15
View File
@@ -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.
+43
View File
@@ -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.
+2
View File
@@ -0,0 +1,2 @@
-r requirements.txt
httpx>=0.27,<1
+3
View File
@@ -0,0 +1,3 @@
fastapi>=0.115,<1
uvicorn>=0.30,<1
Pillow>=10,<13
+19
View File
@@ -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()
+58
View File
@@ -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()
+54
View File
@@ -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()
+50
View File
@@ -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)
+35
View File
@@ -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))
+88
View File
@@ -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"
+106
View File
@@ -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')
+132
View File
@@ -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')
+44
View File
@@ -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')
+175
View File
@@ -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)
+24
View File
@@ -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)
+15
View File
@@ -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'))
+16
View File
@@ -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'))
+107
View File
@@ -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